Repository: Onemind-Services-LLC/netbox-secrets (language: python)

Issue:
Added model extension to other apps
# Pull request checklist

## Description

<!--- Describe your changes in detail -->

This PR does not change the UI functionality but under the hood, I have made several modification to how things were connected.

- Generic Relation to `devices` and `virtual machine` has been removed to allow association with other objects easily
- Added new settings that are user-defined to auto-magically generate the template extension code for defined apps. This allows for greater flexibility and can essentially allow a user to display `secret` panel form other objects.
- Removed redundant and unused code
- Fixed minor 304 issues in the TS code
- Add form can only be accessed for objects that exist on NetBox. This also prevents a user from re-assigning the secret as that can lead to unexpected issues.
- HTML code has been rewritten to add reusable code as much as possible and simplify the old implementation


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..61e1ad1
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,23 @@
+# Changelog
+
+## [v1.4.0](https://github.com/Onemind-Services-LLC/netbox-secrets/tree/v1.4.0) (2022-12-30)
+
+[Full Changelog](https://github.com/Onemind-Services-LLC/netbox-secrets/compare/69f98839760e72ea8372f6eb688f584840eedcc4...v1.4.0)
+
+**Closed issues:**
+
+- Recheck tests [\#2](https://github.com/Onemind-Services-LLC/netbox-secrets/issues/2)
+
+**Merged pull requests:**
+
+- Prepare for v1.4.0 [\#9](https://github.com/Onemind-Services-LLC/netbox-secrets/pull/9) ([abhi1693](https://github.com/abhi1693))
+- Added workflows [\#8](https://github.com/Onemind-Services-LLC/netbox-secrets/pull/8) ([abhi1693](https://github.com/abhi1693))
+- Added prerequisite models [\#7](https://github.com/Onemind-Services-LLC/netbox-secrets/pull/7) ([kprince28](https://github.com/kprince28))
+- Bump minimatch from 3.0.4 to 3.1.2 in /netbox\_secrets/project-static [\#6](https://github.com/Onemind-Services-LLC/netbox-secrets/pull/6) ([dependabot[bot]](https://github.com/apps/dependabot))
+- Bump json5 from 2.2.0 to 2.2.2 in /netbox\_secrets/project-static [\#5](https://github.com/Onemind-Services-LLC/netbox-secrets/pull/5) ([dependabot[bot]](https://github.com/apps/dependabot))
+- Updated test cases [\#4](https://github.com/Onemind-Services-LLC/netbox-secrets/pull/4) ([kprince28](https://github.com/kprince28))
+- Updated to be compatible with NetBox v3.3 [\#1](https://github.com/Onemind-Services-LLC/netbox-secrets/pull/1) ([kprince28](https://github.com/kprince28))
+
+
+
+\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)*
diff --git a/README.md b/README.md
index e715d5a..81f9080 100644
--- a/README.md
+++ b/README.md
@@ -1,17 +1,39 @@
-Netbox Secret Store
----
+# Netbox Secrets
 
-This is the continuation of the secrets app.
+This is the continuation of the [NetBox Secretstore](https://github.com/DanSheps/netbox-secretstore) app.
 
-Installation
-----
+# Installation
 
 * Install NetBox as per NetBox documentation
 * Add to local_requirements.txt:
-  * `netbox-secrets`
+  * `git+https://github.com/Onemind-Services-LLC/netbox-secrets`
 * Install requirements: `./venv/bin/pip install -r local_requirements.txt`
 * Add to PLUGINS in NetBox configuration:
   * `'netbox_secrets',`
 * Run migration: `./venv/bin/python netbox/manage.py migrate`
 * Run collectstatic: `./venv/bin/python netbox/manage.py collectstatic --no-input`
 
+# Configuration
+
+The following options are available in the configuration file:
+
+- `apps`
+  - __Type__: `List`
+  - __Description__: List of apps to enable
+  - __Default__: `['dcim.device', 'virtualization.virtualmachine']`
+- `display_default`
+  - __Type__: `String`
+  - __Description__: Where to display the secret on the detail page of the defined apps
+  - __Default__: `left_page`
+  - __Options__: `left_page`, `right_page`, `full_width_page`
+- `display_setting`
+  - __Type__: `Dict`
+  - __Description__: Set display setting for concrete model
+  - __Default__: `{}`
+  - __Options__: `{'app.model': 'display_default'}`
+  - __Example__: `{'dcim.device': 'full_width_page', 'virtualization.virtualmachine': 'right_page'}`
+- `public_key_size`
+  - __Type__: `Integer`
+  - __Description__: Size of the public key
+  - __Default__: `2048`
+  - __Options__: `2048`, `4096`, `8192`
diff --git a/netbox_secrets/__init__.py b/netbox_secrets/__init__.py
index a5e007f..09aa98b 100644
--- a/netbox_secrets/__init__.py
+++ b/netbox_secrets/__init__.py
@@ -1,8 +1,7 @@
-from extras.plugins import PluginConfig
-
-
 from importlib.metadata import metadata
 
+from extras.plugins import PluginConfig
+
 metadata = metadata('netbox_secrets')
 
 
@@ -18,7 +17,10 @@ class NetBoxSecrets(PluginConfig):
     max_version = '3.3.99'
     required_settings = []
     default_settings = {
-        'public_key_size': 2048
+        'apps': ['dcim.device', 'virtualization.virtualmachine'],
+        'display_default': 'left_page',
+        'display_setting': {},
+        'public_key_size': 2048,
     }
 
 
diff --git a/netbox_secrets/admin.py b/netbox_secrets/admin.py
index a30de10..71446a7 100644
--- a/netbox_secrets/admin.py
+++ b/netbox_secrets/admin.py
@@ -28,7 +28,7 @@ def get_actions(self, request):
             del actions['activate_selected']
         return actions
 
-    def activate_selected(modeladmin, request, queryset):
+    def activate_selected(self, request, queryset):
         """
         Enable bulk activation of UserKeys
         """
diff --git a/netbox_secrets/api/serializers.py b/netbox_secrets/api/serializers.py
index 5338d9d..83b3b50 100644
--- a/netbox_secrets/api/serializers.py
+++ b/netbox_secrets/api/serializers.py
@@ -3,15 +3,16 @@
 from rest_framework import serializers
 
 from netbox.api.fields import ContentTypeField
-from netbox.api.serializers import NetBoxModelSerializer, NestedGroupModelSerializer
-from netbox_secrets.constants import SECRET_ASSIGNMENT_MODELS
-from netbox_secrets.models import Secret, SecretRole
+from netbox.api.serializers import NetBoxModelSerializer
+from netbox.constants import NESTED_SERIALIZER_PREFIX
 from utilities.api import get_serializer_for_model
 from .nested_serializers import *
+from ..constants import SECRET_ASSIGNABLE_MODELS
+from ..models import Secret, SecretRole
 
 
 #
-# Secrets
+# Secret Roles
 #
 
 class SecretRoleSerializer(NetBoxModelSerializer):
@@ -26,10 +27,14 @@ class Meta:
         ]
 
 
+#
+# Secrets
+#
+
 class SecretSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='plugins-api:netbox_secrets-api:secret-detail')
     assigned_object_type = ContentTypeField(
-        queryset=ContentType.objects.filter(SECRET_ASSIGNMENT_MODELS)
+        queryset=ContentType.objects.filter(SECRET_ASSIGNABLE_MODELS)
     )
     assigned_object = serializers.SerializerMethodField(read_only=True)
     role = NestedSecretRoleSerializer()
@@ -45,12 +50,11 @@ class Meta:
 
     @swagger_serializer_method(serializer_or_field=serializers.DictField)
     def get_assigned_object(self, obj):
-        serializer = get_serializer_for_model(obj.assigned_object, prefix='Nested')
+        serializer = get_serializer_for_model(obj.assigned_object, prefix=NESTED_SERIALIZER_PREFIX)
         context = {'request': self.context['request']}
         return serializer(obj.assigned_object, context=context).data
 
     def validate(self, data):
-
         # Encrypt plaintext data using the master key provided from the view context
         if data.get('plaintext'):
             s = Secret(plaintext=data['plaintext'])
diff --git a/netbox_secrets/api/views.py b/netbox_secrets/api/views.py
index d9dd19b..65a20a2 100644
--- a/netbox_secrets/api/views.py
+++ b/netbox_secrets/api/views.py
@@ -1,9 +1,9 @@
 import base64
 
 from Crypto.PublicKey import RSA
+from django.conf import settings
 from django.http import HttpResponseBadRequest
 from drf_yasg import openapi
-from drf_yasg.openapi import Parameter
 from drf_yasg.utils import swagger_auto_schema
 from rest_framework.exceptions import ValidationError
 from rest_framework.permissions import IsAuthenticated
@@ -18,6 +18,9 @@
 from utilities.utils import count_related
 from . import serializers
 
+plugin_settings = settings.PLUGINS_CONFIG.get('netbox_secrets', {})
+public_key_size = plugin_settings.get('public_key_size')
+
 ERR_USERKEY_MISSING = "No UserKey found for the current user."
 ERR_USERKEY_INACTIVE = "UserKey has not been activated for decryption."
 ERR_PRIVKEY_MISSING = "Private key was not provided."
@@ -28,6 +31,7 @@ class SecretsRootView(APIRootView):
     """
     Secrets API root view
     """
+
     def get_view_name(self):
         return 'Secrets'
 
@@ -39,7 +43,7 @@ def get_view_name(self):
 class SecretRoleViewSet(NetBoxModelViewSet):
     queryset = SecretRole.objects.annotate(
         secret_count=count_related(Secret, 'role')
-    )
+    ).prefetch_related('tags')
     serializer_class = serializers.SecretRoleSerializer
     filterset_class = filtersets.SecretRoleFilterSet
 
@@ -127,16 +131,7 @@ def list(self, request, *args, **kwargs):
 class GetSessionKeyViewSet(ViewSet):
     """
     Retrieve a temporary session key to use for encrypting and decrypting secrets via the API. The user's private RSA
-    key is POSTed with the name `private_key`. An example:
-
-        curl -v -X POST -H "Authorization: Token <token>" -H "Accept: application/json; indent=4" \\
-        --data-urlencode "private_key@<filename>" https://netbox/api/plugins/netbox_secrets/get-session-key/
-
-    This request will yield a base64-encoded session key to be included in an `X-Session-Key` header in future requests:
-
-        {
-            "session_key": "+8t4SI6XikgVmB5+/urhozx9O5qCQANyOk1MNe6taRf="
-        }
+    key is POSTed with the name `private_key`.
 
     This endpoint accepts one optional parameter: `preserve_key`. If True and a session key exists, the existing session
     key will be returned instead of a new one.
@@ -144,14 +139,21 @@ class GetSessionKeyViewSet(ViewSet):
     permission_classes = [IsAuthenticated]
 
     @swagger_auto_schema(
-        manual_parameters=[
-            Parameter(
-                name='private_key',
-                in_='query',
-                required=True,
-                type=openapi.TYPE_STRING
-            )
-        ]
+        request_body=openapi.Schema(
+            type=openapi.TYPE_OBJECT,
+            required=['private_key'],
+            properties={
+                'private_key': openapi.Schema(
+                    type=openapi.TYPE_STRING,
+                    description='Private RSA key used to decrypt the session key',
+                ),
+                'preserve_key': openapi.Schema(
+                    type=openapi.TYPE_BOOLEAN,
+                    description='Preserve existing session key',
+                    default=False
+                )
+            },
+        ),
     )
     def create(self, request):
 
@@ -178,7 +180,7 @@ def create(self, request):
         except SessionKey.DoesNotExist:
             current_session_key = None
 
-        if current_session_key and request.GET.get('preserve_key', False):
+        if current_session_key and request.data.get('preserve_key', False):
 
             # Retrieve the existing session key
             key = current_session_key.get_session_key(master_key)
@@ -217,17 +219,44 @@ class GenerateRSAKeyPairViewSet(ViewSet):
     """
     permission_classes = [IsAuthenticated]
 
+    @swagger_auto_schema(
+        manual_parameters=[
+            openapi.Parameter(
+                name='key_size',
+                in_=openapi.IN_QUERY,
+                type=openapi.TYPE_INTEGER,
+                description='Number of bits in the key',
+                default=public_key_size
+            ),
+        ],
+        responses={
+            200: openapi.Schema(
+                type=openapi.TYPE_OBJECT,
+                required=['public_key', 'private_key'],
+                properties={
+                    'public_key': openapi.Schema(
+                        type=openapi.TYPE_STRING,
+                        description='Public RSA key',
+                    ),
+                    'private_key': openapi.Schema(
+                        type=openapi.TYPE_STRING,
+                        description='Private RSA key',
+                    )
+                },
+            )
+        }
+    )
     def list(self, request):
 
         # Determine what size key to generate
         try:
-            key_size = request.GET.get('key_size', 2048)
+            key_size = request.GET.get('key_size', public_key_size)
             key_size = int(key_size)
-        except ValueError as e:
-            key_size = 2048
+        except ValueError:
+            key_size = public_key_size
 
         if key_size not in range(2048, 4097, 256):
-            key_size = 2048
+            key_size = public_key_size
 
         # Export RSA private and public keys in PEM format
         key = RSA.generate(key_size)
diff --git a/netbox_secrets/constants.py b/netbox_secrets/constants.py
index 1680382..c32257f 100644
--- a/netbox_secrets/constants.py
+++ b/netbox_secrets/constants.py
@@ -1,13 +1,15 @@
+from django.conf import settings
 from django.db.models import Q
 
+plugin_settings = settings.PLUGINS_CONFIG.get('netbox_secrets')
 
 #
 # Secrets
 #
 
-SECRET_ASSIGNMENT_MODELS = Q(
-    Q(app_label='dcim', model='device') |
-    Q(app_label='virtualization', model='virtualmachine')
-)
+SECRET_ASSIGNABLE_MODELS = Q()
+for app_model in plugin_settings.get('apps'):
+    app_label, model = app_model.split('.')
+    SECRET_ASSIGNABLE_MODELS |= Q(app_label=app_label, model=model)
 
 SECRET_PLAINTEXT_MAX_LENGTH = 65535
diff --git a/netbox_secrets/filtersets.py b/netbox_secrets/filtersets.py
index 893f202..b2798b5 100644
--- a/netbox_secrets/filtersets.py
+++ b/netbox_secrets/filtersets.py
@@ -1,10 +1,9 @@
 import django_filters
+from django.contrib.contenttypes.models import ContentType
 from django.db.models import Q
-
-from dcim.models import Device
-from extras.filters import TagFilter
 from netbox.filtersets import NetBoxModelFilterSet
-from virtualization.models import VirtualMachine
+
+from .constants import SECRET_ASSIGNABLE_MODELS
 from .models import Secret, SecretRole
 
 __all__ = (
@@ -41,10 +40,13 @@ class SecretFilterSet(NetBoxModelFilterSet):
         method='search',
         label='Search',
     )
+
     name = django_filters.ModelMultipleChoiceFilter(
-        queryset=SecretRole.objects.all(),
-        field_name='name'
+        queryset=Secret.objects.all(),
+        field_name='name',
+        label='Name',
     )
+
     role_id = django_filters.ModelMultipleChoiceFilter(
         queryset=SecretRole.objects.all(),
         label='Role (ID)',
@@ -55,38 +57,20 @@ class SecretFilterSet(NetBoxModelFilterSet):
         to_field_name='slug',
         label='Role (slug)',
     )
-    device = django_filters.ModelMultipleChoiceFilter(
-        field_name='device__name',
-        queryset=Device.objects.all(),
-        to_field_name='name',
-        label='Device (name)',
-    )
-    device_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='device',
-        queryset=Device.objects.all(),
-        label='Device (ID)',
-    )
-    virtual_machine = django_filters.ModelMultipleChoiceFilter(
-        field_name='virtual_machine__name',
-        queryset=VirtualMachine.objects.all(),
-        to_field_name='name',
-        label='Virtual machine (name)',
-    )
-    virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='virtual_machine',
-        queryset=VirtualMachine.objects.all(),
-        label='Virtual machine (ID)',
+
+    assigned_object_type_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='assigned_object_type',
+        queryset=ContentType.objects.filter(SECRET_ASSIGNABLE_MODELS),
+        label='Object type (ID)',
     )
-    tag = TagFilter()
 
     class Meta:
         model = Secret
-        fields = ['id', 'name']
+        fields = ['id', 'assigned_object_type_id', 'assigned_object_id', 'role_id', 'role', 'name']
 
     def search(self, queryset, name, value):
         if not value.strip():
             return queryset
         return queryset.filter(
-            Q(name__icontains=value) |
-            Q(device__name__icontains=value)
+            Q(name__icontains=value)
         )
diff --git a/netbox_secrets/forms/__init__.py b/netbox_secrets/forms/__init__.py
index ddc9961..62e376a 100644
--- a/netbox_secrets/forms/__init__.py
+++ b/netbox_secrets/forms/__init__.py
@@ -7,7 +7,6 @@
     'SecretRoleBulkEditForm',
     'SecretForm',
     'SecretCSVForm',
-    'SecretBulkEditForm',
     'SecretFilterForm',
     'UserKeyForm',
     'ActivateUserKeyForm'
diff --git a/netbox_secrets/forms/fields.py b/netbox_secrets/forms/fields.py
deleted file mode 100644
index 1bf0f09..0000000
--- a/netbox_secrets/forms/fields.py
+++ /dev/null
@@ -1,57 +0,0 @@
-import django_filters
-from django.forms import BoundField
-from django.urls import reverse
-
-from utilities.forms import widgets, DynamicModelChoiceField
-
-
-class PluginDynamicModelChoiceMixin:
-
-    def get_bound_field(self, form, field_name):
-        bound_field = BoundField(form, self, field_name)
-
-        # Set initial value based on prescribed child fields (if not already set)
-        if not self.initial and self.initial_params:
-            filter_kwargs = {}
-            for kwarg, child_field in self.initial_params.items():
-                value = form.initial.get(child_field.lstrip('$'))
-                if value:
-                    filter_kwargs[kwarg] = value
-            if filter_kwargs:
-                self.initial = self.queryset.filter(**filter_kwargs).first()
-
-        # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options
-        # will be populated on-demand via the APISelect widget.
-        data = bound_field.value()
-        if data:
-            field_name = getattr(self, 'to_field_name') or 'pk'
-            filter = self.filter(field_name=field_name)
-            try:
-                self.queryset = filter.filter(self.queryset, data)
-            except TypeError:
-                # Catch any error caused by invalid initial data passed from the user
-                self.queryset = self.queryset.none()
-        else:
-            self.queryset = self.queryset.none()
-
-        # Set the data URL on the APISelect widget (if not already set)
-        widget = bound_field.field.widget
-        if not widget.attrs.get('data-url'):
-            app_label = self.queryset.model._meta.app_label
-            model_name = self.queryset.model._meta.model_name
-            data_url = reverse('plugins-api:{}-api:{}-list'.format(app_label, model_name))
-            widget.attrs['data-url'] = data_url
-
-        return bound_field
-
-
-class PluginDynamicModelChoiceField(PluginDynamicModelChoiceMixin, DynamicModelChoiceField):
-    pass
-
-
-class PluginDynamicModelMultipleChoiceField(PluginDynamicModelChoiceMixin, DynamicModelChoiceField):
-    """
-    A multiple-choice version of DynamicModelChoiceField.
-    """
-    filter = django_filters.ModelMultipleChoiceFilter
-    widget = widgets.APISelectMultiple
diff --git a/netbox_secrets/forms/secrets.py b/netbox_secrets/forms/secrets.py
index 7f389dd..2b311be 100644
--- a/netbox_secrets/forms/secrets.py
+++ b/netbox_secrets/forms/secrets.py
@@ -1,14 +1,14 @@
 from Crypto.Cipher import PKCS1_OAEP
 from Crypto.PublicKey import RSA
 from django import forms
+from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext as _
 
-from dcim.models import Device
 from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelCSVForm, NetBoxModelFilterSetForm, NetBoxModelForm
 from netbox_secrets.constants import *
 from netbox_secrets.models import Secret, SecretRole, UserKey
-from utilities.forms import CSVModelChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
-from virtualization.models import VirtualMachine
+from utilities.forms import ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, \
+    SlugField
 
 
 def validate_rsa_key(key, is_secret=True):
@@ -65,6 +65,7 @@ class SecretRoleBulkEditForm(NetBoxModelBulkEditForm):
     )
 
     model = SecretRole
+
     class Meta:
         nullable_fields = ['description']
 
@@ -86,14 +87,6 @@ class SecretRoleFilterForm(NetBoxModelFilterSetForm):
 #
 
 class SecretForm(NetBoxModelForm):
-    device = DynamicModelChoiceField(
-        queryset=Device.objects.all(),
-        required=False
-    )
-    virtual_machine = DynamicModelChoiceField(
-        queryset=VirtualMachine.objects.all(),
-        required=False
-    )
     plaintext = forms.CharField(
         max_length=SECRET_PLAINTEXT_MAX_LENGTH,
         required=False,
@@ -117,21 +110,10 @@ class SecretForm(NetBoxModelForm):
     class Meta:
         model = Secret
         fields = (
-            'device', 'virtual_machine', 'role', 'name', 'plaintext', 'plaintext2', 'tags',
+            'role', 'name', 'plaintext', 'plaintext2', 'tags',
         )
 
     def __init__(self, *args, **kwargs):
-
-        # Initialize helper selectors
-        instance = kwargs.get('instance')
-        initial = kwargs.get('initial', {}).copy()
-        if instance:
-            if type(instance.assigned_object) is Device:
-                initial['device'] = instance.assigned_object
-            elif type(instance.assigned_object) is VirtualMachine:
-                initial['virtual_machine'] = instance.assigned_object
-        kwargs['initial'] = initial
-
         super().__init__(*args, **kwargs)
 
         # A plaintext value is required when creating a new Secret
@@ -141,99 +123,12 @@ def __init__(self, *args, **kwargs):
     def clean(self):
         super().clean()
 
-        if not self.cleaned_data['device'] and not self.cleaned_data['virtual_machine']:
-            raise forms.ValidationError("Secrets must be assigned to a device or virtual machine.")
-
-        if self.cleaned_data['device'] and self.cleaned_data['virtual_machine']:
-            raise forms.ValidationError("Cannot select both a device and virtual machine for secret assignment.")
-
         # Verify that the provided plaintext values match
         if self.cleaned_data['plaintext'] != self.cleaned_data['plaintext2']:
             raise forms.ValidationError({
                 'plaintext2': "The two given plaintext values do not match. Please check your input."
             })
 
-    def save(self, *args, **kwargs):
-        # Set assigned object
-        self.instance.assigned_object = self.cleaned_data.get('device') or self.cleaned_data.get('virtual_machine')
-
-        return super().save(*args, **kwargs)
-
-
-class SecretCSVForm(NetBoxModelCSVForm):
-    role = CSVModelChoiceField(
-        queryset=SecretRole.objects.all(),
-        to_field_name='name',
-        help_text='Assigned role'
-    )
-    device = CSVModelChoiceField(
-        queryset=Device.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Assigned device'
-    )
-    virtual_machine = CSVModelChoiceField(
-        queryset=VirtualMachine.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Assigned VM'
-    )
-    plaintext = forms.CharField(
-        help_text='Plaintext secret data'
-    )
-
-    class Meta:
-        model = Secret
-        fields = ('role', 'name', 'plaintext', 'device', 'virtual_machine')
-        help_texts = {
-            'name': 'Name or username',
-        }
-
-    def clean(self):
-        super().clean()
-
-        device = self.cleaned_data.get('device')
-        virtual_machine = self.cleaned_data.get('virtual_machine')
-
-        # Validate device OR VM is assigned
-        if not device and not virtual_machine:
-            raise forms.ValidationError("Secret must be assigned to a device or a virtual machine")
-        if device and virtual_machine:
-            raise forms.ValidationError("Secret cannot be assigned to both a device and a virtual machine")
-
-    def save(self, *args, **kwargs):
-
-        # Set device/VM assignment
-        self.instance.assigned_object = self.cleaned_data['device'] or self.cleaned_data['virtual_machine']
-
-        s = super().save(*args, **kwargs)
-
-        # Set plaintext on instance
-        s.plaintext = str(self.cleaned_data['plaintext'])
-
-        return s
-
-
-class SecretBulkEditForm(NetBoxModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=Secret.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    role = DynamicModelChoiceField(
-        queryset=SecretRole.objects.all(),
-        required=False
-    )
-    name = forms.CharField(
-        max_length=100,
-        required=False
-    )
-
-    model = Secret
-    class Meta:
-        nullable_fields = [
-            'name',
-        ]
-
 
 class SecretFilterForm(NetBoxModelFilterSetForm):
     model = Secret
@@ -241,24 +136,16 @@ class SecretFilterForm(NetBoxModelFilterSetForm):
         required=False,
         label=_('Search')
     )
-    name = DynamicModelMultipleChoiceField(
-        queryset=Secret.objects.all(),
-        required=False
+    assigned_object_type_id = ContentTypeMultipleChoiceField(
+        queryset=ContentType.objects.filter(SECRET_ASSIGNABLE_MODELS),
+        required=False,
+        label='Object type(s)'
     )
     role_id = DynamicModelMultipleChoiceField(
         queryset=SecretRole.objects.all(),
         required=False,
         label=_('Role')
     )
-    device = DynamicModelMultipleChoiceField(
-        queryset=Device.objects.all(),
-        required=False
-    )
-
-    virtual_machine = DynamicModelMultipleChoiceField(
-        queryset=VirtualMachine.objects.all(),
-        required=False
-    )
 
 
 #
diff --git a/netbox_secrets/models/secrets.py b/netbox_secrets/models/secrets.py
index b60a600..d3a42c0 100644
--- a/netbox_secrets/models/secrets.py
+++ b/netbox_secrets/models/secrets.py
@@ -430,17 +430,3 @@ def validate(self, plaintext):
         if not self.hash:
             raise Exception("Hash has not been generated for this secret.")
         return check_password(plaintext, self.hash, preferred=SecretValidationHasher())
-
-GenericRelation(
-    to=Secret,
-    content_type_field='assigned_object_type',
-    object_id_field='assigned_object_id',
-    related_query_name='device'
-).contribute_to_class(Device, 'secrets')
-
-GenericRelation(
-    to=Secret,
-    content_type_field='assigned_object_type',
-    object_id_field='assigned_object_id',
-    related_query_name='virtual_machine'
-).contribute_to_class(VirtualMachine, 'secrets')
diff --git a/netbox_secrets/navigation.py b/netbox_secrets/navigation.py
index 16b8c9b..7aa1f46 100644
--- a/netbox_secrets/navigation.py
+++ b/netbox_secrets/navigation.py
@@ -1,4 +1,5 @@
-from extras.plugins import PluginMenuItem, PluginMenuButton
+from extras.plugins import PluginMenuButton, PluginMenuItem
+from utilities.choices import ButtonColorChoices
 
 menu_items = (
     PluginMenuItem(
@@ -15,14 +16,14 @@
                 link="plugins:netbox_secrets:secretrole_add",
                 title="Add Secret Role",
                 icon_class="mdi mdi-plus-thick",
-                color="green",
+                color=ButtonColorChoices.GREEN,
                 permissions=["netbox_secrets.add_secretrole"],
             ),
             PluginMenuButton(
                 link="plugins:netbox_secrets:secretrole_import",
                 title="Import Secret Role",
                 icon_class="mdi mdi-upload",
-                color="teal",
+                color=ButtonColorChoices.CYAN,
                 permissions=["netbox_secrets.add_secretrole"],
             ),
         ),
@@ -31,21 +32,5 @@
         link_text="Secrets",
         link="plugins:netbox_secrets:secret_list",
         permissions=["netbox_secrets.view_secret"],
-        buttons=(
-            PluginMenuButton(
-                link="plugins:netbox_secrets:secret_add",
-                title="Add Secret",
-                icon_class="mdi mdi-plus-thick",
-                color="green",
-                permissions=["netbox_secrets.add_secret"],
-            ),
-            PluginMenuButton(
-                link="plugins:netbox_secrets:secret_import",
-                title="Import Secret",
-                icon_class="mdi mdi-upload",
-                color="teal",
-                permissions=["netbox_secrets.add_secret"],
-            ),
-        ),
     ),
 )
diff --git a/netbox_secrets/project-static/src/secrets.ts b/netbox_secrets/project-static/src/secrets.ts
index 36c730c..ba7002f 100644
--- a/netbox_secrets/project-static/src/secrets.ts
+++ b/netbox_secrets/project-static/src/secrets.ts
@@ -27,7 +27,7 @@ function initGenerateKeyPair() {
       }
     }
     // Fetch the key pair from the API.
-    apiGetBase<APIKeyPair>('/api/plugins/secrets/generate-rsa-key-pair').then(data => {
+    apiGetBase<APIKeyPair>('/api/plugins/secrets/generate-rsa-key-pair/').then(data => {
       if (!hasError(data)) {
         // If key pair generation was successful, set the textarea elements' value to the generated
         // values.
@@ -98,7 +98,7 @@ function initLockUnlock() {
   function unlock(id: string | null) {
     const target = document.getElementById(`secret_${id}`) as HTMLDivElement | HTMLInputElement;
     if (typeof id === 'string' && id !== '') {
-      apiGetBase<APISecret>(`/api/plugins/secrets/secrets/${id}`).then(data => {
+      apiGetBase<APISecret>(`/api/plugins/secrets/secrets/${id}/`).then(data => {
         if (!hasError(data)) {
           const { plaintext } = data;
           // `plaintext` is the plain text value of the secret. If it is null, it has not been
diff --git a/netbox_secrets/static/netbox_secrets/secrets.js b/netbox_secrets/static/netbox_secrets/secrets.js
index ab092f4..9bdb8a1 100644
--- a/netbox_secrets/static/netbox_secrets/secrets.js
+++ b/netbox_secrets/static/netbox_secrets/secrets.js
@@ -1,5 +1,5 @@
 (()=>{var R=Object.create;var E=Object.defineProperty;var I=Object.getOwnPropertyDescriptor;var M=Object.getOwnPropertyNames;var P=Object.getPrototypeOf,C=Object.prototype.hasOwnProperty;var B=e=>E(e,"__esModule",{value:!0});var D=(e,r)=>()=>(r||e((r={exports:{}}).exports,r),r.exports);var H=(e,r,o)=>{if(r&&typeof r=="object"||typeof r=="function")for(let t of M(r))!C.call(e,t)&&t!=="default"&&E(e,t,{get:()=>r[t],enumerable:!(o=I(r,t))||o.enumerable});return e},_=e=>H(B(E(e!=null?R(P(e)):{},"default",e&&e.__esModule&&"default"in e?{get:()=>e.default,enumerable:!0}:{value:e,enumerable:!0})),e);var p=(e,r,o)=>new Promise((t,i)=>{var c=a=>{try{s(o.next(a))}catch(l){i(l)}},n=a=>{try{s(o.throw(a))}catch(l){i(l)}},s=a=>a.done?t(a.value):Promise.resolve(a.value).then(c,n);s((o=o.apply(e,r)).next())});var T=D(v=>{"use strict";v.parse=O;v.serialize=F;var q=decodeURIComponent,$=encodeURIComponent,K=/; */,m=/^[\u0009\u0020-\u007e\u0080-\u00ff]+$/;function O(e,r){if(typeof e!="string")throw new TypeError("argument str must be a string");for(var o={},t=r||{},i=e.split(K),c=t.decode||q,n=0;n<i.length;n++){var s=i[n],a=s.indexOf("=");if(!(a<0)){var l=s.substr(0,a).trim(),u=s.substr(++a,s.length).trim();u[0]=='"'&&(u=u.slice(1,-1)),o[l]==null&&(o[l]=U(u,c))}}return o}function F(e,r,o){var t=o||{},i=t.encode||$;if(typeof i!="function")throw new TypeError("option encode is invalid");if(!m.test(e))throw new TypeError("argument name is invalid");var c=i(r);if(c&&!m.test(c))throw new TypeError("argument val is invalid");var n=e+"="+c;if(t.maxAge!=null){var s=t.maxAge-0;if(isNaN(s)||!isFinite(s))throw new TypeError("option maxAge is invalid");n+="; Max-Age="+Math.floor(s)}if(t.domain){if(!m.test(t.domain))throw new TypeError("option domain is invalid");n+="; Domain="+t.domain}if(t.path){if(!m.test(t.path))throw new TypeError("option path is invalid");n+="; Path="+t.path}if(t.expires){if(typeof t.expires.toUTCString!="function")throw new TypeError("option expires is invalid");n+="; Expires="+t.expires.toUTCString()}if(t.httpOnly&&(n+="; HttpOnly"),t.secure&&(n+="; Secure"),t.sameSite){var a=typeof t.sameSite=="string"?t.sameSite.toLowerCase():t.sameSite;switch(a){case!0:n+="; SameSite=Strict";break;case"lax":n+="; SameSite=Lax";break;case"strict":n+="; SameSite=Strict";break;case"none":n+="; SameSite=None";break;default:throw new TypeError("option sameSite is invalid")}}return n}function U(e,r){try{return r(e)}catch(o){return e}}});function d(e,r,o,t){let i="mdi-alert";switch(e){case"warning":i="mdi-alert";break;case"success":i="mdi-check-circle";break;case"info":i="mdi-information";break;case"danger":i="mdi-alert";break}let c=document.createElement("div");c.setAttribute("class","toast-container position-fixed bottom-0 end-0 m-3");let n=document.createElement("div");n.setAttribute("class",`toast bg-${e}`),n.setAttribute("role","alert"),n.setAttribute("aria-live","assertive"),n.setAttribute("aria-atomic","true");let s=document.createElement("div");s.setAttribute("class",`toast-header bg-${e} text-body`);let a=document.createElement("i");a.setAttribute("class",`mdi ${i}`);let l=document.createElement("strong");l.setAttribute("class","me-auto ms-1"),l.innerText=r;let u=document.createElement("button");u.setAttribute("type","button"),u.setAttribute("class","btn-close"),u.setAttribute("data-bs-dismiss","toast"),u.setAttribute("aria-label","Close");let y=document.createElement("div");if(y.setAttribute("class","toast-body"),s.appendChild(a),s.appendChild(l),typeof t!="undefined"){let w=document.createElement("small");w.setAttribute("class","text-muted"),s.appendChild(w)}return s.appendChild(u),y.innerText=o.trim(),n.appendChild(s),n.appendChild(y),c.appendChild(n),document.body.appendChild(c),new window.Toast(n)}var h=_(T());function S(e){return"error"in e&&"exception"in e}function f(e){return"error"in e}function k(e){return"value"in e&&"required"in e}function j(){let{csrftoken:e}=h.default.parse(document.cookie);if(typeof e=="undefined")throw new Error("Invalid or missing CSRF token");return e}function A(e,r,o){return p(this,null,function*(){let t=j(),i=new Headers({"X-CSRFToken":t}),c;typeof o!="undefined"&&(c=JSON.stringify(o),i.set("content-type","application/json"),i.set("Accept","application/json"));let n=yield fetch(e,{method:r,body:c,headers:i,credentials:"same-origin"}),s=n.headers.get("Content-Type");if(typeof s=="string"&&s.includes("text"))return{error:yield n.text()};let a=yield n.json();return!n.ok&&Array.isArray(a)?{error:a.join(`
-`)}:!n.ok&&"detail"in a?{error:a.detail}:a})}function x(e,r){return p(this,null,function*(){return yield A(e,"POST",r)})}function g(e){return p(this,null,function*(){return yield A(e,"GET")})}function G(){let e=document.getElementById("new_keypair_modal"),r=document.getElementById("use_new_pubkey");if(e===null||r===null)return;let o=e.querySelector("textarea#new_pubkey"),t=e.querySelector("textarea#new_privkey");function i(){for(let n of[o,t])n!==null&&n.setAttribute("readonly","");g("/api/plugins/secrets/generate-rsa-key-pair").then(n=>{if(f(n))d("danger","Error",n.error).show();else{let{private_key:s,public_key:a}=n;o!==null&&t!==null&&(o.value=a,t.value=s)}})}function c(){let n=document.getElementById("id_public_key");o!==null&&(n.value=o.value,n.innerText=o.value)}e.addEventListener("shown.bs.modal",()=>i()),r.addEventListener("click",()=>c())}function L(e,r){let o=document.querySelector(`button.unlock-secret[secret-id='${e}']`),t=document.querySelector(`button.lock-secret[secret-id='${e}']`),i=document.querySelector(`button.copy-secret[secret-id='${e}']`);o!==null&&(r==="unlock"&&o.classList.add("d-none"),r==="lock"&&o.classList.remove("d-none")),t!==null&&(r==="unlock"&&t.classList.remove("d-none"),r==="lock"&&t.classList.add("d-none")),i!==null&&(r==="unlock"&&i.classList.remove("d-none"),r==="lock"&&i.classList.add("d-none"))}function N(){let e=new window.Modal("#privkey_modal");function r(t){let i=document.getElementById(`secret_${t}`);typeof t=="string"&&t!==""&&g(`/api/plugins/secrets/secrets/${t}`).then(c=>{if(f(c))c.error.toLowerCase().includes("invalid session key")?e.show():d("danger","Error",c.error).show();else{let{plaintext:n}=c;i!==null&&n!==null?(k(i)?i.value=n:i.innerText=n,L(t,"unlock")):e.show()}})}function o(t){if(typeof t=="string"&&t!==""){let i=document.getElementById(`secret_${t}`);k(i)?i.value="********":i.innerText="********",L(t,"lock")}}for(let t of document.querySelectorAll("button.unlock-secret"))t.addEventListener("click",()=>r(t.getAttribute("secret-id")));for(let t of document.querySelectorAll("button.lock-secret"))t.addEventListener("click",()=>o(t.getAttribute("secret-id")))}function z(e){x("/api/plugins/secrets/get-session-key/",{private_key:e}).then(r=>{if(!f(r))d("success","Session Key Received","You may now unlock secrets.").show();else{let o=r.error;S(r)&&(o+=`
+`)}:!n.ok&&"detail"in a?{error:a.detail}:a})}function x(e,r){return p(this,null,function*(){return yield A(e,"POST",r)})}function g(e){return p(this,null,function*(){return yield A(e,"GET")})}function G(){let e=document.getElementById("new_keypair_modal"),r=document.getElementById("use_new_pubkey");if(e===null||r===null)return;let o=e.querySelector("textarea#new_pubkey"),t=e.querySelector("textarea#new_privkey");function i(){for(let n of[o,t])n!==null&&n.setAttribute("readonly","");g("/api/plugins/secrets/generate-rsa-key-pair/").then(n=>{if(f(n))d("danger","Error",n.error).show();else{let{private_key:s,public_key:a}=n;o!==null&&t!==null&&(o.value=a,t.value=s)}})}function c(){let n=document.getElementById("id_public_key");o!==null&&(n.value=o.value,n.innerText=o.value)}e.addEventListener("shown.bs.modal",()=>i()),r.addEventListener("click",()=>c())}function L(e,r){let o=document.querySelector(`button.unlock-secret[secret-id='${e}']`),t=document.querySelector(`button.lock-secret[secret-id='${e}']`),i=document.querySelector(`button.copy-secret[secret-id='${e}']`);o!==null&&(r==="unlock"&&o.classList.add("d-none"),r==="lock"&&o.classList.remove("d-none")),t!==null&&(r==="unlock"&&t.classList.remove("d-none"),r==="lock"&&t.classList.add("d-none")),i!==null&&(r==="unlock"&&i.classList.remove("d-none"),r==="lock"&&i.classList.add("d-none"))}function N(){let e=new window.Modal("#privkey_modal");function r(t){let i=document.getElementById(`secret_${t}`);typeof t=="string"&&t!==""&&g(`/api/plugins/secrets/secrets/${t}/`).then(c=>{if(f(c))c.error.toLowerCase().includes("invalid session key")?e.show():d("danger","Error",c.error).show();else{let{plaintext:n}=c;i!==null&&n!==null?(k(i)?i.value=n:i.innerText=n,L(t,"unlock")):e.show()}})}function o(t){if(typeof t=="string"&&t!==""){let i=document.getElementById(`secret_${t}`);k(i)?i.value="********":i.innerText="********",L(t,"lock")}}for(let t of document.querySelectorAll("button.unlock-secret"))t.addEventListener("click",()=>r(t.getAttribute("secret-id")));for(let t of document.querySelectorAll("button.lock-secret"))t.addEventListener("click",()=>o(t.getAttribute("secret-id")))}function z(e){x("/api/plugins/secrets/get-session-key/",{private_key:e}).then(r=>{if(!f(r))d("success","Session Key Received","You may now unlock secrets.").show();else{let o=r.error;S(r)&&(o+=`
 ${r.exception}`),d("danger","Failed to Retrieve Session Key",o).show()}})}function J(){for(let e of document.querySelectorAll("#request_session_key")){let r=function(){for(let o of document.querySelectorAll("#user_privkey"))z(o.value),o.value=""};e.addEventListener("click",r)}}function X(){let e=new window.Modal("#privkey_modal");function r(o){document.cookie.indexOf("session_key")===-1&&(o.preventDefault(),e.show())}for(let o of document.querySelectorAll(".requires-session-key")){let t=o.closest("form");t!==null&&t.addEventListener("submit",r)}}function b(){for(let e of[G,N,J,X])e()}document.readyState!=="loading"?b():document.addEventListener("DOMContentLoaded",b);})();
 /*!
  * cookie
diff --git a/netbox_secrets/static/netbox_secrets/secrets.js.map b/netbox_secrets/static/netbox_secrets/secrets.js.map
index cee0f0b..89bc004 100644
--- a/netbox_secrets/static/netbox_secrets/secrets.js.map
+++ b/netbox_secrets/static/netbox_secrets/secrets.js.map
@@ -1,7 +1,7 @@
 {
   "version": 3,
   "sources": ["../node_modules/cookie/index.js", "../src/bs.ts", "../src/util.ts", "../src/secrets.ts", "../src/index.ts"],
-  "sourcesContent": ["/*!\n * cookie\n * Copyright(c) 2012-2014 Roman Shtylman\n * Copyright(c) 2015 Douglas Christopher Wilson\n * MIT Licensed\n */\n\n'use strict';\n\n/**\n * Module exports.\n * @public\n */\n\nexports.parse = parse;\nexports.serialize = serialize;\n\n/**\n * Module variables.\n * @private\n */\n\nvar decode = decodeURIComponent;\nvar encode = encodeURIComponent;\nvar pairSplitRegExp = /; */;\n\n/**\n * RegExp to match field-content in RFC 7230 sec 3.2\n *\n * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ]\n * field-vchar   = VCHAR / obs-text\n * obs-text      = %x80-FF\n */\n\nvar fieldContentRegExp = /^[\\u0009\\u0020-\\u007e\\u0080-\\u00ff]+$/;\n\n/**\n * Parse a cookie header.\n *\n * Parse the given cookie header string into an object\n * The object has the various cookies as keys(names) => values\n *\n * @param {string} str\n * @param {object} [options]\n * @return {object}\n * @public\n */\n\nfunction parse(str, options) {\n  if (typeof str !== 'string') {\n    throw new TypeError('argument str must be a string');\n  }\n\n  var obj = {}\n  var opt = options || {};\n  var pairs = str.split(pairSplitRegExp);\n  var dec = opt.decode || decode;\n\n  for (var i = 0; i < pairs.length; i++) {\n    var pair = pairs[i];\n    var eq_idx = pair.indexOf('=');\n\n    // skip things that don't look like key=value\n    if (eq_idx < 0) {\n      continue;\n    }\n\n    var key = pair.substr(0, eq_idx).trim()\n    var val = pair.substr(++eq_idx, pair.length).trim();\n\n    // quoted values\n    if ('\"' == val[0]) {\n      val = val.slice(1, -1);\n    }\n\n    // only assign once\n    if (undefined == obj[key]) {\n      obj[key] = tryDecode(val, dec);\n    }\n  }\n\n  return obj;\n}\n\n/**\n * Serialize data into a cookie header.\n *\n * Serialize the a name value pair into a cookie string suitable for\n * http headers. An optional options object specified cookie parameters.\n *\n * serialize('foo', 'bar', { httpOnly: true })\n *   => \"foo=bar; httpOnly\"\n *\n * @param {string} name\n * @param {string} val\n * @param {object} [options]\n * @return {string}\n * @public\n */\n\nfunction serialize(name, val, options) {\n  var opt = options || {};\n  var enc = opt.encode || encode;\n\n  if (typeof enc !== 'function') {\n    throw new TypeError('option encode is invalid');\n  }\n\n  if (!fieldContentRegExp.test(name)) {\n    throw new TypeError('argument name is invalid');\n  }\n\n  var value = enc(val);\n\n  if (value && !fieldContentRegExp.test(value)) {\n    throw new TypeError('argument val is invalid');\n  }\n\n  var str = name + '=' + value;\n\n  if (null != opt.maxAge) {\n    var maxAge = opt.maxAge - 0;\n\n    if (isNaN(maxAge) || !isFinite(maxAge)) {\n      throw new TypeError('option maxAge is invalid')\n    }\n\n    str += '; Max-Age=' + Math.floor(maxAge);\n  }\n\n  if (opt.domain) {\n    if (!fieldContentRegExp.test(opt.domain)) {\n      throw new TypeError('option domain is invalid');\n    }\n\n    str += '; Domain=' + opt.domain;\n  }\n\n  if (opt.path) {\n    if (!fieldContentRegExp.test(opt.path)) {\n      throw new TypeError('option path is invalid');\n    }\n\n    str += '; Path=' + opt.path;\n  }\n\n  if (opt.expires) {\n    if (typeof opt.expires.toUTCString !== 'function') {\n      throw new TypeError('option expires is invalid');\n    }\n\n    str += '; Expires=' + opt.expires.toUTCString();\n  }\n\n  if (opt.httpOnly) {\n    str += '; HttpOnly';\n  }\n\n  if (opt.secure) {\n    str += '; Secure';\n  }\n\n  if (opt.sameSite) {\n    var sameSite = typeof opt.sameSite === 'string'\n      ? opt.sameSite.toLowerCase() : opt.sameSite;\n\n    switch (sameSite) {\n      case true:\n        str += '; SameSite=Strict';\n        break;\n      case 'lax':\n        str += '; SameSite=Lax';\n        break;\n      case 'strict':\n        str += '; SameSite=Strict';\n        break;\n      case 'none':\n        str += '; SameSite=None';\n        break;\n      default:\n        throw new TypeError('option sameSite is invalid');\n    }\n  }\n\n  return str;\n}\n\n/**\n * Try decoding a string using a decoding function.\n *\n * @param {string} str\n * @param {function} decode\n * @private\n */\n\nfunction tryDecode(str, decode) {\n  try {\n    return decode(str);\n  } catch (e) {\n    return str;\n  }\n}\n", "type ToastLevel = 'danger' | 'warning' | 'success' | 'info';\n\nexport function createToast(\n  level: ToastLevel,\n  title: string,\n  message: string,\n  extra?: string,\n): InstanceType<typeof window.Toast> {\n  let iconName = 'mdi-alert';\n  switch (level) {\n    case 'warning':\n      iconName = 'mdi-alert';\n      break;\n    case 'success':\n      iconName = 'mdi-check-circle';\n      break;\n    case 'info':\n      iconName = 'mdi-information';\n      break;\n    case 'danger':\n      iconName = 'mdi-alert';\n      break;\n  }\n\n  const container = document.createElement('div');\n  container.setAttribute('class', 'toast-container position-fixed bottom-0 end-0 m-3');\n\n  const main = document.createElement('div');\n  main.setAttribute('class', `toast bg-${level}`);\n  main.setAttribute('role', 'alert');\n  main.setAttribute('aria-live', 'assertive');\n  main.setAttribute('aria-atomic', 'true');\n\n  const header = document.createElement('div');\n  header.setAttribute('class', `toast-header bg-${level} text-body`);\n\n  const icon = document.createElement('i');\n  icon.setAttribute('class', `mdi ${iconName}`);\n\n  const titleElement = document.createElement('strong');\n  titleElement.setAttribute('class', 'me-auto ms-1');\n  titleElement.innerText = title;\n\n  const button = document.createElement('button');\n  button.setAttribute('type', 'button');\n  button.setAttribute('class', 'btn-close');\n  button.setAttribute('data-bs-dismiss', 'toast');\n  button.setAttribute('aria-label', 'Close');\n\n  const body = document.createElement('div');\n  body.setAttribute('class', 'toast-body');\n\n  header.appendChild(icon);\n  header.appendChild(titleElement);\n\n  if (typeof extra !== 'undefined') {\n    const extraElement = document.createElement('small');\n    extraElement.setAttribute('class', 'text-muted');\n    header.appendChild(extraElement);\n  }\n\n  header.appendChild(button);\n\n  body.innerText = message.trim();\n\n  main.appendChild(header);\n  main.appendChild(body);\n  container.appendChild(main);\n  document.body.appendChild(container);\n\n  const toast = new window.Toast(main);\n  return toast;\n}\n", "import Cookie from 'cookie';\n\ntype APIRes<T> = T | ErrorBase | APIError;\ntype Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';\ntype ReqData = URLSearchParams | Dict | undefined | unknown;\n\n/**\n * Type guard to determine if an API response is a detailed error.\n *\n * @param data API JSON Response\n * @returns Type guard for `data`.\n */\nexport function isApiError(data: Record<string, unknown>): data is APIError {\n  return 'error' in data && 'exception' in data;\n}\n\n/**\n * Type guard to determine if an API response is an error.\n *\n * @param data API JSON Response\n * @returns Type guard for `data`.\n */\nexport function hasError(data: Record<string, unknown>): data is ErrorBase {\n  return 'error' in data;\n}\n\n/**\n * Type guard to determine if an element is an `HTMLInputElement`.\n *\n * @param element HTML Element.\n */\nexport function isInputElement(element: HTMLElement): element is HTMLInputElement {\n  return 'value' in element && 'required' in element;\n}\n\n/**\n * Retrieve the CSRF token from cookie storage.\n */\nexport function getCsrfToken(): string {\n  const { csrftoken: csrfToken } = Cookie.parse(document.cookie);\n  if (typeof csrfToken === 'undefined') {\n    throw new Error('Invalid or missing CSRF token');\n  }\n  return csrfToken;\n}\n\n/**\n * Authenticate and interact with the NetBox API.\n *\n * @param url Request URL\n * @param method Request Method\n * @param data Data to `POST`, `PATCH`, or `PUT`, if applicable.\n * @returns JSON Response\n */\nexport async function apiRequest<R extends Dict, D extends ReqData = undefined>(\n  url: string,\n  method: Method,\n  data?: D,\n): Promise<APIRes<R>> {\n  const token = getCsrfToken();\n  const headers = new Headers({ 'X-CSRFToken': token });\n\n  let body;\n  if (typeof data !== 'undefined') {\n    body = JSON.stringify(data);\n    headers.set('content-type', 'application/json');\n    headers.set('Accept', 'application/json');\n  }\n\n  const res = await fetch(url, { method, body, headers, credentials: 'same-origin' });\n  const contentType = res.headers.get('Content-Type');\n  if (typeof contentType === 'string' && contentType.includes('text')) {\n    const error = await res.text();\n    return { error } as ErrorBase;\n  }\n  const json = (await res.json()) as R | APIError;\n  if (!res.ok && Array.isArray(json)) {\n    const error = json.join('\\n');\n    return { error } as ErrorBase;\n  } else if (!res.ok && 'detail' in json) {\n    return { error: json.detail } as ErrorBase;\n  }\n  return json;\n}\n\n/**\n * `POST` an object as form data to the NetBox API.\n *\n * @param url Request URL\n * @param data Object to convert to form data\n * @returns JSON Response\n */\nexport async function apiPostForm<R extends Dict, D extends Dict>(\n  url: string,\n  data: D,\n): Promise<APIRes<R>> {\n  return await apiRequest<R, D>(url, 'POST', data);\n}\n\n/**\n * `GET` data from the NetBox API.\n *\n * @param url Request URL\n * @returns JSON Response\n */\nexport async function apiGetBase<R extends Dict>(url: string): Promise<APIRes<R>> {\n  return await apiRequest<R>(url, 'GET');\n}\n", "import { createToast } from './bs';\nimport { apiGetBase, apiPostForm, isApiError, isInputElement, hasError } from './util';\n\nimport type { APISecret, APIKeyPair } from './types';\n\n/**\n * Initialize Generate Private Key Pair Elements.\n */\nfunction initGenerateKeyPair() {\n  const element = document.getElementById('new_keypair_modal') as HTMLDivElement;\n  const accept = document.getElementById('use_new_pubkey') as HTMLButtonElement;\n  // If the elements are not loaded, stop.\n  if (element === null || accept === null) {\n    return;\n  }\n  const publicElem = element.querySelector<HTMLTextAreaElement>('textarea#new_pubkey');\n  const privateElem = element.querySelector<HTMLTextAreaElement>('textarea#new_privkey');\n\n  /**\n   * Handle Generate Private Key Pair Modal opening.\n   */\n  function handleOpen() {\n    // When the modal opens, set the `readonly` attribute on the textarea elements.\n    for (const elem of [publicElem, privateElem]) {\n      if (elem !== null) {\n        elem.setAttribute('readonly', '');\n      }\n    }\n    // Fetch the key pair from the API.\n    apiGetBase<APIKeyPair>('/api/plugins/secrets/generate-rsa-key-pair').then(data => {\n      if (!hasError(data)) {\n        // If key pair generation was successful, set the textarea elements' value to the generated\n        // values.\n        const { private_key: priv, public_key: pub } = data;\n        if (publicElem !== null && privateElem !== null) {\n          publicElem.value = pub;\n          privateElem.value = priv;\n        }\n      } else {\n        // Otherwise, show an error.\n        const toast = createToast('danger', 'Error', data.error);\n        toast.show();\n      }\n    });\n  }\n\n  /**\n   * Set the public key form field's value to the generated public key.\n   */\n  function handleAccept() {\n    const publicKeyField = document.getElementById('id_public_key') as HTMLTextAreaElement;\n    if (publicElem !== null) {\n      publicKeyField.value = publicElem.value;\n      publicKeyField.innerText = publicElem.value;\n    }\n  }\n  element.addEventListener('shown.bs.modal', () => handleOpen());\n  accept.addEventListener('click', () => handleAccept());\n}\n\n/**\n * Toggle copy/lock/unlock button visibility based on the action occurring.\n * @param id Secret ID.\n * @param action Lock or Unlock, so we know which buttons to display.\n */\nfunction toggleSecretButtons(id: string, action: 'lock' | 'unlock') {\n  const unlockButton = document.querySelector(`button.unlock-secret[secret-id='${id}']`);\n  const lockButton = document.querySelector(`button.lock-secret[secret-id='${id}']`);\n  const copyButton = document.querySelector(`button.copy-secret[secret-id='${id}']`);\n  // If we're unlocking, hide the unlock button. Otherwise, show it.\n  if (unlockButton !== null) {\n    if (action === 'unlock') unlockButton.classList.add('d-none');\n    if (action === 'lock') unlockButton.classList.remove('d-none');\n  }\n  // If we're unlocking, show the lock button. Otherwise, hide it.\n  if (lockButton !== null) {\n    if (action === 'unlock') lockButton.classList.remove('d-none');\n    if (action === 'lock') lockButton.classList.add('d-none');\n  }\n  // If we're unlocking, show the copy button. Otherwise, hide it.\n  if (copyButton !== null) {\n    if (action === 'unlock') copyButton.classList.remove('d-none');\n    if (action === 'lock') copyButton.classList.add('d-none');\n  }\n}\n\n/**\n * Initialize Lock & Unlock button event listeners & callbacks.\n */\nfunction initLockUnlock() {\n  const privateKeyModal = new window.Modal('#privkey_modal');\n\n  /**\n   * Unlock a secret, or prompt the user for their private key, if a session key is not available.\n   *\n   * @param id Secret ID\n   */\n  function unlock(id: string | null) {\n    const target = document.getElementById(`secret_${id}`) as HTMLDivElement | HTMLInputElement;\n    if (typeof id === 'string' && id !== '') {\n      apiGetBase<APISecret>(`/api/plugins/secrets/secrets/${id}`).then(data => {\n        if (!hasError(data)) {\n          const { plaintext } = data;\n          // `plaintext` is the plain text value of the secret. If it is null, it has not been\n          // decrypted, likely due to a mission session key.\n\n          if (target !== null && plaintext !== null) {\n            // If `plaintext` is not null, we have the decrypted value. Set the target element's\n            // inner text to the decrypted value and toggle copy/lock button visibility.\n            if (isInputElement(target)) {\n              target.value = plaintext;\n            } else {\n              target.innerText = plaintext;\n            }\n\n            toggleSecretButtons(id, 'unlock');\n          } else {\n            // Otherwise, we do _not_ have the decrypted value and need to prompt the user for\n            // their private RSA key, in order to get a session key. The session key is then sent\n            // as a cookie in future requests.\n            privateKeyModal.show();\n          }\n        } else {\n          if (data.error.toLowerCase().includes('invalid session key')) {\n            // If, for some reason, a request was made but resulted in an API error that complains\n            // of a missing session key, prompt the user for their session key.\n            privateKeyModal.show();\n          } else {\n            // If we received an API error but it doesn't contain 'invalid session key', show the\n            // user an error message.\n            const toast = createToast('danger', 'Error', data.error);\n            toast.show();\n          }\n        }\n      });\n    }\n  }\n\n  /**\n   * Lock a secret and toggle visibility of the unlock button.\n   * @param id Secret ID\n   */\n  function lock(id: string | null) {\n    if (typeof id === 'string' && id !== '') {\n      const target = document.getElementById(`secret_${id}`) as HTMLDivElement | HTMLInputElement;\n\n      // Obscure the inner text of the secret element.\n      if (isInputElement(target)) {\n        target.value = '********';\n      } else {\n        target.innerText = '********';\n      }\n\n      // Toggle visibility of the copy/lock/unlock buttons.\n      toggleSecretButtons(id, 'lock');\n    }\n  }\n\n  for (const element of document.querySelectorAll<HTMLButtonElement>('button.unlock-secret')) {\n    element.addEventListener('click', () => unlock(element.getAttribute('secret-id')));\n  }\n  for (const element of document.querySelectorAll<HTMLButtonElement>('button.lock-secret')) {\n    element.addEventListener('click', () => lock(element.getAttribute('secret-id')));\n  }\n}\n\n/**\n * Request a session key from the API.\n * @param privateKey RSA Private Key (valid JSON string)\n */\nfunction requestSessionKey(privateKey: string) {\n  apiPostForm('/api/plugins/secrets/get-session-key/', {\n    private_key: privateKey,\n  }).then(res => {\n    if (!hasError(res)) {\n      // If the response received was not an error, show the user a success message.\n      const toast = createToast('success', 'Session Key Received', 'You may now unlock secrets.');\n      toast.show();\n    } else {\n      // Otherwise, show the user an error message.\n      let message = res.error;\n      if (isApiError(res)) {\n        // If the error received was a standard API error containing a Python exception message,\n        // append it to the error.\n        message += `\\n${res.exception}`;\n      }\n      const toast = createToast('danger', 'Failed to Retrieve Session Key', message);\n      toast.show();\n    }\n  });\n}\n\n/**\n * Initialize Request Session Key Elements.\n */\nfunction initGetSessionKey() {\n  for (const element of document.querySelectorAll<HTMLButtonElement>('#request_session_key')) {\n    /**\n     * Send the user's input private key to the API to get a session key, which will be stored as\n     * a cookie for future requests.\n     */\n    function handleClick() {\n      for (const pk of document.querySelectorAll<HTMLTextAreaElement>('#user_privkey')) {\n        requestSessionKey(pk.value);\n        // Clear the private key form field value.\n        pk.value = '';\n      }\n    }\n    element.addEventListener('click', handleClick);\n  }\n}\n\n/**\n * Initialize Secret Edit Form Handler.\n */\nfunction initSecretsEdit() {\n  const privateKeyModal = new window.Modal('#privkey_modal');\n\n  /**\n   * Check the cookie store for a `session_key`. If not present, prompt the user to submit their\n   * private key.\n   */\n  function handleSubmit(event: Event): void {\n    if (document.cookie.indexOf('session_key') === -1) {\n      event.preventDefault();\n      privateKeyModal.show();\n    }\n  }\n\n  for (const element of document.querySelectorAll<HTMLInputElement>('.requires-session-key')) {\n    const form = element.closest<HTMLFormElement>('form');\n    if (form !== null) {\n      form.addEventListener('submit', handleSubmit);\n    }\n  }\n}\n\nexport function initSecrets() {\n  for (const func of [initGenerateKeyPair, initLockUnlock, initGetSessionKey, initSecretsEdit]) {\n    func();\n  }\n}\n", "import { initSecrets } from './secrets';\n\nif (document.readyState !== 'loading') {\n  initSecrets();\n} else {\n  document.addEventListener('DOMContentLoaded', initSecrets);\n}\n"],
-  "mappings": "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",
+  "sourcesContent": ["/*!\n * cookie\n * Copyright(c) 2012-2014 Roman Shtylman\n * Copyright(c) 2015 Douglas Christopher Wilson\n * MIT Licensed\n */\n\n'use strict';\n\n/**\n * Module exports.\n * @public\n */\n\nexports.parse = parse;\nexports.serialize = serialize;\n\n/**\n * Module variables.\n * @private\n */\n\nvar decode = decodeURIComponent;\nvar encode = encodeURIComponent;\nvar pairSplitRegExp = /; */;\n\n/**\n * RegExp to match field-content in RFC 7230 sec 3.2\n *\n * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ]\n * field-vchar   = VCHAR / obs-text\n * obs-text      = %x80-FF\n */\n\nvar fieldContentRegExp = /^[\\u0009\\u0020-\\u007e\\u0080-\\u00ff]+$/;\n\n/**\n * Parse a cookie header.\n *\n * Parse the given cookie header string into an object\n * The object has the various cookies as keys(names) => values\n *\n * @param {string} str\n * @param {object} [options]\n * @return {object}\n * @public\n */\n\nfunction parse(str, options) {\n  if (typeof str !== 'string') {\n    throw new TypeError('argument str must be a string');\n  }\n\n  var obj = {}\n  var opt = options || {};\n  var pairs = str.split(pairSplitRegExp);\n  var dec = opt.decode || decode;\n\n  for (var i = 0; i < pairs.length; i++) {\n    var pair = pairs[i];\n    var eq_idx = pair.indexOf('=');\n\n    // skip things that don't look like key=value\n    if (eq_idx < 0) {\n      continue;\n    }\n\n    var key = pair.substr(0, eq_idx).trim()\n    var val = pair.substr(++eq_idx, pair.length).trim();\n\n    // quoted values\n    if ('\"' == val[0]) {\n      val = val.slice(1, -1);\n    }\n\n    // only assign once\n    if (undefined == obj[key]) {\n      obj[key] = tryDecode(val, dec);\n    }\n  }\n\n  return obj;\n}\n\n/**\n * Serialize data into a cookie header.\n *\n * Serialize the a name value pair into a cookie string suitable for\n * http headers. An optional options object specified cookie parameters.\n *\n * serialize('foo', 'bar', { httpOnly: true })\n *   => \"foo=bar; httpOnly\"\n *\n * @param {string} name\n * @param {string} val\n * @param {object} [options]\n * @return {string}\n * @public\n */\n\nfunction serialize(name, val, options) {\n  var opt = options || {};\n  var enc = opt.encode || encode;\n\n  if (typeof enc !== 'function') {\n    throw new TypeError('option encode is invalid');\n  }\n\n  if (!fieldContentRegExp.test(name)) {\n    throw new TypeError('argument name is invalid');\n  }\n\n  var value = enc(val);\n\n  if (value && !fieldContentRegExp.test(value)) {\n    throw new TypeError('argument val is invalid');\n  }\n\n  var str = name + '=' + value;\n\n  if (null != opt.maxAge) {\n    var maxAge = opt.maxAge - 0;\n\n    if (isNaN(maxAge) || !isFinite(maxAge)) {\n      throw new TypeError('option maxAge is invalid')\n    }\n\n    str += '; Max-Age=' + Math.floor(maxAge);\n  }\n\n  if (opt.domain) {\n    if (!fieldContentRegExp.test(opt.domain)) {\n      throw new TypeError('option domain is invalid');\n    }\n\n    str += '; Domain=' + opt.domain;\n  }\n\n  if (opt.path) {\n    if (!fieldContentRegExp.test(opt.path)) {\n      throw new TypeError('option path is invalid');\n    }\n\n    str += '; Path=' + opt.path;\n  }\n\n  if (opt.expires) {\n    if (typeof opt.expires.toUTCString !== 'function') {\n      throw new TypeError('option expires is invalid');\n    }\n\n    str += '; Expires=' + opt.expires.toUTCString();\n  }\n\n  if (opt.httpOnly) {\n    str += '; HttpOnly';\n  }\n\n  if (opt.secure) {\n    str += '; Secure';\n  }\n\n  if (opt.sameSite) {\n    var sameSite = typeof opt.sameSite === 'string'\n      ? opt.sameSite.toLowerCase() : opt.sameSite;\n\n    switch (sameSite) {\n      case true:\n        str += '; SameSite=Strict';\n        break;\n      case 'lax':\n        str += '; SameSite=Lax';\n        break;\n      case 'strict':\n        str += '; SameSite=Strict';\n        break;\n      case 'none':\n        str += '; SameSite=None';\n        break;\n      default:\n        throw new TypeError('option sameSite is invalid');\n    }\n  }\n\n  return str;\n}\n\n/**\n * Try decoding a string using a decoding function.\n *\n * @param {string} str\n * @param {function} decode\n * @private\n */\n\nfunction tryDecode(str, decode) {\n  try {\n    return decode(str);\n  } catch (e) {\n    return str;\n  }\n}\n", "type ToastLevel = 'danger' | 'warning' | 'success' | 'info';\n\nexport function createToast(\n  level: ToastLevel,\n  title: string,\n  message: string,\n  extra?: string,\n): InstanceType<typeof window.Toast> {\n  let iconName = 'mdi-alert';\n  switch (level) {\n    case 'warning':\n      iconName = 'mdi-alert';\n      break;\n    case 'success':\n      iconName = 'mdi-check-circle';\n      break;\n    case 'info':\n      iconName = 'mdi-information';\n      break;\n    case 'danger':\n      iconName = 'mdi-alert';\n      break;\n  }\n\n  const container = document.createElement('div');\n  container.setAttribute('class', 'toast-container position-fixed bottom-0 end-0 m-3');\n\n  const main = document.createElement('div');\n  main.setAttribute('class', `toast bg-${level}`);\n  main.setAttribute('role', 'alert');\n  main.setAttribute('aria-live', 'assertive');\n  main.setAttribute('aria-atomic', 'true');\n\n  const header = document.createElement('div');\n  header.setAttribute('class', `toast-header bg-${level} text-body`);\n\n  const icon = document.createElement('i');\n  icon.setAttribute('class', `mdi ${iconName}`);\n\n  const titleElement = document.createElement('strong');\n  titleElement.setAttribute('class', 'me-auto ms-1');\n  titleElement.innerText = title;\n\n  const button = document.createElement('button');\n  button.setAttribute('type', 'button');\n  button.setAttribute('class', 'btn-close');\n  button.setAttribute('data-bs-dismiss', 'toast');\n  button.setAttribute('aria-label', 'Close');\n\n  const body = document.createElement('div');\n  body.setAttribute('class', 'toast-body');\n\n  header.appendChild(icon);\n  header.appendChild(titleElement);\n\n  if (typeof extra !== 'undefined') {\n    const extraElement = document.createElement('small');\n    extraElement.setAttribute('class', 'text-muted');\n    header.appendChild(extraElement);\n  }\n\n  header.appendChild(button);\n\n  body.innerText = message.trim();\n\n  main.appendChild(header);\n  main.appendChild(body);\n  container.appendChild(main);\n  document.body.appendChild(container);\n\n  const toast = new window.Toast(main);\n  return toast;\n}\n", "import Cookie from 'cookie';\n\ntype APIRes<T> = T | ErrorBase | APIError;\ntype Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';\ntype ReqData = URLSearchParams | Dict | undefined | unknown;\n\n/**\n * Type guard to determine if an API response is a detailed error.\n *\n * @param data API JSON Response\n * @returns Type guard for `data`.\n */\nexport function isApiError(data: Record<string, unknown>): data is APIError {\n  return 'error' in data && 'exception' in data;\n}\n\n/**\n * Type guard to determine if an API response is an error.\n *\n * @param data API JSON Response\n * @returns Type guard for `data`.\n */\nexport function hasError(data: Record<string, unknown>): data is ErrorBase {\n  return 'error' in data;\n}\n\n/**\n * Type guard to determine if an element is an `HTMLInputElement`.\n *\n * @param element HTML Element.\n */\nexport function isInputElement(element: HTMLElement): element is HTMLInputElement {\n  return 'value' in element && 'required' in element;\n}\n\n/**\n * Retrieve the CSRF token from cookie storage.\n */\nexport function getCsrfToken(): string {\n  const { csrftoken: csrfToken } = Cookie.parse(document.cookie);\n  if (typeof csrfToken === 'undefined') {\n    throw new Error('Invalid or missing CSRF token');\n  }\n  return csrfToken;\n}\n\n/**\n * Authenticate and interact with the NetBox API.\n *\n * @param url Request URL\n * @param method Request Method\n * @param data Data to `POST`, `PATCH`, or `PUT`, if applicable.\n * @returns JSON Response\n */\nexport async function apiRequest<R extends Dict, D extends ReqData = undefined>(\n  url: string,\n  method: Method,\n  data?: D,\n): Promise<APIRes<R>> {\n  const token = getCsrfToken();\n  const headers = new Headers({ 'X-CSRFToken': token });\n\n  let body;\n  if (typeof data !== 'undefined') {\n    body = JSON.stringify(data);\n    headers.set('content-type', 'application/json');\n    headers.set('Accept', 'application/json');\n  }\n\n  const res = await fetch(url, { method, body, headers, credentials: 'same-origin' });\n  const contentType = res.headers.get('Content-Type');\n  if (typeof contentType === 'string' && contentType.includes('text')) {\n    const error = await res.text();\n    return { error } as ErrorBase;\n  }\n  const json = (await res.json()) as R | APIError;\n  if (!res.ok && Array.isArray(json)) {\n    const error = json.join('\\n');\n    return { error } as ErrorBase;\n  } else if (!res.ok && 'detail' in json) {\n    return { error: json.detail } as ErrorBase;\n  }\n  return json;\n}\n\n/**\n * `POST` an object as form data to the NetBox API.\n *\n * @param url Request URL\n * @param data Object to convert to form data\n * @returns JSON Response\n */\nexport async function apiPostForm<R extends Dict, D extends Dict>(\n  url: string,\n  data: D,\n): Promise<APIRes<R>> {\n  return await apiRequest<R, D>(url, 'POST', data);\n}\n\n/**\n * `GET` data from the NetBox API.\n *\n * @param url Request URL\n * @returns JSON Response\n */\nexport async function apiGetBase<R extends Dict>(url: string): Promise<APIRes<R>> {\n  return await apiRequest<R>(url, 'GET');\n}\n", "import { createToast } from './bs';\nimport { apiGetBase, apiPostForm, isApiError, isInputElement, hasError } from './util';\n\nimport type { APISecret, APIKeyPair } from './types';\n\n/**\n * Initialize Generate Private Key Pair Elements.\n */\nfunction initGenerateKeyPair() {\n  const element = document.getElementById('new_keypair_modal') as HTMLDivElement;\n  const accept = document.getElementById('use_new_pubkey') as HTMLButtonElement;\n  // If the elements are not loaded, stop.\n  if (element === null || accept === null) {\n    return;\n  }\n  const publicElem = element.querySelector<HTMLTextAreaElement>('textarea#new_pubkey');\n  const privateElem = element.querySelector<HTMLTextAreaElement>('textarea#new_privkey');\n\n  /**\n   * Handle Generate Private Key Pair Modal opening.\n   */\n  function handleOpen() {\n    // When the modal opens, set the `readonly` attribute on the textarea elements.\n    for (const elem of [publicElem, privateElem]) {\n      if (elem !== null) {\n        elem.setAttribute('readonly', '');\n      }\n    }\n    // Fetch the key pair from the API.\n    apiGetBase<APIKeyPair>('/api/plugins/secrets/generate-rsa-key-pair/').then(data => {\n      if (!hasError(data)) {\n        // If key pair generation was successful, set the textarea elements' value to the generated\n        // values.\n        const { private_key: priv, public_key: pub } = data;\n        if (publicElem !== null && privateElem !== null) {\n          publicElem.value = pub;\n          privateElem.value = priv;\n        }\n      } else {\n        // Otherwise, show an error.\n        const toast = createToast('danger', 'Error', data.error);\n        toast.show();\n      }\n    });\n  }\n\n  /**\n   * Set the public key form field's value to the generated public key.\n   */\n  function handleAccept() {\n    const publicKeyField = document.getElementById('id_public_key') as HTMLTextAreaElement;\n    if (publicElem !== null) {\n      publicKeyField.value = publicElem.value;\n      publicKeyField.innerText = publicElem.value;\n    }\n  }\n  element.addEventListener('shown.bs.modal', () => handleOpen());\n  accept.addEventListener('click', () => handleAccept());\n}\n\n/**\n * Toggle copy/lock/unlock button visibility based on the action occurring.\n * @param id Secret ID.\n * @param action Lock or Unlock, so we know which buttons to display.\n */\nfunction toggleSecretButtons(id: string, action: 'lock' | 'unlock') {\n  const unlockButton = document.querySelector(`button.unlock-secret[secret-id='${id}']`);\n  const lockButton = document.querySelector(`button.lock-secret[secret-id='${id}']`);\n  const copyButton = document.querySelector(`button.copy-secret[secret-id='${id}']`);\n  // If we're unlocking, hide the unlock button. Otherwise, show it.\n  if (unlockButton !== null) {\n    if (action === 'unlock') unlockButton.classList.add('d-none');\n    if (action === 'lock') unlockButton.classList.remove('d-none');\n  }\n  // If we're unlocking, show the lock button. Otherwise, hide it.\n  if (lockButton !== null) {\n    if (action === 'unlock') lockButton.classList.remove('d-none');\n    if (action === 'lock') lockButton.classList.add('d-none');\n  }\n  // If we're unlocking, show the copy button. Otherwise, hide it.\n  if (copyButton !== null) {\n    if (action === 'unlock') copyButton.classList.remove('d-none');\n    if (action === 'lock') copyButton.classList.add('d-none');\n  }\n}\n\n/**\n * Initialize Lock & Unlock button event listeners & callbacks.\n */\nfunction initLockUnlock() {\n  const privateKeyModal = new window.Modal('#privkey_modal');\n\n  /**\n   * Unlock a secret, or prompt the user for their private key, if a session key is not available.\n   *\n   * @param id Secret ID\n   */\n  function unlock(id: string | null) {\n    const target = document.getElementById(`secret_${id}`) as HTMLDivElement | HTMLInputElement;\n    if (typeof id === 'string' && id !== '') {\n      apiGetBase<APISecret>(`/api/plugins/secrets/secrets/${id}/`).then(data => {\n        if (!hasError(data)) {\n          const { plaintext } = data;\n          // `plaintext` is the plain text value of the secret. If it is null, it has not been\n          // decrypted, likely due to a mission session key.\n\n          if (target !== null && plaintext !== null) {\n            // If `plaintext` is not null, we have the decrypted value. Set the target element's\n            // inner text to the decrypted value and toggle copy/lock button visibility.\n            if (isInputElement(target)) {\n              target.value = plaintext;\n            } else {\n              target.innerText = plaintext;\n            }\n\n            toggleSecretButtons(id, 'unlock');\n          } else {\n            // Otherwise, we do _not_ have the decrypted value and need to prompt the user for\n            // their private RSA key, in order to get a session key. The session key is then sent\n            // as a cookie in future requests.\n            privateKeyModal.show();\n          }\n        } else {\n          if (data.error.toLowerCase().includes('invalid session key')) {\n            // If, for some reason, a request was made but resulted in an API error that complains\n            // of a missing session key, prompt the user for their session key.\n            privateKeyModal.show();\n          } else {\n            // If we received an API error but it doesn't contain 'invalid session key', show the\n            // user an error message.\n            const toast = createToast('danger', 'Error', data.error);\n            toast.show();\n          }\n        }\n      });\n    }\n  }\n\n  /**\n   * Lock a secret and toggle visibility of the unlock button.\n   * @param id Secret ID\n   */\n  function lock(id: string | null) {\n    if (typeof id === 'string' && id !== '') {\n      const target = document.getElementById(`secret_${id}`) as HTMLDivElement | HTMLInputElement;\n\n      // Obscure the inner text of the secret element.\n      if (isInputElement(target)) {\n        target.value = '********';\n      } else {\n        target.innerText = '********';\n      }\n\n      // Toggle visibility of the copy/lock/unlock buttons.\n      toggleSecretButtons(id, 'lock');\n    }\n  }\n\n  for (const element of document.querySelectorAll<HTMLButtonElement>('button.unlock-secret')) {\n    element.addEventListener('click', () => unlock(element.getAttribute('secret-id')));\n  }\n  for (const element of document.querySelectorAll<HTMLButtonElement>('button.lock-secret')) {\n    element.addEventListener('click', () => lock(element.getAttribute('secret-id')));\n  }\n}\n\n/**\n * Request a session key from the API.\n * @param privateKey RSA Private Key (valid JSON string)\n */\nfunction requestSessionKey(privateKey: string) {\n  apiPostForm('/api/plugins/secrets/get-session-key/', {\n    private_key: privateKey,\n  }).then(res => {\n    if (!hasError(res)) {\n      // If the response received was not an error, show the user a success message.\n      const toast = createToast('success', 'Session Key Received', 'You may now unlock secrets.');\n      toast.show();\n    } else {\n      // Otherwise, show the user an error message.\n      let message = res.error;\n      if (isApiError(res)) {\n        // If the error received was a standard API error containing a Python exception message,\n        // append it to the error.\n        message += `\\n${res.exception}`;\n      }\n      const toast = createToast('danger', 'Failed to Retrieve Session Key', message);\n      toast.show();\n    }\n  });\n}\n\n/**\n * Initialize Request Session Key Elements.\n */\nfunction initGetSessionKey() {\n  for (const element of document.querySelectorAll<HTMLButtonElement>('#request_session_key')) {\n    /**\n     * Send the user's input private key to the API to get a session key, which will be stored as\n     * a cookie for future requests.\n     */\n    function handleClick() {\n      for (const pk of document.querySelectorAll<HTMLTextAreaElement>('#user_privkey')) {\n        requestSessionKey(pk.value);\n        // Clear the private key form field value.\n        pk.value = '';\n      }\n    }\n    element.addEventListener('click', handleClick);\n  }\n}\n\n/**\n * Initialize Secret Edit Form Handler.\n */\nfunction initSecretsEdit() {\n  const privateKeyModal = new window.Modal('#privkey_modal');\n\n  /**\n   * Check the cookie store for a `session_key`. If not present, prompt the user to submit their\n   * private key.\n   */\n  function handleSubmit(event: Event): void {\n    if (document.cookie.indexOf('session_key') === -1) {\n      event.preventDefault();\n      privateKeyModal.show();\n    }\n  }\n\n  for (const element of document.querySelectorAll<HTMLInputElement>('.requires-session-key')) {\n    const form = element.closest<HTMLFormElement>('form');\n    if (form !== null) {\n      form.addEventListener('submit', handleSubmit);\n    }\n  }\n}\n\nexport function initSecrets() {\n  for (const func of [initGenerateKeyPair, initLockUnlock, initGetSessionKey, initSecretsEdit]) {\n    func();\n  }\n}\n", "import { initSecrets } from './secrets';\n\nif (document.readyState !== 'loading') {\n  initSecrets();\n} else {\n  document.addEventListener('DOMContentLoaded', initSecrets);\n}\n"],
+  "mappings": "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",
   "names": []
 }
diff --git a/netbox_secrets/tables.py b/netbox_secrets/tables.py
index 2aff26f..642de9d 100644
--- a/netbox_secrets/tables.py
+++ b/netbox_secrets/tables.py
@@ -1,16 +1,14 @@
 import django_tables2 as tables
 
-from netbox.tables import BaseTable, columns, NetBoxTable
-from netbox_secrets.utils.tables import PluginButtonsColumn
-from .models import SecretRole, Secret
+from netbox.tables import NetBoxTable, columns
+from .models import Secret, SecretRole
 
 
 #
 # Secret roles
 #
 
-class SecretRoleTable(BaseTable):
-    pk = columns.ToggleColumn()
+class SecretRoleTable(NetBoxTable):
     name = tables.Column(
         linkify=True
     )
@@ -19,12 +17,11 @@ class SecretRoleTable(BaseTable):
         url_params={'role_id': 'pk'},
         verbose_name='Secrets'
     )
-    actions = PluginButtonsColumn(SecretRole)
 
-    class Meta(BaseTable.Meta):
+    class Meta(NetBoxTable.Meta):
         model = SecretRole
-        fields = ('pk', 'name', 'secret_count', 'description', 'slug', 'actions')
-        default_columns = ('pk', 'name', 'secret_count', 'description', 'actions')
+        fields = ('pk', 'id', 'name', 'secret_count', 'description', 'slug', 'actions')
+        default_columns = ('id', 'name', 'secret_count', 'description', 'actions')
 
 
 #
@@ -32,18 +29,11 @@ class Meta(BaseTable.Meta):
 #
 
 class SecretTable(NetBoxTable):
-    pk = columns.ToggleColumn()
-    id = tables.Column(  # Provides a link to the secret
-        linkify=True
-    )
     name = tables.Column(
         linkify=True
     )
-    assigned_object = tables.Column(
-        linkify=True,
-        orderable=False,
-        verbose_name='Assigned object'
-    )
+    assigned_object_type = columns.ContentTypeColumn(verbose_name='Object type')
+    assigned_object = tables.Column(linkify=True, orderable=False, verbose_name='Object')
     role = tables.Column(
         linkify=True
     )
@@ -51,7 +41,9 @@ class SecretTable(NetBoxTable):
         url_name='plugins:netbox_secrets:secret_list'
     )
 
-    class Meta(BaseTable.Meta):
+    class Meta(NetBoxTable.Meta):
         model = Secret
-        fields = ('pk', 'id', 'assigned_object', 'role', 'name', 'last_updated', 'hash', 'tags')
-        default_columns = ('pk', 'id', 'name', 'assigned_object', 'role', 'last_updated')
+        fields = (
+            'pk', 'id', 'name', 'assigned_object_type', 'assigned_object', 'role', 'created', 'last_updated', 'tags',
+        )
+        default_columns = ('pk', 'id', 'name', 'assigned_object_type', 'assigned_object', 'role', 'actions')
diff --git a/netbox_secrets/template_content.py b/netbox_secrets/template_content.py
index 6dc0ff8..ff25ac1 100644
--- a/netbox_secrets/template_content.py
+++ b/netbox_secrets/template_content.py
@@ -1,33 +1,61 @@
+import logging
+
+from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
+from django.db.utils import OperationalError
 
 from extras.plugins import PluginTemplateExtension
 from .models import Secret
 
-
-class Secrets(PluginTemplateExtension):
-
-    def right_page(self):
-        obj = self.context['object']
-
-        secrets = None
-        ctype = ContentType.objects.get_for_model(obj)
-        if ctype.model == 'device':
-            secrets = Secret.objects.filter(assigned_object_id=obj.pk, assigned_object_type=ctype)
-        elif ctype.model == 'virtualmachine':
-            secrets = Secret.objects.filter(assigned_object_id=obj.pk, assigned_object_type=ctype)
-
-        return self.render('netbox_secrets/inc/device_secrets.html', extra_context={
-            'secrets': secrets,
-            'type': ctype.model if ctype.model == 'device' else ctype.name.replace(' ', '_'),
-        })
-
-
-class DeviceSecrets(Secrets):
-    model = 'dcim.device'
-
-
-class VMSecrets(Secrets):
-    model = 'virtualization.virtualmachine'
-
-
-template_extensions = [DeviceSecrets, VMSecrets]
+logger = logging.getLogger(__name__)
+plugin_settings = settings.PLUGINS_CONFIG.get('netbox_secrets')
+template_extensions = []
+
+
+def secrets_panel(self):
+    obj = self.context['object']
+    app_label, model = self.model.split('.')
+    assigned_object_type = ContentType.objects.get(app_label=app_label, model=model).id
+
+    return self.render(
+        'netbox_secrets/inc/secrets_panel.html',
+        extra_context={
+            'secrets': Secret.objects.filter(
+                assigned_object_type=assigned_object_type,
+                assigned_object_id=obj.id
+            )
+        }
+    )
+
+
+def get_display_on(app_model):
+    """Get preferred display location for app_model"""
+    display_on = plugin_settings.get('display_default')
+
+    if display_setting := plugin_settings.get('display_setting'):
+        display_on = display_setting.get(app_model, display_on)
+
+    return display_on
+
+
+# Generate plugin extensions for the defined classes
+try:
+    for content_type in ContentType.objects.all():
+        app_label = content_type.app_label
+        model = content_type.model
+        app_model_name = f'{app_label}.{model}'
+
+        if app_model_name in plugin_settings.get('apps'):
+            klass_name = f'{app_label}_{model}_plugin_template_extension'
+            dynamic_klass = type(
+                klass_name,
+                (PluginTemplateExtension,),
+                {'model': app_model_name, get_display_on(app_model_name): secrets_panel}
+            )
+            template_extensions.append(dynamic_klass)
+except OperationalError as e:
+    # This happens when the database is not yet ready
+    logger.warning(f'Database not ready, skipping plugin extensions: {e}')
+except Exception as e:
+    # Unexpected error
+    logger.error(e)
diff --git a/netbox_secrets/templates/netbox_secrets/buttons/add.html b/netbox_secrets/templates/netbox_secrets/buttons/add.html
deleted file mode 100644
index 784bd05..0000000
--- a/netbox_secrets/templates/netbox_secrets/buttons/add.html
+++ /dev/null
@@ -1,3 +0,0 @@
-<a href="{{ add_url }}" class="btn btn-primary">
-    <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add
-</a>
diff --git a/netbox_secrets/templates/netbox_secrets/buttons/clone.html b/netbox_secrets/templates/netbox_secrets/buttons/clone.html
deleted file mode 100644
index b2d4c87..0000000
--- a/netbox_secrets/templates/netbox_secrets/buttons/clone.html
+++ /dev/null
@@ -1,3 +0,0 @@
-<a href="{{ url }}" class="btn btn-success">
-    <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Clone
-</a>
diff --git a/netbox_secrets/templates/netbox_secrets/buttons/delete.html b/netbox_secrets/templates/netbox_secrets/buttons/delete.html
deleted file mode 100644
index 95bf3cb..0000000
--- a/netbox_secrets/templates/netbox_secrets/buttons/delete.html
+++ /dev/null
@@ -1,3 +0,0 @@
-<a href="{{ url }}" class="btn btn-danger">
-    <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete
-</a>
diff --git a/netbox_secrets/templates/netbox_secrets/buttons/edit.html b/netbox_secrets/templates/netbox_secrets/buttons/edit.html
deleted file mode 100644
index 7950d7e..0000000
--- a/netbox_secrets/templates/netbox_secrets/buttons/edit.html
+++ /dev/null
@@ -1,3 +0,0 @@
-<a href="{{ url }}" class="btn btn-warning">
-    <span class="mdi mdi-pencil" aria-hidden="true"></span> Edit
-</a>
diff --git a/netbox_secrets/templates/netbox_secrets/buttons/export.html b/netbox_secrets/templates/netbox_secrets/buttons/export.html
deleted file mode 100644
index 341dca3..0000000
--- a/netbox_secrets/templates/netbox_secrets/buttons/export.html
+++ /dev/null
@@ -1,16 +0,0 @@
-<div class="btn-group">
-  <button type="button" class="btn btn-success dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
-    <span class="mdi mdi-database-export" aria-hidden="true"></span>
-    Export <span class="caret"></span>
-  </button>
-  <ul class="dropdown-menu dropdown-menu-right">
-    <li><a href="?{% if url_params %}{{ url_params.urlencode }}&{% endif %}export=table">Current view</a></li>
-    <li><a href="?{% if url_params %}{{ url_params.urlencode }}&{% endif %}export">Default format</a></li>
-    {% if export_templates %}
-      <li class="divider"></li>
-      {% for et in export_templates %}
-        <li><a href="?{% if url_params %}{{ url_params.urlencode }}&{% endif %}export={{ et.name }}"{% if et.description %} title="{{ et.description }}"{% endif %}>{{ et.name }}</a></li>
-      {% endfor %}
-    {% endif %}
-  </ul>
-</div>
diff --git a/netbox_secrets/templates/netbox_secrets/buttons/import.html b/netbox_secrets/templates/netbox_secrets/buttons/import.html
deleted file mode 100644
index 1a9ecdf..0000000
--- a/netbox_secrets/templates/netbox_secrets/buttons/import.html
+++ /dev/null
@@ -1,3 +0,0 @@
-<a href="{% url import_url %}" class="btn btn-info">
-    <span class="mdi mdi-database-import" aria-hidden="true"></span> Import
-</a>
diff --git a/netbox_secrets/templates/netbox_secrets/buttons/tr_changelog.html b/netbox_secrets/templates/netbox_secrets/buttons/tr_changelog.html
deleted file mode 100644
index 7ade954..0000000
--- a/netbox_secrets/templates/netbox_secrets/buttons/tr_changelog.html
+++ /dev/null
@@ -1,3 +0,0 @@
-<a href="{{ url }}"  class="btn btn-outline-dark btn-sm" title="Change log">
-    <i class="mdi mdi-history"></i>
-</a>
\ No newline at end of file
diff --git a/netbox_secrets/templates/netbox_secrets/buttons/tr_delete.html b/netbox_secrets/templates/netbox_secrets/buttons/tr_delete.html
deleted file mode 100644
index a0586fa..0000000
--- a/netbox_secrets/templates/netbox_secrets/buttons/tr_delete.html
+++ /dev/null
@@ -1,3 +0,0 @@
-<a href="{{ url }}" class="btn btn-sm btn-danger" title="Delete">
-    <i class="mdi mdi-trash-can-outline"></i>
-</a>
\ No newline at end of file
diff --git a/netbox_secrets/templates/netbox_secrets/buttons/tr_edit.html b/netbox_secrets/templates/netbox_secrets/buttons/tr_edit.html
deleted file mode 100644
index 2a4ee01..0000000
--- a/netbox_secrets/templates/netbox_secrets/buttons/tr_edit.html
+++ /dev/null
@@ -1,3 +0,0 @@
-<a href="{{ url }}" class="btn btn-sm btn-warning" title="Edit">
-    <i class="mdi mdi-pencil"></i>
-</a>
\ No newline at end of file
diff --git a/netbox_secrets/templates/netbox_secrets/inc/assigned_secrets.html b/netbox_secrets/templates/netbox_secrets/inc/assigned_secrets.html
deleted file mode 100644
index 71b1e70..0000000
--- a/netbox_secrets/templates/netbox_secrets/inc/assigned_secrets.html
+++ /dev/null
@@ -1,29 +0,0 @@
-{% if secrets %}
-    <form id="secret_form">
-        {% csrf_token %}
-    </form>
-    <table class="table table-hover">
-        {% for secret in secrets %}
-            <tr>
-                <td><a href="{% url 'plugins:netbox_secrets:secret' pk=secret.pk %}">{{ secret.role }}</a></td>
-                <td>{{ secret.name }}</td>
-                <td class="font-monospace" id="secret_{{ secret.pk }}">********</td>
-                <td class="text-end noprint">
-                    <button class="btn btn-sm btn-success unlock-secret" secret-id="{{ secret.pk }}">
-                        <i class="mdi mdi-lock"></i> Unlock
-                    </button>
-                    <button class="btn btn-sm btn-outline-dark copy-secret d-none" secret-id="{{ secret.pk }}" data-clipboard-target="#secret_{{ secret.pk }}">
-                        <i class="mdi mdi-content-copy"></i> Copy
-                    </button>
-                    <button class="btn btn-sm btn-danger lock-secret d-none" secret-id="{{ secret.pk }}">
-                        <i class="mdi mdi-lock-open"></i> Lock
-                    </button>
-                </td>
-            </tr>
-        {% endfor %}
-    </table>
-{% else %}
-    <div class="text-muted">
-        None found
-    </div>
-{% endif %}
diff --git a/netbox_secrets/templates/netbox_secrets/inc/device_secrets.html b/netbox_secrets/templates/netbox_secrets/inc/device_secrets.html
deleted file mode 100644
index 1e63889..0000000
--- a/netbox_secrets/templates/netbox_secrets/inc/device_secrets.html
+++ /dev/null
@@ -1,18 +0,0 @@
-{% if perms.netbox_secrets.view_secret %}
-    <div class="card">
-        <h5 class="card-header">
-            Secrets
-        </h5>
-        <div class="card-body">
-            {% include 'netbox_secrets/inc/assigned_secrets.html' %}
-        </div>
-        {% if perms.netbox_secrets.add_secret %}
-        <div class="card-footer text-end noprint">
-            <a href="{% url 'plugins:netbox_secrets:secret_add' %}?{{ type }}={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-primary">
-                <i class="mdi mdi-plus-thick"></i> Add Secret
-            </a>
-        </div>
-        {% endif %}
-    </div>
-{% include 'netbox_secrets/inc/private_key_modal.html' %}
-{% endif %}
\ No newline at end of file
diff --git a/netbox_secrets/templates/netbox_secrets/inc/private_key_modal.html b/netbox_secrets/templates/netbox_secrets/inc/private_key_modal.html
index 01adb4e..8043c29 100644
--- a/netbox_secrets/templates/netbox_secrets/inc/private_key_modal.html
+++ b/netbox_secrets/templates/netbox_secrets/inc/private_key_modal.html
@@ -19,7 +19,7 @@ <h5 class="modal-title" id="privkey_modal_title">
             </div>
             <div class="modal-footer float-end">
                 <button id="request_session_key" class="btn btn-primary" data-bs-dismiss="modal">
-                    Request Session Key
+                    <i class="mdi mdi-key"></i> Request Session Key
                 </button>
             </div>
         </div>
diff --git a/netbox_secrets/templates/netbox_secrets/inc/secret_actions.html b/netbox_secrets/templates/netbox_secrets/inc/secret_actions.html
new file mode 100644
index 0000000..e682502
--- /dev/null
+++ b/netbox_secrets/templates/netbox_secrets/inc/secret_actions.html
@@ -0,0 +1,25 @@
+{% if perms.netbox_secrets.view_secret %}
+    <button class="btn btn-sm btn-success unlock-secret" secret-id="{{ object.pk }}" title="Unlock Secret"><i
+            class="mdi mdi-lock"></i>
+    </button>
+    <button class="btn btn-sm btn-outline-dark copy-secret d-none" secret-id="{{ object.pk }}" title="Copy to clipboard"
+            data-clipboard-target="#secret_{{ object.pk }}"><i class="mdi mdi-content-copy"></i>
+    </button>
+    <button class="btn btn-sm btn-danger lock-secret d-none" secret-id="{{ object.pk }}" title="Lock Secret">
+        <i class="mdi mdi-lock-open"></i>
+    </button>
+{% endif %}
+
+{% if '/plugins/secrets/' not in request.path %}
+    {% if perms.netbox_secrets.change_secret %}
+        <a class="btn btn-warning btn-sm" title="Edit Secret"
+           href="{% url 'plugins:netbox_secrets:secret_edit' pk=object.pk %}?return_url={{ request.path }}"><i
+                class="mdi mdi-pencil" aria-hidden="true"></i></a>
+    {% endif %}
+
+    {% if perms.netbox_secrets.delete_secret %}
+        <a class="btn btn-danger btn-sm" title="Delete Secret"
+           href="{% url 'plugins:netbox_secrets:secret_delete' pk=object.pk %}?return_url={{ request.path }}"><i
+                class="mdi mdi-trash-can-outline" aria-hidden="true"></i></a>
+    {% endif %}
+{% endif %}
\ No newline at end of file
diff --git a/netbox_secrets/templates/netbox_secrets/inc/secrets_panel.html b/netbox_secrets/templates/netbox_secrets/inc/secrets_panel.html
new file mode 100644
index 0000000..d0df6cd
--- /dev/null
+++ b/netbox_secrets/templates/netbox_secrets/inc/secrets_panel.html
@@ -0,0 +1,44 @@
+{% load helpers %}
+{% if perms.netbox_secrets.view_secret %}
+    <div class="card">
+        <h5 class="card-header">Secrets</h5>
+        <div class="card-body">
+            {% with secrets=secrets %}
+                {% if secrets.exists %}
+                    <form id="secret_form">
+                        {% csrf_token %}
+                    </form>
+                    <table class="table table-hover">
+                        <tr>
+                            <th>Name</th>
+                            <th>Role</th>
+                            <th>Secret</th>
+                            <th></th>
+                        </tr>
+                        {% for secret in secrets %}
+                            <tr>
+                                <td><a href="{{ secret.get_absolute_url }}">
+                                    {% if secret.name %}{{ secret.name }}{% else %}(Not Set){% endif %}</a></td>
+                                <td><a href="{{ secret.role.get_absolute_url }}">{{ secret.role }}</a></td>
+                                <td class="font-monospace" id="secret_{{ secret.pk }}">********</td>
+                                <td class="text-end noprint">
+                                    {% include 'netbox_secrets/inc/secret_actions.html' with object=secret %}
+                                </td>
+                            </tr>
+                        {% endfor %}
+                    </table>
+                {% else %}
+                    <div class="text-muted">None</div>
+                {% endif %}
+            {% endwith %}
+        </div>
+        {% if perms.netbox_secrets.add_secret %}
+            <div class="card-footer text-end noprint">
+                <a class="btn btn-primary btn-sm"
+                   href="{% url 'plugins:netbox_secrets:secret_add' %}?assigned_object_type={{ object|content_type_id }}&assigned_object_id={{ object.pk }}&return_url={{ request.path }}"><i
+                        class="mdi mdi-plus-thick"></i> Add Secret</a>
+            </div>
+        {% endif %}
+    </div>
+    {% include 'netbox_secrets/inc/private_key_modal.html' %}
+{% endif %}
\ No newline at end of file
diff --git a/netbox_secrets/templates/netbox_secrets/secret.html b/netbox_secrets/templates/netbox_secrets/secret.html
index cb520b0..4533fa3 100644
--- a/netbox_secrets/templates/netbox_secrets/secret.html
+++ b/netbox_secrets/templates/netbox_secrets/secret.html
@@ -55,15 +55,7 @@ <h5 class="card-header">
                     <div class="col col-md-2">Secret</div>
                     <div class="col col-md-6"><code id="secret_{{ object.pk }}">********</code></div>
                     <div class="col col-md-4 text-end noprint">
-                        <button class="btn btn-sm btn-success unlock-secret" secret-id="{{ object.pk }}">
-                            <i class="mdi mdi-lock"></i> Unlock
-                        </button>
-                        <button class="btn btn-sm btn-outline-dark copy-secret d-none" secret-id="{{ object.pk }}" data-clipboard-target="#secret_{{ object.pk }}">
-                            <i class="mdi mdi-content-copy"></i> Copy
-                        </button>
-                        <button class="btn btn-sm btn-danger lock-secret d-none" secret-id="{{ object.pk }}">
-                            <i class="mdi mdi-lock-open"></i> Lock
-                        </button>
+                        {% include 'netbox_secrets/inc/secret_actions.html' %}
                     </div>
                 </div>
             </div>
diff --git a/netbox_secrets/templates/netbox_secrets/secret_bulk_import.html b/netbox_secrets/templates/netbox_secrets/secret_bulk_import.html
deleted file mode 100644
index 1ff0a95..0000000
--- a/netbox_secrets/templates/netbox_secrets/secret_bulk_import.html
+++ /dev/null
@@ -1,7 +0,0 @@
-{% extends 'generic/object_bulk_import.html' %}
-{% load static %}
-
-{% block content %}
-{{ block.super }}
-{% include 'netbox_secrets/inc/private_key_modal.html' %}
-{% endblock %}
diff --git a/netbox_secrets/templates/netbox_secrets/secret_delete.html b/netbox_secrets/templates/netbox_secrets/secret_delete.html
deleted file mode 100644
index 4e6068e..0000000
--- a/netbox_secrets/templates/netbox_secrets/secret_delete.html
+++ /dev/null
@@ -1,8 +0,0 @@
-{% extends 'utilities/confirmation_form.html' %}
-{% load form_helpers %}
-
-{% block title %}Delete secret {{ secret }}?{% endblock %}
-
-{% block message %}
-    <p>Are you sure you want to delete secret {{ secret }}?</p>
-{% endblock %}
diff --git a/netbox_secrets/templates/netbox_secrets/secret_edit.html b/netbox_secrets/templates/netbox_secrets/secret_edit.html
index a900e88..0cd48ea 100644
--- a/netbox_secrets/templates/netbox_secrets/secret_edit.html
+++ b/netbox_secrets/templates/netbox_secrets/secret_edit.html
@@ -1,89 +1,16 @@
 {% extends 'generic/object_edit.html' %}
+{% load helpers %}
 {% load static %}
 {% load form_helpers %}
-
-{% block title %}{% if obj.pk %}Editing {{ obj }}{% else %}Add a Secret{% endif %}{% endblock %}
-
 {% block form %}
-{% render_errors form %}
-
-{{ form.private_key }}
-<div class="field-group">
-    <h4>Secret Assignment</h4>
-    {% with vm_tab_active=form.initial.virtual_machine %}
-        <ul class="nav nav-tabs" role="tablist">
-            <li class="nav-item" role="presentation">
-                <button
-                    role="tab"
-                    type="button"
-                    id="device_tab"
-                    data-bs-toggle="tab"
-                    class="nav-link{% if not vm_tab_active %} active{% endif %}"
-                    data-bs-target="#device"
-                    aria-controls="device"
-                >
-                    Device
-                </button>
-            </li>
-            <li class="nav-item" role="presentation">
-                <button
-                    role="tab"
-                    type="button"
-                    id="vm_tab"
-                    data-bs-toggle="tab"
-                    class="nav-link{% if vm_tab_active %} active{% endif %}"
-                    data-bs-target="#virtualmachine"
-                    aria-controls="virtualmachine"
-                >
-                    Virtual Machine
-                </button>
-            </li>
-        </ul>
-        <div class="tab-content">
-            <div class="tab-pane{% if not vm_tab_active %} active{% endif %}" id="device">
-                {% render_field form.device %}
-            </div>
-            <div class="tab-pane{% if vm_tab_active %} active{% endif %}" id="virtualmachine">
-                {% render_field form.virtual_machine %}
-            </div>
-    {% endwith %}
-    {% render_field form.role %}
-    {% render_field form.name %}
-    {% render_field form.tags %}
-    </div>
-</div>
-<div class="field-group">
-    <h4>Secret Data</h4>
-    {% if obj.pk %}
+    <div class="field-group-mb-5">
         <div class="row mb-3">
-            <label class="col-sm-3 col-form-label text-end">Current Plain Text</label>
-            <div class="col">
-                <input class="form-control font-monospace" value="********" id="secret_{{ obj.pk }}" disabled />
+            <label class="col-sm-3 col-form-label text-lg-end required">
+                {{ object.assigned_object|meta:"verbose_name"|bettertitle }}</label>
+            <div class="col-sm-9">
+                <div class="form-control-plaintext">{{ object.assigned_object|linkify }}</div>
             </div>
         </div>
-        <div class="row mb-3">
-            <div class="col col-sm-3 offset-sm-9 text-end">
-                <button type="button" class="btn btn-sm btn-success unlock-secret" secret-id="{{ obj.pk }}">
-                    <i class="mdi mdi-lock"></i> Unlock
-                </button>
-                <button type="button" class="btn btn-sm, btn-danger lock-secret d-none" secret-id="{{ obj.pk }}">
-                    <i class="mdi mdi-lock-open"></i> Lock
-                </button>
-            </div>
-        </div>
-    {% endif %}
-    {% render_field form.plaintext %}
-    {% render_field form.plaintext2 %}
-
-</div>
-{% if form.custom_fields %}
-    <div class="card">
-        <h5 class="card-header">Custom Fields</h5>
-        <div class="card-body">
-            {% render_custom_fields form %}
-        </div>
+        {% render_form form %}
     </div>
-{% endif %}
-
-{% include 'netbox_secrets/inc/private_key_modal.html' %}
-{% endblock %}
+{% endblock form %}
\ No newline at end of file
diff --git a/netbox_secrets/templates/netbox_secrets/sessionkey_delete.html b/netbox_secrets/templates/netbox_secrets/sessionkey_delete.html
deleted file mode 100644
index e3e8a7e..0000000
--- a/netbox_secrets/templates/netbox_secrets/sessionkey_delete.html
+++ /dev/null
@@ -1,5 +0,0 @@
-{% extends 'generic/object_delete.html' %}
-
-{% block message %}
-    <p>Are you sure you want to delete your session key?</p>
-{% endblock %}
diff --git a/netbox_secrets/urls.py b/netbox_secrets/urls.py
index 07de2dc..5c744ef 100644
--- a/netbox_secrets/urls.py
+++ b/netbox_secrets/urls.py
@@ -1,36 +1,33 @@
 from django.urls import path
 
 from netbox.views.generic import ObjectChangeLogView, ObjectJournalView
-from .views import *
-from .models import Secret, SecretRole
+from . import views, models
 
 urlpatterns = [
 
     # Secret roles
-    path('secret-roles/', SecretRoleListView.as_view(), name='secretrole_list'),
-    path('secret-roles/add/', SecretRoleEditView.as_view(), name='secretrole_add'),
-    path('secret-roles/import/', SecretRoleBulkImportView.as_view(), name='secretrole_import'),
-    path('secret-roles/edit/', SecretRoleBulkEditView.as_view(), name='secretrole_bulk_edit'),
-    path('secret-roles/delete/', SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'),
-    path('secret-roles/<int:pk>/', SecretRoleView.as_view(), name='secretrole'),
-    path('secret-roles/<int:pk>/edit/', SecretRoleEditView.as_view(), name='secretrole_edit'),
-    path('secret-roles/<int:pk>/delete/', SecretRoleDeleteView.as_view(), name='secretrole_delete'),
-    path('secret-roles/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='secretrole_changelog', kwargs={'model': SecretRole}),
+    path('secret-roles/', views.SecretRoleListView.as_view(), name='secretrole_list'),
+    path('secret-roles/add/', views.SecretRoleEditView.as_view(), name='secretrole_add'),
+    path('secret-roles/import/', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'),
+    path('secret-roles/edit/', views.SecretRoleBulkEditView.as_view(), name='secretrole_bulk_edit'),
+    path('secret-roles/delete/', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'),
+    path('secret-roles/<int:pk>/', views.SecretRoleView.as_view(), name='secretrole'),
+    path('secret-roles/<int:pk>/edit/', views.SecretRoleEditView.as_view(), name='secretrole_edit'),
+    path('secret-roles/<int:pk>/delete/', views.SecretRoleDeleteView.as_view(), name='secretrole_delete'),
+    path('secret-roles/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='secretrole_changelog', kwargs={'model': models.SecretRole}),
 
     # Secrets
-    path('secrets/', SecretListView.as_view(), name='secret_list'),
-    path('secrets/add/', SecretEditView.as_view(), name='secret_add'),
-    path('secrets/import/', SecretBulkImportView.as_view(), name='secret_import'),
-    path('secrets/edit/', SecretBulkEditView.as_view(), name='secret_bulk_edit'),
-    path('secrets/delete/', SecretBulkDeleteView.as_view(), name='secret_bulk_delete'),
-    path('secrets/<int:pk>/', SecretView.as_view(), name='secret'),
-    path('secrets/<int:pk>/edit/', SecretEditView.as_view(), name='secret_edit'),
-    path('secrets/<int:pk>/delete/', SecretDeleteView.as_view(), name='secret_delete'),
-    path('secrets/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='secret_changelog', kwargs={'model': Secret}),
-    path('secrets/<int:pk>/journal/', ObjectJournalView.as_view(), name='secret_journal', kwargs={'model': Secret}),
+    path('secrets/', views.SecretListView.as_view(), name='secret_list'),
+    path('secrets/add/', views.SecretEditView.as_view(), name='secret_add'),
+    path('secrets/delete/', views.SecretBulkDeleteView.as_view(), name='secret_bulk_delete'),
+    path('secrets/<int:pk>/', views.SecretView.as_view(), name='secret'),
+    path('secrets/<int:pk>/edit/', views.SecretEditView.as_view(), name='secret_edit'),
+    path('secrets/<int:pk>/delete/', views.SecretDeleteView.as_view(), name='secret_delete'),
+    path('secrets/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='secret_changelog', kwargs={'model': models.Secret}),
+    path('secrets/<int:pk>/journal/', ObjectJournalView.as_view(), name='secret_journal', kwargs={'model': models.Secret}),
 
     # User
-    path('user-key/', UserKeyView.as_view(), name='userkey'),
-    path('user-key/edit/', UserKeyEditView.as_view(), name='userkey_edit'),
-    path('session-key/delete/', SessionKeyDeleteView.as_view(), name='sessionkey_delete'),
+    path('user-key/', views.UserKeyView.as_view(), name='userkey'),
+    path('user-key/edit/', views.UserKeyEditView.as_view(), name='userkey_edit'),
+    path('session-key/delete/', views.SessionKeyDeleteView.as_view(), name='sessionkey_delete'),
 ]
diff --git a/netbox_secrets/utils/__init__.py b/netbox_secrets/utils/__init__.py
index 7bfd124..d3a9773 100644
--- a/netbox_secrets/utils/__init__.py
+++ b/netbox_secrets/utils/__init__.py
@@ -1,7 +1,9 @@
 from .crypto import *
+from .helpers import *
 
 __all__ = (
     'encrypt_master_key',
     'decrypt_master_key',
     'generate_random_key',
+    'get_session_key',
 )
diff --git a/netbox_secrets/utils/helpers.py b/netbox_secrets/utils/helpers.py
index fcf9d52..b74cecc 100644
--- a/netbox_secrets/utils/helpers.py
+++ b/netbox_secrets/utils/helpers.py
@@ -1,5 +1,7 @@
 import base64
 
+__all__ = ['get_session_key']
+
 
 def get_session_key(request):
     """
diff --git a/netbox_secrets/utils/tables.py b/netbox_secrets/utils/tables.py
deleted file mode 100644
index b93ccaf..0000000
--- a/netbox_secrets/utils/tables.py
+++ /dev/null
@@ -1,5 +0,0 @@
-from netbox.tables import columns
-
-
-class PluginButtonsColumn(columns.ActionsColumn):
-    pass
diff --git a/netbox_secrets/views.py b/netbox_secrets/views.py
index 887a7a7..ff79eaf 100644
--- a/netbox_secrets/views.py
+++ b/netbox_secrets/views.py
@@ -1,32 +1,21 @@
-import base64
 import logging
 
 from django.contrib import messages
 from django.contrib.auth.mixins import LoginRequiredMixin
+from django.contrib.contenttypes.models import ContentType
+from django.db import transaction
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.utils.html import escape
 from django.utils.safestring import mark_safe
 from django.views.generic.base import View
 
+from extras.signals import clear_webhooks
 from netbox.views import generic
-from netbox_secrets.forms import SecretRoleFilterForm
-from utilities.forms import ConfirmationForm
-from utilities.utils import count_related
-from .filtersets import *
-from .forms import *
-from .models import SessionKey, UserKey
-from .tables import *
-
-
-def get_session_key(request):
-    """
-    Extract and decode the session key sent with a request. Returns None if no session key was provided.
-    """
-    session_key = request.COOKIES.get('session_key', None)
-    if session_key is not None:
-        return base64.b64decode(session_key)
-    return session_key
+from utilities.exceptions import AbortRequest, PermissionsViolation
+from utilities.forms import ConfirmationForm, restrict_form_fields
+from utilities.utils import count_related, prepare_cloned_fields
+from . import filtersets, forms, models, tables, utils
 
 
 #
@@ -34,23 +23,23 @@ def get_session_key(request):
 #
 
 class SecretRoleListView(generic.ObjectListView):
-    queryset = SecretRole.objects.annotate(
-        secret_count=count_related(Secret, 'role')
-    )
-    table = SecretRoleTable
-    filterset = SecretRoleFilterSet
-    filterset_form = SecretRoleFilterForm
+    queryset = models.SecretRole.objects.annotate(
+        secret_count=count_related(models.Secret, 'role')
+    ).prefetch_related('tags')
+    table = tables.SecretRoleTable
+    filterset = filtersets.SecretRoleFilterSet
+    filterset_form = forms.SecretRoleFilterForm
 
 
 class SecretRoleView(generic.ObjectView):
-    queryset = SecretRole.objects.all()
+    queryset = models.SecretRole.objects.prefetch_related('tags')
 
     def get_extra_context(self, request, instance):
-        secrets = Secret.objects.restrict(request.user, 'view').filter(
+        secrets = models.Secret.objects.restrict(request.user, 'view').filter(
             role=instance
         )
 
-        secrets_table = SecretTable(secrets, exclude=('role',))
+        secrets_table = tables.SecretTable(secrets, exclude=('role',))
         secrets_table.configure(request)
 
         return {
@@ -59,34 +48,34 @@ def get_extra_context(self, request, instance):
 
 
 class SecretRoleEditView(generic.ObjectEditView):
-    queryset = SecretRole.objects.all()
-    form = SecretRoleForm
+    queryset = models.SecretRole.objects.prefetch_related('tags')
+    form = forms.SecretRoleForm
 
 
 class SecretRoleDeleteView(generic.ObjectDeleteView):
-    queryset = SecretRole.objects.all()
+    queryset = models.SecretRole.objects.prefetch_related('tags')
 
 
 class SecretRoleBulkImportView(generic.BulkImportView):
-    queryset = SecretRole.objects.all()
-    model_form = SecretRoleCSVForm
-    table = SecretRoleTable
+    queryset = models.SecretRole.objects.prefetch_related('tags')
+    model_form = forms.SecretRoleCSVForm
+    table = tables.SecretRoleTable
 
 
 class SecretRoleBulkEditView(generic.BulkEditView):
-    queryset = SecretRole.objects.annotate(
-        secret_count=count_related(Secret, 'role')
-    )
-    filterset = SecretRoleFilterSet
-    table = SecretRoleTable
-    form = SecretRoleBulkEditForm
+    queryset = models.SecretRole.objects.annotate(
+        secret_count=count_related(models.Secret, 'role')
+    ).prefetch_related('tags')
+    filterset = filtersets.SecretRoleFilterSet
+    table = tables.SecretRoleTable
+    form = forms.SecretRoleBulkEditForm
 
 
 class SecretRoleBulkDeleteView(generic.BulkDeleteView):
-    queryset = SecretRole.objects.annotate(
-        secret_count=count_related(Secret, 'role')
-    )
-    table = SecretRoleTable
+    queryset = models.SecretRole.objects.annotate(
+        secret_count=count_related(models.Secret, 'role')
+    ).prefetch_related('tags')
+    table = tables.SecretRoleTable
 
 
 #
@@ -94,28 +83,44 @@ class SecretRoleBulkDeleteView(generic.BulkDeleteView):
 #
 
 class SecretListView(generic.ObjectListView):
-    queryset = Secret.objects.all()
-    filterset = SecretFilterSet
-    filterset_form = SecretFilterForm
-    table = SecretTable
-    action_buttons = ('add', 'import', 'export')
+    queryset = models.Secret.objects.prefetch_related('role','tags')
+    filterset = filtersets.SecretFilterSet
+    filterset_form = forms.SecretFilterForm
+    table = tables.SecretTable
+    actions = ('bulk_delete', 'bulk_edit')
 
 
 class SecretView(generic.ObjectView):
-    queryset = Secret.objects.all()
+    queryset = models.Secret.objects.prefetch_related('role','tags')
 
 
 class SecretEditView(generic.ObjectEditView):
-    queryset = Secret.objects.all()
-    form = SecretForm
+    queryset = models.Secret.objects.prefetch_related('role','tags')
+    form = forms.SecretForm
     template_name = 'netbox_secrets/secret_edit.html'
 
+    def alter_object(self, instance, request, url_args, url_kwargs):
+        if not instance.pk:
+            # Assign the assigned_object based on the URL parameters
+            content_type = get_object_or_404(ContentType, pk=request.GET.get('assigned_object_type'))
+            instance.assigned_object = get_object_or_404(
+                content_type.model_class(), pk=request.GET.get('assigned_object_id')
+            )
+
+        return instance
+
+    def get_extra_addanother_params(self, request):
+        return {
+            'assigned_object_type': request.GET.get('assigned_object_type'),
+            'assigned_object_id': request.GET.get('assigned_object_id'),
+        }
+
     def dispatch(self, request, *args, **kwargs):
 
         # Check that the user has a valid UserKey
         try:
-            uk = UserKey.objects.get(user=request.user)
-        except UserKey.DoesNotExist:
+            uk = models.UserKey.objects.get(user=request.user)
+        except models.UserKey.DoesNotExist:
             messages.warning(request, "This operation requires an active user key, but you don't have one.")
             return redirect('plugins:netbox_secrets:userkey')
         if not uk.is_active():
@@ -126,116 +131,99 @@ def dispatch(self, request, *args, **kwargs):
 
     def post(self, request, *args, **kwargs):
         logger = logging.getLogger('netbox.views.ObjectEditView')
-        session_key = get_session_key(request)
-        secret = self.get_object(**kwargs)
-        form = self.form(request.POST, instance=secret)
-
-        if form.is_valid():
-            logger.debug("Form validation was successful")
-            secret = form.save(commit=False)
-
-            # We must have a session key in order to set the plaintext of a Secret
-            if form.cleaned_data['plaintext'] and session_key is None:
-                logger.debug("Unable to proceed: No session key was provided with the request")
-                form.add_error(None, "No session key was provided with the request. Unable to encrypt secret data.")
-
-            elif form.cleaned_data['plaintext']:
-                master_key = None
-                try:
-                    sk = SessionKey.objects.get(userkey__user=request.user)
-                    master_key = sk.get_master_key(session_key)
-                except SessionKey.DoesNotExist:
-                    logger.debug("Unable to proceed: User has no session key assigned")
-                    form.add_error(None, "No session key found for this user.")
+        obj = self.get_object(**kwargs)
+        session_key = utils.get_session_key(request)
 
-                if master_key is not None:
-                    logger.debug("Successfully resolved master key for encryption")
-                    secret.plaintext = str(form.cleaned_data['plaintext'])
-                    secret.encrypt(master_key)
+        # Take a snapshot for change logging (if editing an existing object)
+        if obj.pk and hasattr(obj, 'snapshot'):
+            obj.snapshot()
 
-            secret.save()
-            form.save_m2m()
+        obj = self.alter_object(obj, request, args, kwargs)
 
-            msg = '{} secret'.format('Created' if not form.instance.pk else 'Modified')
-            logger.info(f"{msg} {secret} (PK: {secret.pk})")
-            msg = f'{msg} <a href="{secret.get_absolute_url()}">{escape(secret)}</a>'
-            messages.success(request, mark_safe(msg))
+        form = self.form(data=request.POST, instance=obj)
+        restrict_form_fields(form, request.user)
 
-            return redirect(self.get_return_url(request, secret))
+        if form.is_valid():
+            logger.debug("Form validation was successful")
+            try:
+                with transaction.atomic():
+                    object_created = form.instance.pk is None
+                    obj = form.save(commit=False)
+
+                    # We must have a session key in order to set the plaintext of a Secret
+                    if form.cleaned_data['plaintext'] and session_key is None:
+                        logger.debug("Unable to proceed: No session key was provided with the request")
+                        form.add_error(None,
+                                       "No session key was provided with the request. Unable to encrypt secret data.")
+
+                    elif form.cleaned_data['plaintext']:
+                        master_key = None
+                        try:
+                            sk = models.SessionKey.objects.get(userkey__user=request.user)
+                            master_key = sk.get_master_key(session_key)
+                        except models.SessionKey.DoesNotExist:
+                            logger.debug("Unable to proceed: User has no session key assigned")
+                            form.add_error(None, "No session key found for this user.")
+
+                        if master_key is not None:
+                            logger.debug("Successfully resolved master key for encryption")
+                            obj.plaintext = str(form.cleaned_data['plaintext'])
+                            obj.encrypt(master_key)
+
+                    obj.save()
+                    form.save_m2m()
+
+                    msg = '{} {}'.format(
+                        'Created' if object_created else 'Modified',
+                        self.queryset.model._meta.verbose_name
+                    )
+                    logger.info(f"{msg} {obj} (PK: {obj.pk})")
+                    if hasattr(obj, 'get_absolute_url'):
+                        msg = mark_safe(f'{msg} <a href="{obj.get_absolute_url()}">{escape(obj)}</a>')
+                    else:
+                        msg = f'{msg} {obj}'
+                    messages.success(request, msg)
+
+                    if '_addanother' in request.POST:
+                        redirect_url = request.path
+
+                        # If cloning is supported, pre-populate a new instance of the form
+                        params = prepare_cloned_fields(obj)
+                        params.update(self.get_extra_addanother_params(request))
+                        if params:
+                            if 'return_url' in request.GET:
+                                params['return_url'] = request.GET.get('return_url')
+                            redirect_url += f"?{params.urlencode()}"
+
+                        return redirect(redirect_url)
+
+                    return_url = self.get_return_url(request, obj)
+
+                    return redirect(return_url)
+            except (AbortRequest, PermissionsViolation) as e:
+                logger.debug(e.message)
+                form.add_error(None, e.message)
+                clear_webhooks.send(sender=self)
 
         else:
             logger.debug("Form validation failed")
 
         return render(request, self.template_name, {
-            'obj': secret,
-            'obj_type': self.queryset.model._meta.verbose_name,
+            'object': obj,
             'form': form,
-            'return_url': self.get_return_url(request, secret),
+            'return_url': self.get_return_url(request, obj),
+            **self.get_extra_context(request, obj),
         })
 
 
 class SecretDeleteView(generic.ObjectDeleteView):
-    queryset = Secret.objects.all()
-
-
-class SecretBulkImportView(generic.BulkImportView):
-    queryset = Secret.objects.all()
-    model_form = SecretCSVForm
-    table = SecretTable
-    template_name = 'netbox_secrets/secret_bulk_import.html'
-    widget_attrs = {'class': 'requires-session-key'}
-
-    master_key = None
-
-    def _save_obj(self, obj_form, request):
-        """
-        Encrypt each object before saving it to the database.
-        """
-        obj = obj_form.save(commit=False)
-        obj.encrypt(self.master_key)
-        obj.save()
-        return obj
-
-    def post(self, request):
-
-        # Grab the session key from cookies.
-        session_key = request.COOKIES.get('session_key')
-        if session_key:
-
-            # Attempt to derive the master key using the provided session key.
-            try:
-                sk = SessionKey.objects.get(userkey__user=request.user)
-                self.master_key = sk.get_master_key(base64.b64decode(session_key))
-            except SessionKey.DoesNotExist:
-                messages.error(request, "No session key found for this user.")
-
-            if self.master_key is not None:
-                return super().post(request)
-            else:
-                messages.error(request, "Invalid private key! Unable to encrypt secret data.")
-
-        else:
-            messages.error(request, "No session key was provided with the request. Unable to encrypt secret data.")
-
-        return render(request, self.template_name, {
-            'form': self._import_form(request.POST),
-            'fields': self.model_form().fields,
-            'obj_type': self.model_form._meta.model._meta.verbose_name,
-            'return_url': self.get_return_url(request),
-        })
-
-
-class SecretBulkEditView(generic.BulkEditView):
-    queryset = Secret.objects.prefetch_related('role')
-    filterset = SecretFilterSet
-    table = SecretTable
-    form = SecretBulkEditForm
+    queryset = models.Secret.objects.prefetch_related('role','tags')
 
 
 class SecretBulkDeleteView(generic.BulkDeleteView):
-    queryset = Secret.objects.prefetch_related('role')
-    filterset = SecretFilterSet
-    table = SecretTable
+    queryset = models.Secret.objects.prefetch_related('role','tags')
+    filterset = filtersets.SecretFilterSet
+    table = tables.SecretTable
 
 
 class UserKeyView(LoginRequiredMixin, View):
@@ -243,8 +231,8 @@ class UserKeyView(LoginRequiredMixin, View):
 
     def get(self, request):
         try:
-            userkey = UserKey.objects.get(user=request.user)
-        except UserKey.DoesNotExist:
+            userkey = models.UserKey.objects.get(user=request.user)
+        except models.UserKey.DoesNotExist:
             userkey = None
 
         return render(request, self.template_name, {
@@ -254,19 +242,19 @@ def get(self, request):
 
 
 class UserKeyEditView(LoginRequiredMixin, View):
-    queryset = SessionKey.objects.all()
+    queryset = models.SessionKey.objects.all()
     template_name = 'netbox_secrets/userkey_edit.html'
 
     def dispatch(self, request, *args, **kwargs):
         try:
-            self.userkey = UserKey.objects.get(user=request.user)
-        except UserKey.DoesNotExist:
-            self.userkey = UserKey(user=request.user)
+            self.userkey = models.UserKey.objects.get(user=request.user)
+        except models.UserKey.DoesNotExist:
+            self.userkey = models.UserKey(user=request.user)
 
         return super().dispatch(request, *args, **kwargs)
 
     def get(self, request):
-        form = UserKeyForm(instance=self.userkey)
+        form = forms.UserKeyForm(instance=self.userkey)
         print(self.template_name)
         return render(request, self.template_name, {
             'object': self.userkey,
@@ -275,7 +263,7 @@ def get(self, request):
         })
 
     def post(self, request):
-        form = UserKeyForm(data=request.POST, instance=self.userkey)
+        form = forms.UserKeyForm(data=request.POST, instance=self.userkey)
         if form.is_valid():
             uk = form.save(commit=False)
             uk.user = request.user
@@ -291,10 +279,10 @@ def post(self, request):
 
 
 class SessionKeyDeleteView(generic.ObjectDeleteView):
-    queryset = SessionKey.objects.all()
+    queryset = models.SessionKey.objects.all()
 
     def get(self, request):
-        sessionkey = get_object_or_404(SessionKey, userkey__user=request.user)
+        sessionkey = get_object_or_404(models.SessionKey, userkey__user=request.user)
         form = ConfirmationForm()
 
         return render(request, 'netbox_secrets/sessionkey_delete.html', {
@@ -305,7 +293,7 @@ def get(self, request):
         })
 
     def post(self, request):
-        sessionkey = get_object_or_404(SessionKey, userkey__user=request.user)
+        sessionkey = get_object_or_404(models.SessionKey, userkey__user=request.user)
         form = ConfirmationForm(request.POST)
         if form.is_valid():
             # Delete session key
diff --git a/setup.py b/setup.py
index ae74eff..2fa715f 100644
--- a/setup.py
+++ b/setup.py
@@ -2,11 +2,10 @@
 
 setup(
     name='netbox-secrets',
-    version='1.4.0',
-    description='Netbox Secret Store',
+    version='1.5.0',
+    description='Netbox Secrets',
     long_description='A Secret store for NetBox',
-    url='https://github.com/dansheps/netbox-secrets/',
-    download_url='https://www.pypi.org/project/netbox-secrets/',
+    url='https://github.com/Onemind-Services-LLC/netbox-secrets/',
     author='Daniel Sheppard',
     author_email='dans@dansheps.com',
     license='Apache 2.0',


Test patch:
diff --git a/netbox_secrets/tests/test_api.py b/netbox_secrets/tests/test_api.py
index 5d0fbb1..ff78984 100644
--- a/netbox_secrets/tests/test_api.py
+++ b/netbox_secrets/tests/test_api.py
@@ -178,9 +178,10 @@ def test_get_session_key(self):
     def test_get_session_key_preserved(self):
         encoded_session_key = base64.b64encode(self.session_key.key).decode()
 
-        url = reverse('plugins-api:netbox_secrets-api:get-session-key-list') + '?preserve_key=True'
+        url = reverse('plugins-api:netbox_secrets-api:get-session-key-list')
         data = {
             'private_key': PRIVATE_KEY,
+            'preserve_key': True,
         }
         response = self.client.post(url, data, **self.header)
 
diff --git a/netbox_secrets/tests/test_filters.py b/netbox_secrets/tests/test_filters.py
index ad8eabe..cf543fc 100644
--- a/netbox_secrets/tests/test_filters.py
+++ b/netbox_secrets/tests/test_filters.py
@@ -94,17 +94,3 @@ def test_role(self):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         params = {'role': [roles[0].slug, roles[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
-
-    def test_device(self):
-        devices = Device.objects.all()[:2]
-        params = {'device_id': [devices[0].pk, devices[1].pk]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-        params = {'device': [devices[0].name, devices[1].name]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
-    def test_virtual_machine(self):
-        virtual_machines = VirtualMachine.objects.all()[:2]
-        params = {'virtual_machine_id': [virtual_machines[0].pk, virtual_machines[1].pk]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-        params = {'virtual_machine': [virtual_machines[0].name, virtual_machines[1].name]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
diff --git a/netbox_secrets/tests/test_views.py b/netbox_secrets/tests/test_views.py
index f21b511..a0e6b29 100644
--- a/netbox_secrets/tests/test_views.py
+++ b/netbox_secrets/tests/test_views.py
@@ -1,8 +1,3 @@
-import base64
-
-from django.test import override_settings
-from django.urls import reverse
-
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from netbox_secrets.models import Secret, SecretRole, SessionKey, UserKey
 from utilities.testing import ViewTestCases
@@ -22,7 +17,15 @@ def _get_base_url(self):
         )
 
 
-class SecretRoleTestCase(SecretsTestMixin, ViewTestCases.OrganizationalObjectViewTestCase):
+class SecretRoleTestCase(
+    SecretsTestMixin,
+    ViewTestCases.GetObjectViewTestCase,
+    ViewTestCases.GetObjectChangelogViewTestCase,
+    ViewTestCases.DeleteObjectViewTestCase,
+    ViewTestCases.ListObjectsViewTestCase,
+    ViewTestCases.BulkEditObjectsViewTestCase,
+    ViewTestCases.BulkDeleteObjectsViewTestCase
+):
     model = SecretRole
 
     @classmethod
@@ -58,7 +61,6 @@ class SecretTestCase(
     ViewTestCases.GetObjectChangelogViewTestCase,
     ViewTestCases.DeleteObjectViewTestCase,
     ViewTestCases.ListObjectsViewTestCase,
-    ViewTestCases.BulkEditObjectsViewTestCase,
     ViewTestCases.BulkDeleteObjectsViewTestCase
 ):
     model = Secret
@@ -97,11 +99,6 @@ def setUpTestData(cls):
             'name': 'Secret X',
         }
 
-        cls.bulk_edit_data = {
-            'role': secretroles[1].pk,
-            'name': 'New name',
-        }
-
     def setUp(self):
         super().setUp()
 
@@ -111,24 +108,3 @@ def setUp(self):
         master_key = userkey.get_master_key(PRIVATE_KEY)
         self.session_key = SessionKey(userkey=userkey)
         self.session_key.save(master_key)
-
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
-    def test_import_objects(self):
-        self.add_permissions('netbox_secrets.add_secret')
-
-        device = Device.objects.get(name='Device 1')
-        csv_data = (
-            "device,role,name,plaintext",
-            f"{device.name},Secret Role 1,Secret 4,abcdefghij",
-            f"{device.name},Secret Role 1,Secret 5,abcdefghij",
-            f"{device.name},Secret Role 1,Secret 6,abcdefghij",
-        )
-
-        # Set the session_key cookie on the request
-        session_key = base64.b64encode(self.session_key.key).decode('utf-8')
-        self.client.cookies['session_key'] = session_key
-
-        response = self.client.post(reverse('plugins:netbox_secrets:secret_import'), {'csv': '\n'.join(csv_data)})
-
-        self.assertHttpStatus(response, 200)
-        self.assertEqual(Secret.objects.count(), 6)