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

Issue:
[Bug]:  List View action Buttons not Visible
### NetBox Secrets plugin version

1.10.2

### NetBox version

3.7

### Steps to Reproduce

Go to secret view and there is no Action buttons Visible on top right corner.

### Expected Behavior

Action Buttons should be visible.

### Observed Behavior

There is no Actions Button visible.
[Bug]:  List View action Buttons not Visible
### NetBox Secrets plugin version

1.10.2

### NetBox version

3.7

### Steps to Reproduce

Go to secret view and there is no Action buttons Visible on top right corner.

### Expected Behavior

Action Buttons should be visible.

### Observed Behavior

There is no Actions Button visible.
NB-12 Add support for NetBox v4
# Pull Request

## Description

This pull request includes significant updates to the secrets plugin, making it fully compatible with NetBox v4. Additionally, it introduces several key management enhancements to improve user experience and security. The key changes are detailed below:

### 1. Compatibility with NetBox v4
- Updated the secrets plugin to ensure full compatibility with the latest version of NetBox (v4).

### 2. Key Management Enhancements

#### Removal of Activate Keys from Admin Panel
- The option to activate keys has been removed from the admin panel. Activation is now managed through the user key table.

#### User Key Table
- A new user key table has been introduced, allowing users to view and manage their keys.

#### Session Key Visibility
- Users can view their session keys within the object view of the user key. However, users do not have permission to view or edit the session keys of others.

#### Add/Edit User Keys
- Buttons for adding and editing user keys are now available within the user key table.

### 3. API Enhancements
- A new API has been added to enable the activation of user keys through API calls.

## Impact
These updates enhance the usability and security of the secrets plugin, providing a more streamlined experience for users and administrators alike. The compatibility with NetBox v4 ensures that users can take advantage of the latest features and improvements in the platform.

## Testing
- Ensure that the plugin works seamlessly with NetBox v4.
- Verify that the activate keys functionality is properly removed from the admin panel and integrated into the user key table.
- Validate the functionality of the add/edit buttons in the user key table.
- Test the new API for user key activation to ensure it works as expected.


### Fixes: #136 


Discussion / hints:

Fix patch:
diff --git a/.github/labels.toml b/.github/labels.toml
deleted file mode 100644
index b063e45..0000000
--- a/.github/labels.toml
+++ /dev/null
@@ -1,119 +0,0 @@
-["type: performance"]
-color = "6572A4"
-name = "type: performance"
-description = ""
-
-["status: abandoned"]
-color = "fc9580"
-name = "status: abandoned"
-description = "It's believed that this issue is no longer important to the requestor and no one else has shown an i"
-
-["status: accepted"]
-color = "d66039"
-name = "status: accepted"
-description = "It's clear what the subject of the issue is about, and what the resolution should be."
-
-["status: available"]
-color = "fcefa6"
-name = "status: available"
-description = "No one has claimed responsibility for resolving this issue."
-
-["status: blocked"]
-color = "ffcc9e"
-name = "status: blocked"
-description = "There is another issue that needs to be resolved first, or a specific person is required to comment"
-
-["status: completed"]
-color = "250077"
-name = "status: completed"
-description = "Nothing further to be done with this issue."
-
-["status: in progress"]
-color = "f4c9a1"
-name = "status: in progress"
-description = "This issue is being worked on"
-
-["status: on hold"]
-color = "c8a2f9"
-name = "status: on hold"
-description = "Similar to blocked, but is assigned to someone"
-
-["status: review needed"]
-color = "e99695"
-name = "status: review needed"
-description = "The issue has a PR attached to it which needs to be reviewed."
-
-["type: breaking"]
-color = "d6a30c"
-name = "type: breaking"
-description = ""
-
-["type: bug"]
-color = "d73a4a"
-name = "type: bug"
-description = "Something isn't working"
-
-["type: dependencies"]
-color = "0366d6"
-name = "type: dependencies"
-description = "Pull requests that update a dependency file"
-
-["type: deprecated"]
-color = "2e0baa"
-name = "type: deprecated"
-description = ""
-
-["type: docs"]
-color = "0075ca"
-name = "type: docs"
-description = "Improvements or additions to documentation"
-
-["type: duplicate"]
-color = "cfd3d7"
-name = "type: duplicate"
-description = "This issue or pull request already exists"
-
-["type: feature"]
-color = "a2eeef"
-name = "type: feature"
-description = "New feature or request"
-
-["type: help wanted"]
-color = "008672"
-name = "type: help wanted"
-description = "Extra attention is needed"
-
-["type: invalid"]
-color = "e4e669"
-name = "type: invalid"
-description = "This doesn't seem right"
-
-["type: maintenance"]
-color = "c2e0c6"
-name = "type: maintenance"
-description = ""
-
-["type: question"]
-color = "d876e3"
-name = "type: question"
-description = "Further information is requested"
-
-["type: security"]
-color = "7dede7"
-name = "type: security"
-description = "Pull requests that address a security vulnerability"
-
-["type: tests"]
-color = "F25F44"
-name = "type: tests"
-description = ""
-
-["type: wontfix"]
-color = "ffffff"
-name = "type: wontfix"
-description = "This will not be worked on"
-
-["type: ui/ux"]
-color = "5319E7"
-name = "type: ui/ux"
-description = "UI/UX design"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c5afbed..269bd19 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -21,7 +21,7 @@ jobs:
           fetch-depth: 0
       - uses: actions/setup-python@v4
         with:
-          python-version: 3.9
+          python-version: 3.12
       - name: Lint Code Base
         uses: github/super-linter/slim@v4
         env:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7dd205b..55370f1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,43 @@
 
 ## [Unreleased](https://github.com/Onemind-Services-LLC/netbox-secrets/tree/HEAD)
 
-[Full Changelog](https://github.com/Onemind-Services-LLC/netbox-secrets/compare/v1.10.0...HEAD)
+[Full Changelog](https://github.com/Onemind-Services-LLC/netbox-secrets/compare/v1.10.2...HEAD)
+
+**Closed issues:**
+
+- Netbox 4.0 Support [\#136](https://github.com/Onemind-Services-LLC/netbox-secrets/issues/136)
+- \[Docs\]: Add help for upgrading from netbox 2.11 to 3.x [\#134](https://github.com/Onemind-Services-LLC/netbox-secrets/issues/134)
+- \[Bug\]:  List View action Buttons not Visible [\#132](https://github.com/Onemind-Services-LLC/netbox-secrets/issues/132)
+- \[Feature\]:  Hashicorp Vault integration? [\#125](https://github.com/Onemind-Services-LLC/netbox-secrets/issues/125)
+- \[Bug\]: User Key Add Error: `'str' object has no attribute '_meta'` [\#116](https://github.com/Onemind-Services-LLC/netbox-secrets/issues/116)
+- \[Bug\]: Cannot create more than one un-named secret for an object [\#100](https://github.com/Onemind-Services-LLC/netbox-secrets/issues/100)
+- \[Feature\]: pass session key in GET/POST requests data [\#85](https://github.com/Onemind-Services-LLC/netbox-secrets/issues/85)
+- \[Feature\]: Add button to export keys to a file for download and a button to copy the private key [\#16](https://github.com/Onemind-Services-LLC/netbox-secrets/issues/16)
+
+**Merged pull requests:**
+
+- Bump braces from 3.0.2 to 3.0.3 in /netbox\_secrets/project-static in the npm\_and\_yarn group across 1 directory [\#140](https://github.com/Onemind-Services-LLC/netbox-secrets/pull/140) ([dependabot[bot]](https://github.com/apps/dependabot))
+
+## [v1.10.2](https://github.com/Onemind-Services-LLC/netbox-secrets/tree/v1.10.2) (2024-01-26)
+
+[Full Changelog](https://github.com/Onemind-Services-LLC/netbox-secrets/compare/v1.9.2...v1.10.2)
+
+**Merged pull requests:**
+
+- Reverted 8257a03 to fix filtersets [\#129](https://github.com/Onemind-Services-LLC/netbox-secrets/pull/129) ([abhi1693](https://github.com/abhi1693))
+
+## [v1.9.2](https://github.com/Onemind-Services-LLC/netbox-secrets/tree/v1.9.2) (2024-01-26)
+
+[Full Changelog](https://github.com/Onemind-Services-LLC/netbox-secrets/compare/v1.10.1...v1.9.2)
+
+**Closed issues:**
+
+- \[Feature\]: Disable encryption? [\#126](https://github.com/Onemind-Services-LLC/netbox-secrets/issues/126)
+- Mismatch in PluginConfig Attributes and Update descriptions in setup.py file [\#124](https://github.com/Onemind-Services-LLC/netbox-secrets/issues/124)
+
+## [v1.10.1](https://github.com/Onemind-Services-LLC/netbox-secrets/tree/v1.10.1) (2024-01-03)
+
+[Full Changelog](https://github.com/Onemind-Services-LLC/netbox-secrets/compare/v1.10.0...v1.10.1)
 
 **Closed issues:**
 
diff --git a/Dockerfile b/Dockerfile
index 8569e60..06feaa9 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-ARG NETBOX_VARIANT=v3.7
+ARG NETBOX_VARIANT=v4.0
 
 FROM netboxcommunity/netbox:${NETBOX_VARIANT}
 
diff --git a/README.md b/README.md
index dde23a5..5641460 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,7 @@ and stability.
 | 3.5.x          | 1.8.x          |
 | 3.6.x          | 1.9.x          |
 | 3.7.x          | 1.10.x         |
+| 4.0.x          | 2.0.x          |
 
 # Installation
 
diff --git a/docker-compose.yml b/docker-compose.yml
index 4a1024e..c5dd00e 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,5 +1,3 @@
-version: '3.4'
-
 services:
   netbox:
     build:
@@ -14,7 +12,7 @@ services:
 
   # postgres
   postgres:
-    image: postgres:14-alpine
+    image: postgres:16-alpine
     env_file: env/postgres.env
 
   # redis
diff --git a/netbox_secrets/__init__.py b/netbox_secrets/__init__.py
index 3e02853..b59c600 100644
--- a/netbox_secrets/__init__.py
+++ b/netbox_secrets/__init__.py
@@ -1,7 +1,8 @@
 from importlib.metadata import metadata
 
 from django.db.utils import OperationalError, ProgrammingError
-from extras.plugins import PluginConfig
+
+from netbox.plugins import PluginConfig
 
 metadata = metadata('netbox_secrets')
 
@@ -14,8 +15,8 @@ class NetBoxSecrets(PluginConfig):
     author = metadata.get('Author')
     author_email = metadata.get('Author-email')
     base_url = 'secrets'
-    min_version = '3.7.0'
-    max_version = '3.7.99'
+    min_version = '4.0.0'
+    max_version = '4.0.99'
     required_settings = []
     default_settings = {
         'apps': ['dcim.device', 'virtualization.virtualmachine'],
diff --git a/netbox_secrets/admin.py b/netbox_secrets/admin.py
deleted file mode 100644
index 406b022..0000000
--- a/netbox_secrets/admin.py
+++ /dev/null
@@ -1,72 +0,0 @@
-from django.contrib import admin, messages
-from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
-from django.shortcuts import redirect, render
-
-from .forms import ActivateUserKeyForm
-from .models import UserKey
-
-
-@admin.register(UserKey)
-class UserKeyAdmin(admin.ModelAdmin):
-    actions = ['activate_selected']
-    list_display = ['user', 'is_filled', 'is_active', 'created']
-    fields = ['user', 'public_key', 'is_active', 'last_updated']
-    readonly_fields = ['user', 'is_active', 'last_updated']
-
-    def has_add_permission(self, request):
-        # Don't allow a user to create a new public key directly.
-        return False
-
-    def has_delete_permission(self, request, obj=None):
-        # Don't allow a user to delete a public key directly.
-        return False
-
-    def get_readonly_fields(self, request, obj=None):
-        # Don't allow a user to modify an existing public key directly.
-        if obj and obj.public_key:
-            return ['public_key'] + self.readonly_fields
-        return self.readonly_fields
-
-    @admin.action(description='Activate selected public keys', permissions=['change'])
-    def activate_selected(self, request, queryset):
-        """
-        Enable bulk activation of UserKeys
-        """
-        try:
-            my_userkey = UserKey.objects.get(user=request.user)
-        except UserKey.DoesNotExist:
-            messages.error(request, "You do not have a User Key.")
-            return redirect('admin:netbox_secrets_userkey_changelist')
-
-        if not my_userkey.is_active():
-            messages.error(request, "Your User Key is not active.")
-            return redirect('admin:netbox_secrets_userkey_changelist')
-
-        if 'activate' in request.POST:
-            form = ActivateUserKeyForm(request.POST)
-            if form.is_valid():
-                master_key = my_userkey.get_master_key(form.cleaned_data['secret_key'])
-                if master_key is not None:
-                    for uk in form.cleaned_data[ACTION_CHECKBOX_NAME]:
-                        uk.activate(master_key)
-                    messages.success(
-                        request,
-                        "Successfully activated {} user keys.".format(len(form.cleaned_data[ACTION_CHECKBOX_NAME])),
-                    )
-                    return redirect('admin:netbox_secrets_userkey_changelist')
-                else:
-                    messages.error(
-                        request,
-                        "Invalid private key provided. Unable to retrieve master key.",
-                        extra_tags='error',
-                    )
-        else:
-            form = ActivateUserKeyForm(initial={ACTION_CHECKBOX_NAME: request.POST.getlist(ACTION_CHECKBOX_NAME)})
-
-        return render(
-            request,
-            'netbox_secrets/activate_keys.html',
-            {
-                'form': form,
-            },
-        )
diff --git a/netbox_secrets/api/nested_serializers.py b/netbox_secrets/api/nested_serializers.py
deleted file mode 100644
index 47f8759..0000000
--- a/netbox_secrets/api/nested_serializers.py
+++ /dev/null
@@ -1,44 +0,0 @@
-from netbox.api.serializers import WritableNestedSerializer
-from rest_framework import serializers
-
-from ..models import *
-
-__all__ = [
-    'NestedSecretRoleSerializer',
-    'NestedSecretSerializer',
-    'NestedSessionKeySerializer',
-    'NestedUserKeySerializer',
-]
-
-
-class NestedSecretSerializer(WritableNestedSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='plugins-api:netbox_secrets-api:secret-detail')
-
-    class Meta:
-        model = Secret
-        fields = ['id', 'url', 'display', 'name']
-
-
-class NestedSecretRoleSerializer(WritableNestedSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='plugins-api:netbox_secrets-api:secretrole-detail')
-    secret_count = serializers.IntegerField(read_only=True)
-
-    class Meta:
-        model = SecretRole
-        fields = ['id', 'url', 'display', 'name', 'slug', 'secret_count']
-
-
-class NestedSessionKeySerializer(WritableNestedSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='plugins-api:netbox_secrets-api:sessionkey-detail')
-
-    class Meta:
-        model = SessionKey
-        fields = ['id', 'url', 'display']
-
-
-class NestedUserKeySerializer(WritableNestedSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='plugins-api:netbox_secrets-api:userkey-detail')
-
-    class Meta:
-        model = UserKey
-        fields = ['id', 'url', 'display']
diff --git a/netbox_secrets/api/serializers.py b/netbox_secrets/api/serializers.py
index 3d7b9c8..d8a42b8 100644
--- a/netbox_secrets/api/serializers.py
+++ b/netbox_secrets/api/serializers.py
@@ -1,14 +1,18 @@
+from functools import cached_property
+
 from django.contrib.contenttypes.models import ContentType
+from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
-from netbox.api.fields import ContentTypeField
-from netbox.api.serializers import NetBoxModelSerializer
-from netbox.constants import NESTED_SERIALIZER_PREFIX
 from rest_framework import serializers
-from utilities.api import get_serializer_for_model
+from rest_framework.utils.serializer_helpers import BindingDict
 
+from netbox.api.fields import ContentTypeField
+from netbox.api.serializers import NetBoxModelSerializer
+from users.api.serializers import UserSerializer
+from utilities.api import get_related_object_by_attrs, get_serializer_for_model
 from ..constants import SECRET_ASSIGNABLE_MODELS
 from ..models import *
-from .nested_serializers import *
+
 
 __all__ = [
     'SecretRoleSerializer',
@@ -25,8 +29,11 @@
 #
 
 
-class UserKeySerializer(serializers.ModelSerializer):
+class UserKeySerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='plugins-api:netbox_secrets-api:userkey-detail')
+    user = UserSerializer(
+        nested=True,
+    )
     public_key = serializers.CharField()
     private_key = serializers.CharField(
         write_only=True,
@@ -45,6 +52,7 @@ class Meta:
             'id',
             'url',
             'display',
+            'user',
             'public_key',
             'private_key',
             'created',
@@ -52,10 +60,7 @@ class Meta:
             'is_active',
             'is_filled',
         ]
-
-    @extend_schema_field(serializers.CharField())
-    def get_display(self, obj):
-        return str(obj)
+        brief_fields = ('id', 'display', 'url')
 
 
 #
@@ -68,7 +73,7 @@ class SessionKeySerializer(serializers.ModelSerializer):
 
     display = serializers.SerializerMethodField(read_only=True)
 
-    userkey = NestedUserKeySerializer()
+    userkey = UserKeySerializer(nested=True)
 
     session_key = serializers.SerializerMethodField(
         read_only=True,
@@ -85,15 +90,49 @@ class Meta:
             'session_key',
             'created',
         ]
+        brief_fields = ('id', 'display', 'url')
 
-    @extend_schema_field(serializers.CharField())
+    @extend_schema_field(OpenApiTypes.STR)
     def get_display(self, obj):
         return str(obj)
 
-    @extend_schema_field(serializers.CharField())
+    @extend_schema_field(OpenApiTypes.STR)
     def get_session_key(self, obj):
         return self.context.get('session_key', None)
 
+    def __init__(self, *args, nested=False, fields=None, **kwargs):
+        self.nested = nested
+        self._requested_fields = fields
+        if self.nested:
+            self.validators = []
+        if self.nested and not fields:
+            self._requested_fields = getattr(self.Meta, 'brief_fields', None)
+        super().__init__(*args, **kwargs)
+
+    def to_internal_value(self, data):
+        # If initialized as a nested serializer, we should expect to receive the attrs or PK
+        # identifying a related object.
+        if self.nested:
+            queryset = self.Meta.model.objects.all()
+            return get_related_object_by_attrs(queryset, data)
+
+        return super().to_internal_value(data)
+
+    @cached_property
+    def fields(self):
+        """
+        Override the fields property to check for requested fields. If defined,
+        return only the applicable fields.
+        """
+        if not self._requested_fields:
+            return super().fields
+
+        fields = BindingDict(self)
+        for key, value in self.get_fields().items():
+            if key in self._requested_fields:
+                fields[key] = value
+        return fields
+
 
 class SessionKeyCreateSerializer(serializers.ModelSerializer):
     private_key = serializers.CharField(
@@ -108,8 +147,8 @@ class SessionKeyCreateSerializer(serializers.ModelSerializer):
     class Meta:
         model = SessionKey
         fields = [
-            'private_key',
             'preserve_key',
+            'private_key',
         ]
 
 
@@ -137,6 +176,7 @@ class Meta:
             'last_updated',
             'secret_count',
         ]
+        brief_fields = ('id', 'name', 'display', 'url', 'secret_count', 'slug')
 
 
 #
@@ -148,7 +188,7 @@ class SecretSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='plugins-api:netbox_secrets-api:secret-detail')
     assigned_object_type = ContentTypeField(queryset=ContentType.objects.filter(SECRET_ASSIGNABLE_MODELS))
     assigned_object = serializers.SerializerMethodField(read_only=True)
-    role = NestedSecretRoleSerializer()
+    role = SecretRoleSerializer(nested=True)
     plaintext = serializers.CharField()
 
     class Meta:
@@ -172,12 +212,15 @@ class Meta:
             'last_updated',
         ]
         validators = []
+        brief_fields = ('id', 'display', 'name', 'url')
 
-    @extend_schema_field(serializers.DictField())
+    @extend_schema_field(serializers.JSONField())
     def get_assigned_object(self, obj):
-        serializer = get_serializer_for_model(obj.assigned_object, prefix=NESTED_SERIALIZER_PREFIX)
+        if obj.assigned_object is None:
+            return None
+        serializer = get_serializer_for_model(obj.assigned_object)
         context = {'request': self.context['request']}
-        return serializer(obj.assigned_object, context=context).data
+        return serializer(obj.assigned_object, nested=True, context=context).data
 
     def validate(self, data):
         # Encrypt plaintext data using the master key provided from the view context
@@ -195,3 +238,8 @@ def validate(self, data):
 class RSAKeyPairSerializer(serializers.Serializer):
     public_key = serializers.CharField()
     private_key = serializers.CharField()
+
+
+class ActivateUserKeySerializer(serializers.Serializer):
+    private_key = serializers.CharField()
+    user_keys = serializers.ListField()
diff --git a/netbox_secrets/api/urls.py b/netbox_secrets/api/urls.py
index 6e54106..ecf0e63 100644
--- a/netbox_secrets/api/urls.py
+++ b/netbox_secrets/api/urls.py
@@ -13,5 +13,6 @@
 # Miscellaneous
 router.register('get-session-key', views.GetSessionKeyViewSet, basename='get-session-key')
 router.register('generate-rsa-key-pair', views.GenerateRSAKeyPairViewSet, basename='generate-rsa-key-pair')
+router.register('activate-user-key', views.ActivateUserKeyViewSet, basename='activate-user-keys')
 
 urlpatterns = router.urls
diff --git a/netbox_secrets/api/views.py b/netbox_secrets/api/views.py
index ef8f51b..600e169 100644
--- a/netbox_secrets/api/views.py
+++ b/netbox_secrets/api/views.py
@@ -4,16 +4,16 @@
 from django.conf import settings
 from django.http import HttpResponseBadRequest
 from drf_spectacular import utils as drf_utils
-from rest_framework import mixins as drf_mixins
+from rest_framework import mixins as drf_mixins, status
 from rest_framework.exceptions import ValidationError
 from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
 from rest_framework.permissions import IsAuthenticated
 from rest_framework.response import Response
 from rest_framework.routers import APIRootView
-from rest_framework.viewsets import ModelViewSet, ViewSet
+from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
 
-from netbox.api.viewsets import BaseViewSet, mixins, NetBoxModelViewSet
-from utilities.utils import count_related
+from netbox.api.viewsets import BaseViewSet, NetBoxModelViewSet, mixins
+from utilities.query import count_related
 from . import serializers
 from .. import constants, exceptions, filtersets, models
 
@@ -38,12 +38,10 @@ def get_view_name(self):
 #
 # User Key
 #
-class UserKeyViewSet(ModelViewSet):
+class UserKeyViewSet(ReadOnlyModelViewSet):
     queryset = models.UserKey.objects.all()
     serializer_class = serializers.UserKeySerializer
-
-    def get_queryset(self):
-        return super().get_queryset().filter(user=self.request.user)
+    filterset_class = filtersets.UserKeyFilterSet
 
 
 #
@@ -141,7 +139,6 @@ class SessionKeyViewSet(
     drf_mixins.ListModelMixin,
     drf_mixins.RetrieveModelMixin,
     drf_mixins.DestroyModelMixin,
-    mixins.BriefModeMixin,
     mixins.BulkDestroyModelMixin,
     mixins.ObjectValidationMixin,
     BaseViewSet,
@@ -346,3 +343,48 @@ def create(self, request):
             response.set_cookie('session_key', value=encoded_key)
 
         return response
+
+
+class ActivateUserKeyViewSet(ViewSet):
+    """
+        This endpoint expects a private key and a list of user keys to be activated.
+        The private key is used to derive a master key, which is then used to activate
+        each user key provided.
+    """
+
+    permission_classes = [IsAuthenticated]
+    serializer_class = serializers.ActivateUserKeySerializer
+    parser_classes = [JSONParser, FormParser, MultiPartParser]
+
+    def create(self, request):
+        serializer = self.serializer_class(data=request.data)
+        if not serializer.is_valid():
+            return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+
+        private_key = serializer.validated_data['private_key']
+        user_keys = serializer.validated_data['user_keys']
+
+        # Validate user key
+        try:
+            user_key = models.UserKey.objects.get(user=request.user)
+        except models.UserKey.DoesNotExist:
+            return HttpResponseBadRequest(ERR_USERKEY_MISSING)
+
+        if not user_key.is_active():
+            return HttpResponseBadRequest(ERR_USERKEY_INACTIVE)
+
+        # Validate private key
+        master_key = user_key.get_master_key(private_key)
+        if master_key is None:
+            return HttpResponseBadRequest(ERR_PRIVKEY_INVALID)
+
+        activated_keys = 0
+        for key_data in user_keys:
+            try:
+                user_key = models.UserKey.objects.get(pk=key_data)
+                user_key.activate(master_key)
+                activated_keys += 1
+            except models.UserKey.DoesNotExist:
+                return HttpResponseBadRequest(f"User key with id {key_data} does not exist.")
+
+        return Response(f"Successfully activated {activated_keys} user keys.", status=status.HTTP_200_OK)
diff --git a/netbox_secrets/constants.py b/netbox_secrets/constants.py
index 71f5e17..c57a3f4 100644
--- a/netbox_secrets/constants.py
+++ b/netbox_secrets/constants.py
@@ -14,6 +14,10 @@
 
 SECRET_PLAINTEXT_MAX_LENGTH = 65535
 
+# General-purpose tokens
+CENSOR_MASTER_KEY = '********'
+CENSOR_MASTER_KEY_CHANGED = '***CHANGED***'
+
 
 #
 # Session Keys
diff --git a/netbox_secrets/filtersets.py b/netbox_secrets/filtersets.py
index ac57c41..88dfd31 100644
--- a/netbox_secrets/filtersets.py
+++ b/netbox_secrets/filtersets.py
@@ -1,5 +1,6 @@
 import django_filters
 from django.conf import settings
+from django.contrib.auth import get_user_model
 from django.contrib.contenttypes.models import ContentType
 from django.db.models import Q
 from django.utils.translation import gettext as _
@@ -8,7 +9,7 @@
 from tenancy.models import Contact
 from utilities.filters import ContentTypeFilter, MultiValueCharFilter
 from .constants import SECRET_ASSIGNABLE_MODELS
-from .models import Secret, SecretRole
+from .models import Secret, SecretRole, UserKey
 
 __all__ = [
     'SecretFilterSet',
@@ -18,6 +19,30 @@
 plugin_settings = settings.PLUGINS_CONFIG['netbox_secrets']
 
 
+class UserKeyFilterSet(NetBoxModelFilterSet):
+    user_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=get_user_model().objects.all(),
+        label=_('User (ID)'),
+    )
+    user = django_filters.ModelMultipleChoiceFilter(
+        field_name='user__username',
+        queryset=get_user_model().objects.all(),
+        to_field_name='username',
+        label=_('User (name)'),
+    )
+
+    class Meta:
+        model = UserKey
+        fields = [
+            'id',
+        ]
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(Q(user__username__icontains=value))
+
+
 class SecretRoleFilterSet(NetBoxModelFilterSet):
     name = MultiValueCharFilter(lookup_expr='iexact')
 
diff --git a/netbox_secrets/forms/bulk_edit.py b/netbox_secrets/forms/bulk_edit.py
index 14d00ff..08bc311 100644
--- a/netbox_secrets/forms/bulk_edit.py
+++ b/netbox_secrets/forms/bulk_edit.py
@@ -1,7 +1,8 @@
 from django import forms
+
 from netbox.forms import NetBoxModelBulkEditForm
 from utilities.forms.fields import CommentField
-
+from utilities.forms.rendering import FieldSet
 from ..models import Secret, SecretRole
 
 __all__ = [
@@ -17,7 +18,7 @@ class SecretRoleBulkEditForm(NetBoxModelBulkEditForm):
 
     model = SecretRole
 
-    fieldsets = ((None, ('description',)),)
+    FieldSets = (FieldSet('description', name=None),)
 
     class Meta:
         nullable_fields = ['description', 'comments']
@@ -30,7 +31,7 @@ class SecretBulkEditForm(NetBoxModelBulkEditForm):
 
     model = Secret
 
-    fieldsets = ((None, ('description',)),)
+    FieldSets = (FieldSet('description', name=None),)
 
     class Meta:
         nullable_fields = ['description', 'comments']
diff --git a/netbox_secrets/forms/filterset.py b/netbox_secrets/forms/filterset.py
index 3050058..778f2f9 100644
--- a/netbox_secrets/forms/filterset.py
+++ b/netbox_secrets/forms/filterset.py
@@ -7,6 +7,7 @@
     DynamicModelMultipleChoiceField,
     TagFilterField,
 )
+from utilities.forms.rendering import FieldSet
 from ..constants import *
 from ..models import Secret, SecretRole
 
@@ -18,7 +19,10 @@
 
 class SecretRoleFilterForm(NetBoxModelFilterSetForm):
     model = SecretRole
-    fieldsets = ((None, ('q', 'filter_id', 'tag')), ('Secret Role', ('id',)))
+    fieldsets = (
+        FieldSet('q', 'filter_id', 'tag', name=None),
+        FieldSet('id', name=_('Secret Role')),
+    )
     id = DynamicModelMultipleChoiceField(queryset=SecretRole.objects.all(), required=False, label=_('Roles Name'))
     tag = TagFilterField(model)
 
@@ -27,9 +31,9 @@ class SecretFilterForm(NetBoxModelFilterSetForm):
     model = Secret
 
     fieldsets = (
-        (None, ('q', 'filter_id', 'tag')),
-        ('Secret', ('id',)),
-        ('Attributes', ('role_id', 'assigned_object_type_id')),
+        FieldSet('q', 'filter_id', 'tag', name=None),
+        FieldSet('id', name=_('Secret')),
+        FieldSet('role_id', 'assigned_object_type_id', name=_("Attributes")),
     )
 
     id = DynamicModelMultipleChoiceField(queryset=Secret.objects.all(), required=False, label=_('Name'))
diff --git a/netbox_secrets/forms/model_forms.py b/netbox_secrets/forms/model_forms.py
index 2b50de9..d98439b 100644
--- a/netbox_secrets/forms/model_forms.py
+++ b/netbox_secrets/forms/model_forms.py
@@ -1,9 +1,11 @@
 from Crypto.Cipher import PKCS1_OAEP
 from Crypto.PublicKey import RSA
 from django import forms
+from django.utils.translation import gettext as _
+
 from netbox.forms import NetBoxModelForm
 from utilities.forms.fields import CommentField, DynamicModelChoiceField, SlugField
-
+from utilities.forms.rendering import FieldSet
 from ..constants import *
 from ..models import Secret, SecretRole, UserKey
 
@@ -42,7 +44,7 @@ def validate_rsa_key(key, is_secret=True):
 class SecretRoleForm(NetBoxModelForm):
     slug = SlugField()
 
-    fieldsets = ((None, ('name', 'slug', 'description', 'tags')),)
+    fieldsets = (FieldSet('name', 'slug', 'description', 'tags', name=None),)
 
     class Meta:
         model = SecretRole
@@ -76,8 +78,8 @@ class SecretForm(NetBoxModelForm):
     comments = CommentField()
 
     fieldsets = (
-        (None, ('name', 'description', 'role', 'tags')),
-        ('Secret Data', ('plaintext', 'plaintext2')),
+        FieldSet('name', 'description', 'role', 'tags', name=None),
+        FieldSet('plaintext', 'plaintext2', name=_('Secret Data')),
     )
 
     class Meta:
@@ -135,12 +137,10 @@ def clean_public_key(self):
 
 
 class ActivateUserKeyForm(forms.Form):
-    _selected_action = forms.ModelMultipleChoiceField(queryset=UserKey.objects.all(), label='User Keys')
+    user_keys = forms.ModelMultipleChoiceField(
+        queryset=UserKey.objects.filter(master_key_cipher__isnull=True), label='User Keys'
+    )
     secret_key = forms.CharField(
-        widget=forms.Textarea(
-            attrs={
-                'class': 'vLargeTextField',
-            },
-        ),
-        label='Your private key',
+        widget=forms.Textarea(attrs={'class': 'vLargeTextField'}),
+        label='Your Private Key',
     )
diff --git a/netbox_secrets/graphql/__init__.py b/netbox_secrets/graphql/__init__.py
index 747bb4f..8edbe0e 100644
--- a/netbox_secrets/graphql/__init__.py
+++ b/netbox_secrets/graphql/__init__.py
@@ -1,1 +1,3 @@
-from .types import *  # noqa
+from .schema import NetboxSecretsQuery
+
+schema = [NetboxSecretsQuery]
diff --git a/netbox_secrets/graphql/filters.py b/netbox_secrets/graphql/filters.py
new file mode 100644
index 0000000..117c54d
--- /dev/null
+++ b/netbox_secrets/graphql/filters.py
@@ -0,0 +1,22 @@
+import strawberry_django
+from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
+
+from ..models import *
+from ..filtersets import *
+
+__all__ = [
+    'SecretFilter',
+    'SecretRoleFilter',
+]
+
+
+@strawberry_django.filter(Secret, lookups=True)
+@autotype_decorator(SecretFilterSet)
+class SecretFilter(BaseFilterMixin):
+    pass
+
+
+@strawberry_django.filter(SecretRole, lookups=True)
+@autotype_decorator(SecretRoleFilterSet)
+class SecretRoleFilter(BaseFilterMixin):
+    pass
diff --git a/netbox_secrets/graphql/schema.py b/netbox_secrets/graphql/schema.py
index 1a00ca3..952f53e 100644
--- a/netbox_secrets/graphql/schema.py
+++ b/netbox_secrets/graphql/schema.py
@@ -1,12 +1,22 @@
-import graphene
-from netbox.graphql.fields import ObjectField, ObjectListField
+from typing import List
 
+import strawberry
+import strawberry_django
+
+from ..models import *
 from .types import *
 
 
-class SecretsQuery(graphene.ObjectType):
-    secret = ObjectField(SecretType)
-    secret_list = ObjectListField(SecretType)
+@strawberry.type
+class NetboxSecretsQuery:
+    @strawberry.field
+    def secret_roles(self, id: int) -> List[SecretRoleType]:
+        return SecretRole.objects.get(pk=id)
+
+    secret_roles_list: List[SecretRoleType] = strawberry_django.field()
+
+    @strawberry.field
+    def secrets(self, id: int) -> List[SecretType]:
+        return Secret.objects.get(pk=id)
 
-    secretrole = ObjectField(SecretRoleType)
-    secretrole_list = ObjectListField(SecretRoleType)
+    secrets_list: List[SecretType] = strawberry_django.field()
diff --git a/netbox_secrets/graphql/types.py b/netbox_secrets/graphql/types.py
index 3ae4006..4311e49 100644
--- a/netbox_secrets/graphql/types.py
+++ b/netbox_secrets/graphql/types.py
@@ -1,6 +1,11 @@
-from netbox.graphql.types import NetBoxObjectType, ObjectType
+from typing import Annotated
 
-from netbox_secrets import filtersets, models
+import strawberry
+import strawberry_django
+
+from netbox.graphql.types import NetBoxObjectType
+from .filters import *
+from ..models import *
 
 __all__ = [
     'SecretRoleType',
@@ -8,26 +13,15 @@
 ]
 
 
-class SecretRoleType(ObjectType):
-    class Meta:
-        model = models.SecretRole
-        fields = '__all__'
-        filterset_class = filtersets.SecretRoleFilterSet
+@strawberry_django.type(SecretRole, fields="__all__", filters=SecretRoleFilter)
+class SecretRoleType(NetBoxObjectType):
+    name: str
+    slug: str
+    description: str
 
 
+@strawberry_django.type(Secret, exclude=('ciphertext', 'hash', 'plaintext'), filters=SecretFilter)
 class SecretType(NetBoxObjectType):
-    class Meta:
-        model = models.Secret
-        fields = [
-            'id',
-            'name',
-            'description',
-            'role',
-            'assigned_object_type',
-            'assigned_object_id',
-            'comments',
-            'tags',
-            'created',
-            'last_updated',
-        ]
-        filterset_class = filtersets.SecretFilterSet
+    role: Annotated['SecretRoleType', strawberry.lazy('netbox_secrets.graphql.types')]
+    name: str
+    description: str
diff --git a/netbox_secrets/migrations/0008_userkey_custom_field_data_userkey_tags.py b/netbox_secrets/migrations/0008_userkey_custom_field_data_userkey_tags.py
new file mode 100644
index 0000000..870edb8
--- /dev/null
+++ b/netbox_secrets/migrations/0008_userkey_custom_field_data_userkey_tags.py
@@ -0,0 +1,27 @@
+# Generated by Django 5.0.6 on 2024-07-02 10:23
+
+import taggit.managers
+from django.db import migrations, models
+
+import utilities.json
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0115_convert_dashboard_widgets'),
+        ('netbox_secrets', '0007_secret__object_repr_secret_comments_and_more'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='userkey',
+            name='custom_field_data',
+            field=models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
+        ),
+        migrations.AddField(
+            model_name='userkey',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+    ]
diff --git a/netbox_secrets/models/secrets.py b/netbox_secrets/models/secrets.py
index 9e390fc..75b4594 100644
--- a/netbox_secrets/models/secrets.py
+++ b/netbox_secrets/models/secrets.py
@@ -5,17 +5,16 @@
 from Crypto.Util import strxor
 from django.conf import settings
 from django.contrib.auth.hashers import check_password, make_password
-from django.contrib.auth.models import User
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.db import models
 from django.urls import reverse
 from django.utils.encoding import force_bytes
-from netbox.models import PrimaryModel
-from netbox.models.features import ChangeLoggingMixin, EventRulesMixin
-from utilities.querysets import RestrictedQuerySet
 
+from netbox.models import NetBoxModel, PrimaryModel
+from utilities.querysets import RestrictedQuerySet
+from ..constants import CENSOR_MASTER_KEY, CENSOR_MASTER_KEY_CHANGED
 from ..exceptions import InvalidKey
 from ..hashers import SecretValidationHasher
 from ..querysets import UserKeyQuerySet
@@ -31,7 +30,7 @@
 plugin_settings = settings.PLUGINS_CONFIG.get('netbox_secrets', {})
 
 
-class UserKey(ChangeLoggingMixin, EventRulesMixin):
+class UserKey(NetBoxModel):
     """
     A UserKey stores a user's personal RSA (public) encryption key, which is used to generate their unique encrypted
     copy of the master encryption key. The encrypted instance of the master key can be decrypted only with the user's
@@ -39,7 +38,9 @@ class UserKey(ChangeLoggingMixin, EventRulesMixin):
     """
 
     id = models.BigAutoField(primary_key=True)
-    user = models.OneToOneField(to=User, on_delete=models.CASCADE, related_name='user_key', editable=False)
+    user = models.OneToOneField(
+        to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='user_key', editable=False
+    )
     public_key = models.TextField(
         verbose_name='RSA public key',
     )
@@ -60,6 +61,9 @@ def __init__(self, *args, **kwargs):
     def __str__(self):
         return self.user.username
 
+    def get_absolute_url(self):
+        return reverse('plugins:netbox_secrets:userkey', args=[self.pk])
+
     def clean(self):
         super().clean()
 
@@ -118,6 +122,27 @@ def delete(self, *args, **kwargs):
 
         super().delete(*args, **kwargs)
 
+    def to_objectchange(self, action):
+        objectchange = super().to_objectchange(action)
+
+        # Censor any backend parameters marked as sensitive in the serialized data
+        pre_change_params = {}
+        post_change_params = {}
+        if objectchange.prechange_data:
+            pre_change_params = objectchange.prechange_data
+        if objectchange.postchange_data:
+            post_change_params = objectchange.postchange_data
+        if post_change_params.get("master_key_cipher"):
+            if post_change_params["master_key_cipher"] != pre_change_params.get("master_key_cipher"):
+                # Set the "changed" master_key_cipher if the parameter's value has been modified
+                post_change_params["master_key_cipher"] = CENSOR_MASTER_KEY_CHANGED
+            else:
+                post_change_params["master_key_cipher"] = CENSOR_MASTER_KEY
+        if pre_change_params.get("master_key_cipher"):
+            pre_change_params["master_key_cipher"] = CENSOR_MASTER_KEY
+
+        return objectchange
+
     def is_filled(self):
         """
         Returns True if the UserKey has been filled with a public RSA key.
diff --git a/netbox_secrets/navigation.py b/netbox_secrets/navigation.py
index b15af8c..4bf95f1 100644
--- a/netbox_secrets/navigation.py
+++ b/netbox_secrets/navigation.py
@@ -1,13 +1,13 @@
 from django.conf import settings
-from extras.plugins import PluginMenuButton, PluginMenuItem, PluginMenu
-from utilities.choices import ButtonColorChoices
+
+from netbox.plugins import PluginMenu, PluginMenuButton, PluginMenuItem
 
 plugins_settings = settings.PLUGINS_CONFIG.get('netbox_secrets')
 
 menu_buttons = (
     PluginMenuItem(
-        link_text="User Key",
-        link="plugins:netbox_secrets:userkey",
+        link_text="User Keys",
+        link="plugins:netbox_secrets:userkey_list",
         permissions=["netbox_secrets.view_userkey"],
     ),
     PluginMenuItem(
@@ -19,14 +19,12 @@
                 link="plugins:netbox_secrets:secretrole_add",
                 title="Add Secret Role",
                 icon_class="mdi mdi-plus-thick",
-                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=ButtonColorChoices.CYAN,
                 permissions=["netbox_secrets.add_secretrole"],
             ),
         ),
diff --git a/netbox_secrets/project-static/src/secrets.ts b/netbox_secrets/project-static/src/secrets.ts
index daed7b1..c206ed2 100644
--- a/netbox_secrets/project-static/src/secrets.ts
+++ b/netbox_secrets/project-static/src/secrets.ts
@@ -1,7 +1,7 @@
 import { createToast } from './bs';
-import { apiGetBase, apiPostForm, isApiError, isInputElement, hasError } from './util';
+import { apiGetBase, apiPostForm, hasError, isApiError, isInputElement } from './util';
 
-import type { APISecret, APIKeyPair } from './types';
+import type { APIKeyPair, APISecret } from './types';
 
 /**
  * Initialize Generate Private Key Pair Elements.
@@ -9,8 +9,10 @@ import type { APISecret, APIKeyPair } from './types';
 function initGenerateKeyPair() {
   const element = document.getElementById('new_keypair_modal') as HTMLDivElement;
   const accept = document.getElementById('use_new_pubkey') as HTMLButtonElement;
+  const copyBtn = document.getElementById('copy_prikey') as HTMLButtonElement;
+  const exportBtn = document.getElementById('export_key') as HTMLButtonElement;
   // If the elements are not loaded, stop.
-  if (element === null || accept === null) {
+  if (element === null || accept === null || copyBtn === null || exportBtn === null) {
     return;
   }
   const publicElem = element.querySelector<HTMLTextAreaElement>('textarea#new_pubkey');
@@ -54,8 +56,31 @@ function initGenerateKeyPair() {
       publicKeyField.innerText = publicElem.value;
     }
   }
+
+  /**
+   * Handles file download functionality.
+   */
+  function handleExport() {
+    const content = `Public Key\n\n${publicElem?.value}\n\nPrivate Key\n\n${privateElem?.value}`;
+
+    const blob = new Blob([content], { type: 'text/plain' });
+
+    const a = document.createElement('a');
+    a.style.display = 'none';
+    a.href = window.URL.createObjectURL(blob);
+    a.download = 'key.txt';
+    document.body.appendChild(a);
+
+    a.click();
+
+    window.URL.revokeObjectURL(a.href);
+    document.body.removeChild(a);
+  }
+
   element.addEventListener('shown.bs.modal', () => handleOpen());
   accept.addEventListener('click', () => handleAccept());
+  copyBtn.addEventListener('click', () => navigator.clipboard.writeText(privateElem?.value || ''));
+  exportBtn.addEventListener('click', () => handleExport());
 }
 
 /**
diff --git a/netbox_secrets/querysets.py b/netbox_secrets/querysets.py
index dc262a6..e1769e4 100644
--- a/netbox_secrets/querysets.py
+++ b/netbox_secrets/querysets.py
@@ -1,7 +1,7 @@
-from django.db.models import QuerySet
+from utilities.querysets import RestrictedQuerySet
 
 
-class UserKeyQuerySet(QuerySet):
+class UserKeyQuerySet(RestrictedQuerySet):
     def active(self):
         return self.filter(master_key_cipher__isnull=False)
 
diff --git a/netbox_secrets/static/netbox_secrets/secrets.js b/netbox_secrets/static/netbox_secrets/secrets.js
index 424c14e..1c5540c 100644
--- a/netbox_secrets/static/netbox_secrets/secrets.js
+++ b/netbox_secrets/static/netbox_secrets/secrets.js
@@ -1,6 +1,12 @@
-"use strict";(()=>{var P=Object.create;var h=Object.defineProperty;var M=Object.getOwnPropertyDescriptor;var R=Object.getOwnPropertyNames;var C=Object.getPrototypeOf,D=Object.prototype.hasOwnProperty;var _=(e,r)=>()=>(r||e((r={exports:{}}).exports,r),r.exports);var H=(e,r,o,t)=>{if(r&&typeof r=="object"||typeof r=="function")for(let i of R(r))!D.call(e,i)&&i!==o&&h(e,i,{get:()=>r[i],enumerable:!(t=M(r,i))||t.enumerable});return e};var B=(e,r,o)=>(o=e!=null?P(C(e)):{},H(r||!e||!e.__esModule?h(o,"default",{value:e,enumerable:!0}):o,e));var p=(e,r,o)=>new Promise((t,i)=>{var s=c=>{try{a(o.next(c))}catch(l){i(l)}},n=c=>{try{a(o.throw(c))}catch(l){i(l)}},a=c=>c.done?t(c.value):Promise.resolve(c.value).then(s,n);a((o=o.apply(e,r)).next())});var b=_(E=>{"use strict";E.parse=O;E.serialize=q;var I=Object.prototype.toString,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=t.decode||$,s=0;s<e.length;){var n=e.indexOf("=",s);if(n===-1)break;var a=e.indexOf(";",s);if(a===-1)a=e.length;else if(a<n){s=e.lastIndexOf(";",n-1)+1;continue}var c=e.slice(s,n).trim();if(o[c]===void 0){var l=e.slice(n+1,a).trim();l.charCodeAt(0)===34&&(l=l.slice(1,-1)),o[c]=F(l,i)}s=a+1}return o}function q(e,r,o){var t=o||{},i=t.encode||K;if(typeof i!="function")throw new TypeError("option encode is invalid");if(!m.test(e))throw new TypeError("argument name is invalid");var s=i(r);if(s&&!m.test(s))throw new TypeError("argument val is invalid");var n=e+"="+s;if(t.maxAge!=null){var a=t.maxAge-0;if(isNaN(a)||!isFinite(a))throw new TypeError("option maxAge is invalid");n+="; Max-Age="+Math.floor(a)}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){var c=t.expires;if(!j(c)||isNaN(c.valueOf()))throw new TypeError("option expires is invalid");n+="; Expires="+c.toUTCString()}if(t.httpOnly&&(n+="; HttpOnly"),t.secure&&(n+="; Secure"),t.priority){var l=typeof t.priority=="string"?t.priority.toLowerCase():t.priority;switch(l){case"low":n+="; Priority=Low";break;case"medium":n+="; Priority=Medium";break;case"high":n+="; Priority=High";break;default:throw new TypeError("option priority is invalid")}}if(t.sameSite){var u=typeof t.sameSite=="string"?t.sameSite.toLowerCase():t.sameSite;switch(u){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 $(e){return e.indexOf("%")!==-1?decodeURIComponent(e):e}function K(e){return encodeURIComponent(e)}function j(e){return I.call(e)==="[object Date]"||e instanceof Date}function F(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 s=document.createElement("div");s.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 a=document.createElement("div");a.setAttribute("class",`toast-header bg-${e} text-body`);let c=document.createElement("i");c.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"),a.appendChild(c),a.appendChild(l),typeof t!="undefined"){let g=document.createElement("small");g.setAttribute("class","text-muted"),a.appendChild(g)}return a.appendChild(u),y.innerText=o.trim(),n.appendChild(a),n.appendChild(y),s.appendChild(n),document.body.appendChild(s),new window.Toast(n)}var T=B(b());function A(e){return"error"in e&&"exception"in e}function f(e){return"error"in e}function v(e){return"value"in e&&"required"in e}function N(){let{csrftoken:e}=T.default.parse(document.cookie);if(typeof e=="undefined")throw new Error("Invalid or missing CSRF token");return e}function S(e,r,o){return p(this,null,function*(){let t=N(),i=new Headers({"X-CSRFToken":t}),s;typeof o!="undefined"&&(s=JSON.stringify(o),i.set("content-type","application/json"),i.set("Accept","application/json"));let n=yield fetch(e,{method:r,body:s,headers:i,credentials:"same-origin"}),a=n.headers.get("Content-Type");if(typeof a=="string"&&a.includes("text"))return{error:yield n.text()};let c=yield n.json();return!n.ok&&Array.isArray(c)?{error:c.join(`
-`)}:!n.ok&&"detail"in c?{error:c.detail}:c})}function x(e,r){return p(this,null,function*(){return yield S(e,"POST",r)})}function k(e){return p(this,null,function*(){return yield S(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","");k("/api/plugins/secrets/generate-rsa-key-pair/").then(n=>{if(f(n))d("danger","Error",n.error).show();else{let{private_key:a,public_key:c}=n;o!==null&&t!==null&&(o.value=c,t.value=a)}})}function s(){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",()=>s())}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(`span[secret-id='${e}']`);console.log(i),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 U(){let e=new window.Modal("#privkey_modal");function r(t){let i=document.getElementById(`secret_${t}`);typeof t=="string"&&t!==""&&k(`/api/plugins/secrets/secrets/${t}/`).then(s=>{if(f(s))s.error.toLowerCase().includes("invalid session key")?e.show():d("danger","Error",s.error).show();else{let{plaintext:n}=s;i!==null&&n!==null?(v(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}`);v(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/session-keys/",{private_key:e,preserve_key:!0}).then(r=>{if(!f(r))window.location.pathname==="/plugins/secrets/user-key/"?window.location.reload():d("success","Session Key Received","You may now unlock secrets.").show();else{let o=r.error;A(r)&&(o+=`
-${r.exception}`),d("danger","Failed to Retrieve Session Key",o).show()}})}function J(){for(let r of document.querySelectorAll("#request_session_key")){let o=function(){for(let t of document.querySelectorAll("#user_privkey"))z(t.value),t.value=""};var e=o;r.addEventListener("click",o)}}function X(){let e=new window.Modal("#privkey_modal");function r(o){document.cookie.indexOf("netbox_secrets_sessionid")===-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 w(){for(let e of[G,U,J,X])e()}document.readyState!=="loading"?w():document.addEventListener("DOMContentLoaded",w);})();
+"use strict";(()=>{var P=Object.create;var g=Object.defineProperty;var R=Object.getOwnPropertyDescriptor;var M=Object.getOwnPropertyNames;var B=Object.getPrototypeOf,C=Object.prototype.hasOwnProperty;var _=(e,n)=>()=>(n||e((n={exports:{}}).exports,n),n.exports);var D=(e,n,i,t)=>{if(n&&typeof n=="object"||typeof n=="function")for(let o of M(n))!C.call(e,o)&&o!==i&&g(e,o,{get:()=>n[o],enumerable:!(t=R(n,o))||t.enumerable});return e};var H=(e,n,i)=>(i=e!=null?P(B(e)):{},D(n||!e||!e.__esModule?g(i,"default",{value:e,enumerable:!0}):i,e));var m=(e,n,i)=>new Promise((t,o)=>{var s=l=>{try{c(i.next(l))}catch(a){o(a)}},r=l=>{try{c(i.throw(l))}catch(a){o(a)}},c=l=>l.done?t(l.value):Promise.resolve(l.value).then(s,r);c((i=i.apply(e,n)).next())});var h=_(E=>{"use strict";E.parse=O;E.serialize=q;var I=Object.prototype.toString,f=/^[\u0009\u0020-\u007e\u0080-\u00ff]+$/;function O(e,n){if(typeof e!="string")throw new TypeError("argument str must be a string");for(var i={},t=n||{},o=t.decode||$,s=0;s<e.length;){var r=e.indexOf("=",s);if(r===-1)break;var c=e.indexOf(";",s);if(c===-1)c=e.length;else if(c<r){s=e.lastIndexOf(";",r-1)+1;continue}var l=e.slice(s,r).trim();if(i[l]===void 0){var a=e.slice(r+1,c).trim();a.charCodeAt(0)===34&&(a=a.slice(1,-1)),i[l]=U(a,o)}s=c+1}return i}function q(e,n,i){var t=i||{},o=t.encode||K;if(typeof o!="function")throw new TypeError("option encode is invalid");if(!f.test(e))throw new TypeError("argument name is invalid");var s=o(n);if(s&&!f.test(s))throw new TypeError("argument val is invalid");var r=e+"="+s;if(t.maxAge!=null){var c=t.maxAge-0;if(isNaN(c)||!isFinite(c))throw new TypeError("option maxAge is invalid");r+="; Max-Age="+Math.floor(c)}if(t.domain){if(!f.test(t.domain))throw new TypeError("option domain is invalid");r+="; Domain="+t.domain}if(t.path){if(!f.test(t.path))throw new TypeError("option path is invalid");r+="; Path="+t.path}if(t.expires){var l=t.expires;if(!j(l)||isNaN(l.valueOf()))throw new TypeError("option expires is invalid");r+="; Expires="+l.toUTCString()}if(t.httpOnly&&(r+="; HttpOnly"),t.secure&&(r+="; Secure"),t.priority){var a=typeof t.priority=="string"?t.priority.toLowerCase():t.priority;switch(a){case"low":r+="; Priority=Low";break;case"medium":r+="; Priority=Medium";break;case"high":r+="; Priority=High";break;default:throw new TypeError("option priority is invalid")}}if(t.sameSite){var d=typeof t.sameSite=="string"?t.sameSite.toLowerCase():t.sameSite;switch(d){case!0:r+="; SameSite=Strict";break;case"lax":r+="; SameSite=Lax";break;case"strict":r+="; SameSite=Strict";break;case"none":r+="; SameSite=None";break;default:throw new TypeError("option sameSite is invalid")}}return r}function $(e){return e.indexOf("%")!==-1?decodeURIComponent(e):e}function K(e){return encodeURIComponent(e)}function j(e){return I.call(e)==="[object Date]"||e instanceof Date}function U(e,n){try{return n(e)}catch(i){return e}}});function p(e,n,i,t){let o="mdi-alert";switch(e){case"warning":o="mdi-alert";break;case"success":o="mdi-check-circle";break;case"info":o="mdi-information";break;case"danger":o="mdi-alert";break}let s=document.createElement("div");s.setAttribute("class","toast-container position-fixed bottom-0 end-0 m-3");let r=document.createElement("div");r.setAttribute("class",`toast bg-${e}`),r.setAttribute("role","alert"),r.setAttribute("aria-live","assertive"),r.setAttribute("aria-atomic","true");let c=document.createElement("div");c.setAttribute("class",`toast-header bg-${e} text-body`);let l=document.createElement("i");l.setAttribute("class",`mdi ${o}`);let a=document.createElement("strong");a.setAttribute("class","me-auto ms-1"),a.innerText=n;let d=document.createElement("button");d.setAttribute("type","button"),d.setAttribute("class","btn-close"),d.setAttribute("data-bs-dismiss","toast"),d.setAttribute("aria-label","Close");let u=document.createElement("div");if(u.setAttribute("class","toast-body"),c.appendChild(l),c.appendChild(a),typeof t!="undefined"){let b=document.createElement("small");b.setAttribute("class","text-muted"),c.appendChild(b)}return c.appendChild(d),u.innerText=i.trim(),r.appendChild(c),r.appendChild(u),s.appendChild(r),document.body.appendChild(s),new window.Toast(r)}var T=H(h());function L(e){return"error"in e&&"exception"in e}function y(e){return"error"in e}function v(e){return"value"in e&&"required"in e}function F(){let{csrftoken:e}=T.default.parse(document.cookie);if(typeof e=="undefined")throw new Error("Invalid or missing CSRF token");return e}function x(e,n,i){return m(this,null,function*(){let t=F(),o=new Headers({"X-CSRFToken":t}),s;typeof i!="undefined"&&(s=JSON.stringify(i),o.set("content-type","application/json"),o.set("Accept","application/json"));let r=yield fetch(e,{method:n,body:s,headers:o,credentials:"same-origin"}),c=r.headers.get("Content-Type");if(typeof c=="string"&&c.includes("text"))return{error:yield r.text()};let l=yield r.json();return!r.ok&&Array.isArray(l)?{error:l.join(`
+`)}:!r.ok&&"detail"in l?{error:l.detail}:l})}function A(e,n){return m(this,null,function*(){return yield x(e,"POST",n)})}function k(e){return m(this,null,function*(){return yield x(e,"GET")})}function N(){let e=document.getElementById("new_keypair_modal"),n=document.getElementById("use_new_pubkey"),i=document.getElementById("copy_prikey"),t=document.getElementById("export_key");if(e===null||n===null||i===null||t===null)return;let o=e.querySelector("textarea#new_pubkey"),s=e.querySelector("textarea#new_privkey");function r(){for(let a of[o,s])a!==null&&a.setAttribute("readonly","");k("/api/plugins/secrets/generate-rsa-key-pair/").then(a=>{if(y(a))p("danger","Error",a.error).show();else{let{private_key:d,public_key:u}=a;o!==null&&s!==null&&(o.value=u,s.value=d)}})}function c(){let a=document.getElementById("id_public_key");o!==null&&(a.value=o.value,a.innerText=o.value)}function l(){let a=`Public Key
+
+${o==null?void 0:o.value}
+
+Private Key
+
+${s==null?void 0:s.value}`,d=new Blob([a],{type:"text/plain"}),u=document.createElement("a");u.style.display="none",u.href=window.URL.createObjectURL(d),u.download="key.txt",document.body.appendChild(u),u.click(),window.URL.revokeObjectURL(u.href),document.body.removeChild(u)}e.addEventListener("shown.bs.modal",()=>r()),n.addEventListener("click",()=>c()),i.addEventListener("click",()=>navigator.clipboard.writeText((s==null?void 0:s.value)||"")),t.addEventListener("click",()=>l())}function S(e,n){let i=document.querySelector(`button.unlock-secret[secret-id='${e}']`),t=document.querySelector(`button.lock-secret[secret-id='${e}']`),o=document.querySelector(`span[secret-id='${e}']`);i!==null&&(n==="unlock"&&i.classList.add("d-none"),n==="lock"&&i.classList.remove("d-none")),t!==null&&(n==="unlock"&&t.classList.remove("d-none"),n==="lock"&&t.classList.add("d-none")),o!==null&&(n==="unlock"&&o.classList.remove("d-none"),n==="lock"&&o.classList.add("d-none"))}function G(){let e=new window.Modal("#privkey_modal");function n(t){let o=document.getElementById(`secret_${t}`);typeof t=="string"&&t!==""&&k(`/api/plugins/secrets/secrets/${t}/`).then(s=>{if(y(s))s.error.toLowerCase().includes("invalid session key")?e.show():p("danger","Error",s.error).show();else{let{plaintext:r}=s;o!==null&&r!==null?(v(o)?o.value=r:o.innerText=r,S(t,"unlock")):e.show()}})}function i(t){if(typeof t=="string"&&t!==""){let o=document.getElementById(`secret_${t}`);v(o)?o.value="********":o.innerText="********",S(t,"lock")}}for(let t of document.querySelectorAll("button.unlock-secret"))t.addEventListener("click",()=>n(t.getAttribute("secret-id")));for(let t of document.querySelectorAll("button.lock-secret"))t.addEventListener("click",()=>i(t.getAttribute("secret-id")))}function z(e){A("/api/plugins/secrets/session-keys/",{private_key:e,preserve_key:!0}).then(n=>{if(!y(n))window.location.pathname==="/plugins/secrets/user-key/"?window.location.reload():p("success","Session Key Received","You may now unlock secrets.").show();else{let i=n.error;L(n)&&(i+=`
+${n.exception}`),p("danger","Failed to Retrieve Session Key",i).show()}})}function J(){for(let n of document.querySelectorAll("#request_session_key")){let i=function(){for(let t of document.querySelectorAll("#user_privkey"))z(t.value),t.value=""};var e=i;n.addEventListener("click",i)}}function X(){let e=new window.Modal("#privkey_modal");function n(i){document.cookie.indexOf("netbox_secrets_sessionid")===-1&&(i.preventDefault(),e.show())}for(let i of document.querySelectorAll(".requires-session-key")){let t=i.closest("form");t!==null&&t.addEventListener("submit",n)}}function w(){for(let e of[N,G,J,X])e()}document.readyState!=="loading"?w():document.addEventListener("DOMContentLoaded",w);})();
 /*! Bundled license information:
 
 cookie/index.js:
diff --git a/netbox_secrets/static/netbox_secrets/secrets.js.map b/netbox_secrets/static/netbox_secrets/secrets.js.map
index cedd1d7..781a33d 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 __toString = Object.prototype.toString\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 dec = opt.decode || decode;\n\n  var index = 0\n  while (index < str.length) {\n    var eqIdx = str.indexOf('=', index)\n\n    // no more cookie pairs\n    if (eqIdx === -1) {\n      break\n    }\n\n    var endIdx = str.indexOf(';', index)\n\n    if (endIdx === -1) {\n      endIdx = str.length\n    } else if (endIdx < eqIdx) {\n      // backtrack on prior semicolon\n      index = str.lastIndexOf(';', eqIdx - 1) + 1\n      continue\n    }\n\n    var key = str.slice(index, eqIdx).trim()\n\n    // only assign once\n    if (undefined === obj[key]) {\n      var val = str.slice(eqIdx + 1, endIdx).trim()\n\n      // quoted values\n      if (val.charCodeAt(0) === 0x22) {\n        val = val.slice(1, -1)\n      }\n\n      obj[key] = tryDecode(val, dec);\n    }\n\n    index = endIdx + 1\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    var expires = opt.expires\n\n    if (!isDate(expires) || isNaN(expires.valueOf())) {\n      throw new TypeError('option expires is invalid');\n    }\n\n    str += '; Expires=' + 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.priority) {\n    var priority = typeof opt.priority === 'string'\n      ? opt.priority.toLowerCase()\n      : opt.priority\n\n    switch (priority) {\n      case 'low':\n        str += '; Priority=Low'\n        break\n      case 'medium':\n        str += '; Priority=Medium'\n        break\n      case 'high':\n        str += '; Priority=High'\n        break\n      default:\n        throw new TypeError('option priority is invalid')\n    }\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 * URL-decode string value. Optimized to skip native call when no %.\n *\n * @param {string} str\n * @returns {string}\n */\n\nfunction decode (str) {\n  return str.indexOf('%') !== -1\n    ? decodeURIComponent(str)\n    : str\n}\n\n/**\n * URL-encode value.\n *\n * @param {string} str\n * @returns {string}\n */\n\nfunction encode (val) {\n  return encodeURIComponent(val)\n}\n\n/**\n * Determine if value is a Date.\n *\n * @param {*} val\n * @private\n */\n\nfunction isDate (val) {\n  return __toString.call(val) === '[object Date]' ||\n    val instanceof Date\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(`span[secret-id='${id}']`);\n  console.log(copyButton);\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/session-keys/', {\n    private_key: privateKey,\n    preserve_key: true,\n  }).then(res => {\n    if (!hasError(res)) {\n      // If the session key has been added from the user key page, reload the page.\n      if (window.location.pathname === '/plugins/secrets/user-key/') {\n        window.location.reload();\n      } else {\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      }\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 `netbox_secrets_sessionid`. If not present, prompt the user to submit their\n   * private key.\n   */\n  function handleSubmit(event: Event): void {\n    if (document.cookie.indexOf('netbox_secrets_sessionid') === -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": ["require_cookie", "__commonJSMin", "exports", "parse", "serialize", "__toString", "fieldContentRegExp", "str", "options", "obj", "opt", "dec", "decode", "index", "eqIdx", "endIdx", "key", "val", "tryDecode", "name", "enc", "encode", "value", "maxAge", "expires", "isDate", "priority", "sameSite", "e", "createToast", "level", "title", "message", "extra", "iconName", "container", "main", "header", "icon", "titleElement", "button", "body", "extraElement", "import_cookie", "isApiError", "data", "hasError", "isInputElement", "element", "getCsrfToken", "csrfToken", "Cookie", "apiRequest", "url", "method", "__async", "token", "headers", "body", "res", "contentType", "json", "apiPostForm", "apiGetBase", "initGenerateKeyPair", "element", "accept", "publicElem", "privateElem", "handleOpen", "elem", "apiGetBase", "data", "hasError", "createToast", "priv", "pub", "handleAccept", "publicKeyField", "toggleSecretButtons", "id", "action", "unlockButton", "lockButton", "copyButton", "initLockUnlock", "privateKeyModal", "unlock", "target", "plaintext", "isInputElement", "lock", "requestSessionKey", "privateKey", "apiPostForm", "res", "message", "isApiError", "initGetSessionKey", "handleClick", "pk", "initSecretsEdit", "handleSubmit", "event", "form", "initSecrets", "func", "initSecrets"]
+  "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 __toString = Object.prototype.toString\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 dec = opt.decode || decode;\n\n  var index = 0\n  while (index < str.length) {\n    var eqIdx = str.indexOf('=', index)\n\n    // no more cookie pairs\n    if (eqIdx === -1) {\n      break\n    }\n\n    var endIdx = str.indexOf(';', index)\n\n    if (endIdx === -1) {\n      endIdx = str.length\n    } else if (endIdx < eqIdx) {\n      // backtrack on prior semicolon\n      index = str.lastIndexOf(';', eqIdx - 1) + 1\n      continue\n    }\n\n    var key = str.slice(index, eqIdx).trim()\n\n    // only assign once\n    if (undefined === obj[key]) {\n      var val = str.slice(eqIdx + 1, endIdx).trim()\n\n      // quoted values\n      if (val.charCodeAt(0) === 0x22) {\n        val = val.slice(1, -1)\n      }\n\n      obj[key] = tryDecode(val, dec);\n    }\n\n    index = endIdx + 1\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    var expires = opt.expires\n\n    if (!isDate(expires) || isNaN(expires.valueOf())) {\n      throw new TypeError('option expires is invalid');\n    }\n\n    str += '; Expires=' + 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.priority) {\n    var priority = typeof opt.priority === 'string'\n      ? opt.priority.toLowerCase()\n      : opt.priority\n\n    switch (priority) {\n      case 'low':\n        str += '; Priority=Low'\n        break\n      case 'medium':\n        str += '; Priority=Medium'\n        break\n      case 'high':\n        str += '; Priority=High'\n        break\n      default:\n        throw new TypeError('option priority is invalid')\n    }\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 * URL-decode string value. Optimized to skip native call when no %.\n *\n * @param {string} str\n * @returns {string}\n */\n\nfunction decode (str) {\n  return str.indexOf('%') !== -1\n    ? decodeURIComponent(str)\n    : str\n}\n\n/**\n * URL-encode value.\n *\n * @param {string} str\n * @returns {string}\n */\n\nfunction encode (val) {\n  return encodeURIComponent(val)\n}\n\n/**\n * Determine if value is a Date.\n *\n * @param {*} val\n * @private\n */\n\nfunction isDate (val) {\n  return __toString.call(val) === '[object Date]' ||\n    val instanceof Date\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  const copyBtn = document.getElementById('copy_prikey') as HTMLButtonElement;\n  const exportBtn = document.getElementById('export_key') as HTMLButtonElement;\n  // If the elements are not loaded, stop.\n  if (element === null || accept === null || copyBtn === null || exportBtn === 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\n  /**\n   * Handles file download functionality.\n   */\n  function handleExport() {\n    const content = `Public Key\\n\\n${publicElem?.value}\\n\\nPrivate Key\\n\\n${privateElem?.value}`;\n\n    const blob = new Blob([content], { type: 'text/plain' });\n\n    const a = document.createElement('a');\n    a.style.display = 'none';\n    a.href = window.URL.createObjectURL(blob);\n    a.download = 'key.txt';\n    document.body.appendChild(a);\n\n    a.click();\n\n    window.URL.revokeObjectURL(a.href);\n    document.body.removeChild(a);\n  }\n\n  element.addEventListener('shown.bs.modal', () => handleOpen());\n  accept.addEventListener('click', () => handleAccept());\n  copyBtn.addEventListener('click', () => navigator.clipboard.writeText(privateElem?.value || ''));\n  exportBtn.addEventListener('click', () => handleExport());\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(`span[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/session-keys/', {\n    private_key: privateKey,\n    preserve_key: true,\n  }).then(res => {\n    if (!hasError(res)) {\n      // If the session key has been added from the user key page, reload the page.\n      if (window.location.pathname === '/plugins/secrets/user-key/') {\n        window.location.reload();\n      } else {\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      }\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 `netbox_secrets_sessionid`. If not present, prompt the user to submit their\n   * private key.\n   */\n  function handleSubmit(event: Event): void {\n    if (document.cookie.indexOf('netbox_secrets_sessionid') === -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": ["require_cookie", "__commonJSMin", "exports", "parse", "serialize", "__toString", "fieldContentRegExp", "str", "options", "obj", "opt", "dec", "decode", "index", "eqIdx", "endIdx", "key", "val", "tryDecode", "name", "enc", "encode", "value", "maxAge", "expires", "isDate", "priority", "sameSite", "e", "createToast", "level", "title", "message", "extra", "iconName", "container", "main", "header", "icon", "titleElement", "button", "body", "extraElement", "import_cookie", "isApiError", "data", "hasError", "isInputElement", "element", "getCsrfToken", "csrfToken", "Cookie", "apiRequest", "url", "method", "__async", "token", "headers", "body", "res", "contentType", "json", "apiPostForm", "apiGetBase", "initGenerateKeyPair", "element", "accept", "copyBtn", "exportBtn", "publicElem", "privateElem", "handleOpen", "elem", "apiGetBase", "data", "hasError", "createToast", "priv", "pub", "handleAccept", "publicKeyField", "handleExport", "content", "blob", "a", "toggleSecretButtons", "id", "action", "unlockButton", "lockButton", "copyButton", "initLockUnlock", "privateKeyModal", "unlock", "target", "plaintext", "isInputElement", "lock", "requestSessionKey", "privateKey", "apiPostForm", "res", "message", "isApiError", "initGetSessionKey", "handleClick", "pk", "initSecretsEdit", "handleSubmit", "event", "form", "initSecrets", "func", "initSecrets"]
 }
diff --git a/netbox_secrets/tables.py b/netbox_secrets/tables.py
index 8aa889d..1434fe8 100644
--- a/netbox_secrets/tables.py
+++ b/netbox_secrets/tables.py
@@ -1,7 +1,9 @@
 import django_tables2 as tables
+from django.utils.translation import gettext as _
+
 from netbox.tables import NetBoxTable, columns
+from .models import Secret, SecretRole, UserKey
 
-from .models import Secret, SecretRole
 
 #
 # Secret roles
@@ -74,3 +76,30 @@ class Meta(NetBoxTable.Meta):
             'role',
             'actions',
         )
+
+
+class UserKeyTable(NetBoxTable):
+    user = tables.Column(linkify=True)
+    is_active = columns.BooleanColumn(
+        verbose_name=_('Is Active'),
+    )
+    tags = columns.TagColumn(url_name='plugins:netbox_secrets:userkey_list')
+    actions = columns.ActionsColumn(actions=())
+
+    class Meta(NetBoxTable.Meta):
+        model = UserKey
+        fields = (
+            'pk',
+            'user',
+            'is_active',
+            'created',
+            'last_updated',
+            'tags',
+            'actions',
+        )
+        default_columns = (
+            'pk',
+            'id',
+            'user',
+            'is_active',
+        )
diff --git a/netbox_secrets/template_content.py b/netbox_secrets/template_content.py
index 751e3ab..81a5551 100644
--- a/netbox_secrets/template_content.py
+++ b/netbox_secrets/template_content.py
@@ -3,10 +3,10 @@
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.db.utils import OperationalError
-from extras.plugins import PluginTemplateExtension
+
+from netbox.plugins import PluginTemplateExtension
 from netbox.views import generic
 from utilities.views import ViewTab, register_model_view
-
 from .filtersets import SecretFilterSet
 from .models import Secret
 from .tables import SecretTable
diff --git a/netbox_secrets/templates/netbox_secrets/activate_keys.html b/netbox_secrets/templates/netbox_secrets/activate_keys.html
index 93c0215..bde4920 100644
--- a/netbox_secrets/templates/netbox_secrets/activate_keys.html
+++ b/netbox_secrets/templates/netbox_secrets/activate_keys.html
@@ -1,12 +1,50 @@
-{% extends "admin/base_site.html" %}
+{% extends 'generic/_base.html' %}
+{% load i18n %}
+
+{% block title %}
+    {% trans "Activate User Key" %}
+{% endblock title %}
+
+{% block tabs %}
+    <ul class="nav nav-tabs">
+        <li class="nav-item" role="presentation">
+            <button class="nav-link active" id="edit-form-tab" data-bs-toggle="tab" data-bs-target="#edit-form"
+                    type="button" role="tab" aria-controls="edit-form" aria-selected="true">
+                {% trans "Activate" %}
+            </button>
+        </li>
+    </ul>
+{% endblock tabs %}
 
 {% block content %}
+    <div class="tab-pane show active" id="edit-form" role="tabpanel" aria-labelledby="object-list-tab">
 
-<form action="." method="post">
-    {% csrf_token %}
-    {{ form.as_p }}
-    <input type="hidden" name="action" value="activate_selected" />
-    <input type="submit" name="activate" value="Activate User Key(s)" />
-</form>
+        <form action="{% url 'plugins:netbox_secrets:userkey_activate' %}" method="post"
+              enctype="multipart/form-data" class="object-edit mt-5">
+            {% csrf_token %}
+            <div class="alert alert-info" role="alert">
+                <div class="d-flex justify-content-between">
+                    <div>
+                        <i class="mdi mdi-information-outline p-2"></i>
+                        <strong>Note:</strong> This form is for activating the <strong>{{ object }}</strong> User Key.
+                    </div>
+                </div>
+            </div>
+            <div id="form_fields" hx-disinherit="hx-select hx-swap">
+                {% block form %}
+                    {% include 'htmx/form.html' %}
+                {% endblock form %}
+            </div>
 
-{% endblock %}
+            <div class="text-end my-3">
+                {% block buttons %}
+                    <a href="{% url 'plugins:netbox_secrets:userkey_list' %}"
+                       class="btn btn-outline-secondary">{% trans "Cancel" %}</a>
+                    <button type="submit" name="_update" class="btn btn-primary">
+                        {% trans "Activate" %}
+                    </button>
+                {% endblock buttons %}
+            </div>
+        </form>
+    </div>
+{% endblock content %}
diff --git a/netbox_secrets/templates/netbox_secrets/inc/view_tab.html b/netbox_secrets/templates/netbox_secrets/inc/view_tab.html
deleted file mode 100644
index 6f6cf71..0000000
--- a/netbox_secrets/templates/netbox_secrets/inc/view_tab.html
+++ /dev/null
@@ -1,22 +0,0 @@
-{% extends 'generic/object.html' %}
-{% load buttons %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
-
-{% block content %}
-    {% include 'inc/table_controls_htmx.html' with table_modal=table_config %}
-    <form method="post">
-        {% csrf_token %}
-        <div class="card">
-            <div class="card-body" id="object_list">
-                {% include 'htmx/table.html' %}
-            </div>
-        </div>
-    </form>
-{% endblock content %}
-
-{% block modals %}
-    {{ block.super }}
-    {% table_config_form table %}
-{% endblock modals %}
diff --git a/netbox_secrets/templates/netbox_secrets/userkey.html b/netbox_secrets/templates/netbox_secrets/userkey.html
index 080fd2f..d79b64d 100644
--- a/netbox_secrets/templates/netbox_secrets/userkey.html
+++ b/netbox_secrets/templates/netbox_secrets/userkey.html
@@ -23,13 +23,15 @@
         <div class="control-group">
             {# Extra buttons #}
             {% block extra_controls %}{% endblock %}
-
             {% if object %}
                 {% if request.user|can_change:object %}
-                    <a href="{% url 'plugins:netbox_secrets:userkey_edit' %}?return_url={{ request.path }}"
-                       class="btn btn-sm btn-warning" role="button">
-                        <span class="mdi mdi-pencil" aria-hidden="true"></span>&nbsp;Edit
-                    </a>
+                    {# Check if the object belongs to the currently logged-in user #}
+                    {% if object.user == request.user %}
+                        <a href="{% url 'plugins:netbox_secrets:userkey_add' %}"
+                           class="btn btn-sm btn-warning" role="button">
+                            <span class="mdi mdi-pencil" aria-hidden="true"></span>&nbsp;Edit
+                        </a>
+                    {% endif %}
                 {% endif %}
             {% endif %}
         </div>
@@ -59,7 +61,7 @@ <h5 class="card-header">Overview</h5>
                                     {% if not object.is_filled %}
                                         You don't have a user key on file.
                                     {% else %}
-                                        Your user key is inactive. Ask an administrator to enable it for you.
+                                        This user key is inactive. Ask an administrator to enable it for you.
                                     {% endif %}
                                 {% endif %}
                             </td>
@@ -86,46 +88,48 @@ <h5 class="card-header">Overview</h5>
                 </div>
             </div>
         </div>
-        {% if object.is_filled and object.is_active %}
-            <div class="col col-md-6">
-                <div class="card">
-                    <h5 class="card-header">Session Key</h5>
-                    <div class="card-body">
-                        <table class="table table-hover">
-                            <tr>
-                                <th scope="row">Status</th>
-                                <td>
-                                    {% if object.session_key %}
-                                        <i class="mdi mdi-check-circle text-success"></i> Active
-                                    {% else %}
-                                        <i class="mdi mdi-close-circle text-danger"></i> Inactive
-                                    {% endif %}
-                                </td>
-                            </tr>
-                            <tr>
-                                <th scope="row">Created</th>
-                                <td>{{ object.session_key.created|placeholder }}</td>
-                            </tr>
-                        </table>
-                    </div>
-                    <div class="card-footer">
-                        <div class="text-end noprint">
-                            {% if object.session_key %}
-                                <a class="btn btn-danger btn-sm" title="Delete Session Key"
-                                   href="{% url 'plugins:netbox_secrets:sessionkey_delete' %}?return_url={{ request.path }}">
-                                    <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
-                                </a>
-                            {% else %}
-                                <button type="button" class="btn btn-sm btn-primary" id="add_session_key"
-                                        data-bs-toggle="modal" data-bs-target="#privkey_modal">
-                                    <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add Session Key
-                                </button>
-                                {% include 'netbox_secrets/inc/private_key_modal.html' %}
-                            {% endif %}
+        {% if object.user == request.user %}
+            {% if object.is_filled and object.is_active %}
+                <div class="col col-md-6">
+                    <div class="card">
+                        <h5 class="card-header">Session Key</h5>
+                        <div class="card-body">
+                            <table class="table table-hover">
+                                <tr>
+                                    <th scope="row">Status</th>
+                                    <td>
+                                        {% if object.session_key %}
+                                            <i class="mdi mdi-check-circle text-success"></i> Active
+                                        {% else %}
+                                            <i class="mdi mdi-close-circle text-danger"></i> Inactive
+                                        {% endif %}
+                                    </td>
+                                </tr>
+                                <tr>
+                                    <th scope="row">Created</th>
+                                    <td>{{ object.session_key.created|placeholder }}</td>
+                                </tr>
+                            </table>
+                        </div>
+                        <div class="card-footer">
+                            <div class="text-end noprint">
+                                {% if object.session_key %}
+                                    <a class="btn btn-danger btn-sm" title="Delete Session Key"
+                                       href="{% url 'plugins:netbox_secrets:sessionkey_delete' %}?return_url={{ request.path }}">
+                                        <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
+                                    </a>
+                                {% else %}
+                                    <button type="button" class="btn btn-sm btn-primary" id="add_session_key"
+                                            data-bs-toggle="modal" data-bs-target="#privkey_modal">
+                                        <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add Session Key
+                                    </button>
+                                    {% include 'netbox_secrets/inc/private_key_modal.html' %}
+                                {% endif %}
+                            </div>
                         </div>
                     </div>
                 </div>
-            </div>
+            {% endif %}
         {% endif %}
     </div>
 {% endblock content %}
diff --git a/netbox_secrets/templates/netbox_secrets/userkey_edit.html b/netbox_secrets/templates/netbox_secrets/userkey_edit.html
index d39426d..47be3f1 100644
--- a/netbox_secrets/templates/netbox_secrets/userkey_edit.html
+++ b/netbox_secrets/templates/netbox_secrets/userkey_edit.html
@@ -1,95 +1,121 @@
-{% extends 'generic/object_edit.html' %}
-{% load form_helpers %}
+{% extends 'generic/_base.html' %}
+{% load buttons %}
+{% load helpers %}
 {% load plugins %}
+{% load render_table from django_tables2 %}
 {% load static %}
+{% load i18n %}
 
-{% block content-wrapper %}
-    <div class="tab-content">
-        <div class="tab-pane show active" id="edit-form" role="tabpanel" aria-labelledby="object-list-tab">
-            {# Link to model documentation #}
-            {% if settings.DOCS_ROOT and object.docs_url %}
-                <div class="float-end">
-                    <a href="{{ object.docs_url }}" target="_blank" class="btn btn-sm btn-outline-secondary"
-                       title="View model documentation">
-                        <i class="mdi mdi-help-circle"></i> Help
-                    </a>
+
+{% block title %}
+    {% if object.pk %}
+        {% trans "Editing" %} {{ object|meta:"verbose_name" }} {{ object }}
+    {% else %}
+        {% blocktrans trimmed with object_type=object|meta:"verbose_name" %}
+            Add a new {{ object_type }}
+        {% endblocktrans %}
+    {% endif %}
+{% endblock title %}
+
+{% block tabs %}
+    <ul class="nav nav-tabs">
+        <li class="nav-item" role="presentation">
+            <button class="nav-link active" id="edit-form-tab" data-bs-toggle="tab" data-bs-target="#edit-form"
+                    type="button" role="tab" aria-controls="edit-form" aria-selected="true">
+                {% if object.pk %}{% trans "Edit" %}{% else %}{% trans "Create" %}{% endif %}
+            </button>
+        </li>
+    </ul>
+{% endblock tabs %}
+
+
+{% block content %}
+    <div class="tab-pane show active" id="edit-form" role="tabpanel" aria-labelledby="object-list-tab">
+
+        <form action="." method="post" enctype="multipart/form-data" class="object-edit mt-5">
+            {% csrf_token %}
+            {% if object.is_active %}
+                <div class="alert alert-warning" role="alert">
+                    <div class="d-flex justify-content-between">
+                        <div>
+                            <i class="mdi mdi-alert p-2"></i>
+                            <strong>Warning:</strong> Changing your public key requires reactivation by another user.
+                            You won't be able to retrieve secrets until then.
+                        </div>
+                    </div>
                 </div>
             {% endif %}
 
-            <form action="." method="post" class="form form-object-edit mt-5">
-                {% csrf_token %}
 
+            <div id="form_fields" hx-disinherit="hx-select hx-swap">
                 {% block form %}
-                    {% if object.is_active %}
-                        <div class="alert alert-danger" role="alert">
-                            <strong>Warning:</strong> Changing your public key will require your user key to be
-                            re-activated
-                            by another user. You will be unable to retrieve any secrets until your key has been
-                            reactivated.
-                        </div>
-                    {% endif %}
-                    <div class="field-group mb-5">
-                        {% render_field form.public_key %}
-                    </div>
-                {% endblock %}
+                    {% include 'htmx/form.html' %}
+                {% endblock form %}
+            </div>
 
-                <div class="text-end my-3">
-                    {% block buttons %}
-                        <button
-                                type="button"
-                                class="btn btn-warning"
-                                id="generate_keypair"
-                                data-bs-toggle="modal"
-                                data-bs-target="#new_keypair_modal">
-                            <i class="mdi mdi-key"></i> Generate a New Key Pair
+            <div class="text-end my-3">
+                {% block buttons %}
+                    <a href="{% url 'plugins:netbox_secrets:userkey_list' %}"
+                       class="btn btn-outline-secondary">{% trans "Cancel" %}</a>
+                    {% if object.pk %}
+                        <button type="submit" name="_update" class="btn btn-primary">
+                            Save
                         </button>
-                        {% if object.pk %}
-                            <button type="submit" name="_update" class="btn btn-primary">
-                                Save
-                            </button>
-                        {% else %}
-                            <button type="submit" name="_create" class="btn btn-primary">
-                                Create
-                            </button>
-                        {% endif %}
-                        <a class="btn btn-outline-danger" href="{{ return_url }}">Cancel</a>
-                    {% endblock %}
-                </div>
-            </form>
-            <div class="modal fade" id="new_keypair_modal" tabindex="-1" role="dialog">
-                <div class="modal-dialog modal-lg" role="document">
-                    <div class="modal-content">
-                        <div class="modal-header">
-                            <h4 class="modal-title" id="new_keypair_modal_title">
-                                New RSA Key Pair
-                            </h4>
-                            <button type="button" class="btn btn-close" data-bs-dismiss="modal"
-                                    aria-label="Close"></button>
+                    {% else %}
+                        <button type="submit" name="_create" class="btn btn-primary">
+                            Create
+                        </button>
+                    {% endif %}
+                    <button
+                            type="button"
+                            class="btn btn-warning"
+                            id="generate_keypair"
+                            data-bs-toggle="modal"
+                            data-bs-target="#new_keypair_modal">
+                        <i class="mdi mdi-key"></i> Generate a New Key Pair
+                    </button>
+                {% endblock buttons %}
+            </div>
+        </form>
+        <div class="modal fade" id="new_keypair_modal" tabindex="-1" role="dialog">
+            <div class="modal-dialog modal-lg" role="document">
+                <div class="modal-content">
+                    <div class="modal-header">
+                        <h4 class="modal-title" id="new_keypair_modal_title">
+                            New RSA Key Pair
+                        </h4>
+                        <button type="button" class="btn btn-close" data-bs-dismiss="modal"
+                                aria-label="Close"></button>
+                    </div>
+                    <div class="modal-body">
+                        <div class="field-group">
+                            <h5>New Public Key</h5>
+                            <textarea class="form-control" rows="10" id="new_pubkey"
+                                      style="height: 250px;font-family:var(--bs-font-monospace);"></textarea>
                         </div>
-                        <div class="modal-body">
-                            <div class="field-group">
-                                <h5>New Public Key</h5>
-                                <textarea class="form-control" rows="10" id="new_pubkey"
-                                          style="height: 250px;font-family:var(--bs-font-monospace);"></textarea>
-                            </div>
 
-                            <div class="field-group">
-                                <h5>New Private Key</h5>
-                                <textarea class="form-control" rows="10" id="new_privkey"
-                                          style="height: 250px;font-family:var(--bs-font-monospace);"></textarea>
-                            </div>
-                        </div>
-                        <div class="modal-footer text-center">
-                            <button type="button" class="btn btn-danger" id="use_new_pubkey" data-bs-dismiss="modal">
-                                I Saved My New Private Key
-                            </button>
+                        <div class="field-group">
+                            <h5>New Private Key</h5>
+                            <textarea class="form-control" rows="10" id="new_privkey"
+                                      style="height: 250px;font-family:var(--bs-font-monospace);"></textarea>
                         </div>
                     </div>
+                    <div class="modal-footer text-center">
+                        <button type="button" class="btn btn-primary" id="copy_prikey">
+                            Copy
+                        </button>
+                        <button type="button" class="btn btn-primary" id="export_key">
+                            Export
+                        </button>
+                        <button type="button" class="btn btn-danger" id="use_new_pubkey" data-bs-dismiss="modal">
+                            I Saved My New Private Key
+                        </button>
+                    </div>
                 </div>
             </div>
         </div>
     </div>
-{% endblock %}
+{% endblock content %}
 
 {% block javascript %}
     {# Javascript #}
diff --git a/netbox_secrets/templates/netbox_secrets/userkey_list.html b/netbox_secrets/templates/netbox_secrets/userkey_list.html
new file mode 100644
index 0000000..91cc514
--- /dev/null
+++ b/netbox_secrets/templates/netbox_secrets/userkey_list.html
@@ -0,0 +1,22 @@
+{% extends 'generic/object_list.html' %}
+{% load i18n %}
+
+{% block controls %}
+    <div class="btn-list">
+        {% block extra_controls %}{% endblock %}
+        {% if 'add' in actions %}
+            <a href="{% url 'plugins:netbox_secrets:userkey_add' %}" type="button"
+               class="btn {% if user_key %}btn-warning{% else %}btn-primary{% endif %}">
+                <i class="mdi {% if user_key %}mdi-pencil{% else %}mdi-plus-thick{% endif %}"></i>
+                {% if user_key %}
+                    {% trans "Edit User Key" %}
+                {% else %}
+                    {% trans "Add User Key" %}
+                {% endif %}
+            </a>
+        {% endif %}
+        <a href="{% url 'plugins:netbox_secrets:userkey_activate'%}" type="button" class="btn btn-primary">
+            <i class="mdi mdi-auto-fix"></i> Activate User Key
+        </a>
+    </div>
+{% endblock controls %}
diff --git a/netbox_secrets/urls.py b/netbox_secrets/urls.py
index b7c9980..b6fb0df 100644
--- a/netbox_secrets/urls.py
+++ b/netbox_secrets/urls.py
@@ -1,6 +1,6 @@
 from django.urls import include, path
-from utilities.urls import get_model_urls
 
+from utilities.urls import get_model_urls
 from . import views
 
 urlpatterns = [
@@ -18,7 +18,9 @@
     path('secrets/edit/', views.SecretBulkEditView.as_view(), name='secret_bulk_edit'),
     path('secrets/<int:pk>/', include(get_model_urls('netbox_secrets', 'secret'))),
     # User
-    path('user-key/', views.UserKeyView.as_view(), name='userkey'),
-    path('user-key/edit/', views.UserKeyEditView.as_view(), name='userkey_edit'),
+    path('user-keys/', views.UserKeyListView.as_view(), name='userkey_list'),
+    path('user-keys/add/', views.UserKeyEditView.as_view(), name='userkey_add'),
+    path('user-keys/activate/', views.ActivateUserkeyView.as_view(), name='userkey_activate'),
+    path('user-keys/<int:pk>/', include(get_model_urls('netbox_secrets', 'userkey'))),
     path('session-key/delete/', views.SessionKeyDeleteView.as_view(), name='sessionkey_delete'),
 ]
diff --git a/netbox_secrets/views.py b/netbox_secrets/views.py
index 95ed676..2399271 100644
--- a/netbox_secrets/views.py
+++ b/netbox_secrets/views.py
@@ -10,29 +10,20 @@
 from django.utils.safestring import mark_safe
 from django.utils.translation import gettext as _
 from django.views.generic.base import View
+
 from extras.signals import clear_events
 from netbox.views import generic
+from netbox_secrets.models import UserKey
 from tenancy.views import ObjectContactsView
 from utilities.exceptions import AbortRequest, PermissionsViolation
 from utilities.forms import restrict_form_fields
-from utilities.utils import count_related, prepare_cloned_fields
+from utilities.query import count_related
+from utilities.querydict import prepare_cloned_fields
 from utilities.views import GetReturnURLMixin, ViewTab, register_model_view
-
 from . import constants, exceptions, filtersets, forms, models, tables, utils
 
 plugin_settings = settings.PLUGINS_CONFIG.get('netbox_secrets')
 
-#
-# Mixins
-#
-
-
-class ObjectChildrenViewMixin(generic.ObjectChildrenView):
-    def get_extra_context(self, request, instance):
-        return {
-            'table_config': f'{self.table.__name__}_config',
-        }
-
 
 #
 # Secret roles
@@ -54,12 +45,11 @@ class SecretRoleView(generic.ObjectView):
 
 
 @register_model_view(models.SecretRole, 'secret')
-class SecretRoleSecretView(ObjectChildrenViewMixin):
+class SecretRoleSecretView(generic.ObjectChildrenView):
     queryset = models.SecretRole.objects.all()
     child_model = models.Secret
     table = tables.SecretTable
     filterset = filtersets.SecretFilterSet
-    template_name = 'netbox_secrets/inc/view_tab.html'
     tab = ViewTab(
         label=_('Secrets'),
         badge=lambda obj: models.Secret.objects.filter(role=obj).count(),
@@ -114,7 +104,11 @@ class SecretListView(generic.ObjectListView):
     filterset = filtersets.SecretFilterSet
     filterset_form = forms.SecretFilterForm
     table = tables.SecretTable
-    actions = ('bulk_delete', 'bulk_edit')
+    actions = {
+        "export": {"view"},
+        "bulk_edit": {"change"},
+        "bulk_delete": {"delete"},
+    }
 
 
 @register_model_view(models.Secret)
@@ -151,10 +145,10 @@ def dispatch(self, request, *args, **kwargs):
             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')
+            return redirect('plugins:netbox_secrets:userkey_add')
         if not uk.is_active():
             messages.warning(request, "This operation is not available. Your user key has not been activated.")
-            return redirect('plugins:netbox_secrets:userkey')
+            return redirect('plugins:netbox_secrets:userkey', pk=uk.pk)
 
         return super().dispatch(request, *args, **kwargs)
 
@@ -286,32 +280,44 @@ class SecretBulkDeleteView(generic.BulkDeleteView):
 
 
 if plugin_settings.get('enable_contacts'):
-
     @register_model_view(models.Secret, 'contacts')
     class SecretContactsView(ObjectContactsView):
         queryset = models.Secret.objects.prefetch_related('role', 'tags')
 
 
-class UserKeyView(LoginRequiredMixin, View):
+#
+# User Key
+#
+
+
+class UserKeyListView(generic.ObjectListView):
+    queryset = models.UserKey.objects.all()
+    table = tables.UserKeyTable
+    filterset = filtersets.UserKeyFilterSet
+    template_name = 'netbox_secrets/userkey_list.html'
+
+    def get_extra_context(self, request):
+        user_key = UserKey.objects.filter(user=request.user).first()
+        return {
+            'user_key': user_key,
+        }
+
+
+@register_model_view(models.UserKey)
+class UserKeyView(generic.ObjectView):
+    queryset = models.UserKey.objects.all()
     template_name = 'netbox_secrets/userkey.html'
 
-    def get(self, request):
-        try:
-            userkey = models.UserKey.objects.get(user=request.user)
-        except models.UserKey.DoesNotExist:
-            userkey = None
 
-        return render(
-            request,
-            self.template_name,
-            {
-                'object': userkey,
-            },
-        )
+@register_model_view(models.UserKey, 'delete')
+class UserKeyDeleteView(generic.ObjectDeleteView):
+    queryset = UserKey.objects.all()
 
 
+@register_model_view(models.UserKey, 'edit')
 class UserKeyEditView(LoginRequiredMixin, GetReturnURLMixin, View):
-    queryset = models.SessionKey.objects.all()
+    queryset = models.UserKey.objects.all()
+    form = forms.UserKeyForm
     template_name = 'netbox_secrets/userkey_edit.html'
 
     def dispatch(self, request, *args, **kwargs):
@@ -323,13 +329,12 @@ def dispatch(self, request, *args, **kwargs):
         return super().dispatch(request, *args, **kwargs)
 
     def get(self, request):
-        form = forms.UserKeyForm(instance=self.userkey)
         return render(
             request,
             self.template_name,
             {
                 'object': self.userkey,
-                'form': form,
+                'form': self.form,
                 'return_url': self.get_return_url(request, self.userkey),
             },
         )
@@ -342,7 +347,7 @@ def post(self, request):
             uk.user = request.user
             uk.save()
             messages.success(request, "Your user key has been saved.")
-            return redirect('plugins:netbox_secrets:userkey')
+            return redirect('plugins:netbox_secrets:userkey', pk=uk.pk)
         else:
             logger.debug("Form validation failed")
             messages.error(request, "Unable to save your user key.")
@@ -357,7 +362,48 @@ def post(self, request):
         )
 
 
-@register_model_view(models.SessionKey, 'delete')
+class ActivateUserkeyView(LoginRequiredMixin, GetReturnURLMixin, View):
+    queryset = models.UserKey.objects.all()
+    template_name = 'netbox_secrets/activate_keys.html'
+
+    def dispatch(self, request, *args, **kwargs):
+        try:
+            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 = forms.ActivateUserKeyForm()
+        return render(
+            request,
+            self.template_name,
+            {
+                'form': form,
+            },
+        )
+
+    def post(self, request):
+        if not self.userkey or not self.userkey.is_active():
+            messages.error(request, "You do not have an active User Key.")
+            return redirect('plugins:netbox_secrets:userkey_activate')
+
+        form = forms.ActivateUserKeyForm(request.POST)
+        if form.is_valid():
+            master_key = self.userkey.get_master_key(form.cleaned_data['secret_key'])
+            user_keys = form.cleaned_data['user_keys']
+            if master_key:
+                for user_key in user_keys:
+                    user_key.activate(master_key)
+                    messages.success(request, f"Successfully activated {len(user_keys)} user keys.")
+                    return redirect("plugins:netbox_secrets:userkey_list")
+            else:
+                messages.error(request, "Invalid Private Key.")
+
+        return render(request, self.template_name, {'form': form})
+
+
 class SessionKeyDeleteView(generic.ObjectDeleteView):
     queryset = models.SessionKey.objects.all()
 
diff --git a/setup.py b/setup.py
index e0049a0..f48d662 100644
--- a/setup.py
+++ b/setup.py
@@ -1,4 +1,5 @@
 import os
+
 from setuptools import find_packages, setup
 
 description = "Enhance your secret management with encrypted storage and flexible, user-friendly features."
@@ -10,13 +11,15 @@
 
 setup(
     name='netbox-secrets',
-    version='1.10.2',
+    version='2.0.0',
     description=description,
     long_description=long_description,
     long_description_content_type="text/markdown",
     url='https://github.com/Onemind-Services-LLC/netbox-secrets/',
     author='Abhimanyu Saharan',
     author_email='asaharan@onemindservices.com',
+    maintainer="Prince Kumar",
+    maintainer_email="pkumar@onemindservices.com",
     license='Apache 2.0',
     install_requires=[
         'pycryptodome',


Test patch:
diff --git a/netbox_secrets/tests/test_api.py b/netbox_secrets/tests/test_api.py
index 2641ab5..c5416d7 100644
--- a/netbox_secrets/tests/test_api.py
+++ b/netbox_secrets/tests/test_api.py
@@ -1,14 +1,14 @@
 import base64
 
-from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
-from django.contrib.contenttypes.models import ContentType
 from django.urls import reverse
 from rest_framework import status
+
+from core.models import ObjectType
+from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from users.models import ObjectPermission
 from utilities.testing import APITestCase, APIViewTestCases
-
-from ..models import *
 from .constants import *
+from ..models import *
 
 
 class SecretsTestMixin:
@@ -97,7 +97,7 @@ def setUp(self):
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
         devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
-        device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, device_role=devicerole)
+        device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, role=devicerole)
 
         secret_roles = (
             SecretRole(name='Secret Role 1', slug='secret-role-1'),
@@ -182,7 +182,7 @@ def test_create_session_key(self):
         obj_perm = ObjectPermission(name='Test permission', actions=['add'])
         obj_perm.save()
         obj_perm.users.add(self.user)
-        obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
         initial_count = self._get_queryset().count()
         response = self.client.post(self._get_list_url(), self.create_data[0], format='json', **self.header)
 
@@ -199,7 +199,7 @@ def test_get_session_key_preserved(self):
         obj_perm = ObjectPermission(name='Test permission', actions=['add'])
         obj_perm.save()
         obj_perm.users.add(self.user)
-        obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
         initial_count = self._get_queryset().count()
         response = self.client.post(self._get_list_url(), self.create_data[1], format='json', **self.header)
 
@@ -216,7 +216,7 @@ def test_get_object(self):
         obj_perm = ObjectPermission(name='Test permission', constraints={'pk': instance.pk}, actions=['view'])
         obj_perm.save()
         obj_perm.users.add(self.user)
-        obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
         # Try GET to permitted object
         url = self._get_detail_url(instance)
@@ -229,7 +229,7 @@ def test_list_objects(self):
         obj_perm = ObjectPermission(name='Test permission', constraints={'pk__in': [instance.pk]}, actions=['view'])
         obj_perm.save()
         obj_perm.users.add(self.user)
-        obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
         # Try GET to permitted objects
         response = self.client.get(self._get_list_url(), **self.header)
@@ -241,7 +241,7 @@ def test_bulk_delete_objects(self):
         obj_perm = ObjectPermission(name='Test permission', actions=['delete'])
         obj_perm.save()
         obj_perm.users.add(self.user)
-        obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
 
         # Target the three most recently created objects to avoid triggering recursive deletions
         # (e.g. with MPTT objects)
diff --git a/netbox_secrets/tests/test_filters.py b/netbox_secrets/tests/test_filters.py
index 131a748..4b6df06 100644
--- a/netbox_secrets/tests/test_filters.py
+++ b/netbox_secrets/tests/test_filters.py
@@ -41,9 +41,9 @@ def setUpTestData(cls):
         device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
 
         devices = (
-            Device(device_type=device_type, name='Device 1', site=site, device_role=device_role),
-            Device(device_type=device_type, name='Device 2', site=site, device_role=device_role),
-            Device(device_type=device_type, name='Device 3', site=site, device_role=device_role),
+            Device(device_type=device_type, name='Device 1', site=site, role=device_role),
+            Device(device_type=device_type, name='Device 2', site=site, role=device_role),
+            Device(device_type=device_type, name='Device 3', site=site, role=device_role),
         )
         Device.objects.bulk_create(devices)
 
diff --git a/netbox_secrets/tests/test_form.py b/netbox_secrets/tests/test_form.py
index 56f43db..5b1ccc0 100644
--- a/netbox_secrets/tests/test_form.py
+++ b/netbox_secrets/tests/test_form.py
@@ -1,9 +1,8 @@
 from django.test import TestCase
-from utilities.testing import create_test_user
 
 from netbox_secrets.forms import UserKeyForm
 from netbox_secrets.models import UserKey
-
+from utilities.testing import create_test_user
 from .constants import PUBLIC_KEY, SSH_PUBLIC_KEY
 
 
diff --git a/netbox_secrets/tests/test_models.py b/netbox_secrets/tests/test_models.py
index 23c510e..0e1ec5b 100644
--- a/netbox_secrets/tests/test_models.py
+++ b/netbox_secrets/tests/test_models.py
@@ -1,11 +1,10 @@
-import string
-
 from Crypto.PublicKey import RSA
 from django.conf import settings
-from django.contrib.auth.models import User
+from django.contrib.auth import get_user_model
 from django.core.exceptions import ValidationError
 from django.test import TestCase
 
+import string
 from netbox_secrets.models import Secret, UserKey
 from netbox_secrets.utils.crypto import (
     decrypt_master_key,
@@ -17,6 +16,7 @@
 
 class UserKeyTestCase(TestCase):
     def setUp(self):
+        User = get_user_model()
         self.TEST_KEYS = {}
         key_size = settings.PLUGINS_CONFIG['netbox_secrets'].get('public_key_size')
         for username in ['alice', 'bob']:
@@ -29,6 +29,7 @@ def test_01_fill(self):
         """
         Validate the filling of a UserKey with public key material.
         """
+        User = get_user_model()
         alice_uk = UserKey(user=User.objects.get(username='alice'))
         self.assertFalse(alice_uk.is_filled(), "UserKey with empty public_key is_filled() did not return False")
         alice_uk.public_key = self.TEST_KEYS['alice_public']
@@ -38,6 +39,7 @@ def test_02_activate(self):
         """
         Validate the activation of a UserKey.
         """
+        User = get_user_model()
         master_key = generate_random_key()
         alice_uk = UserKey(user=User.objects.get(username='alice'), public_key=self.TEST_KEYS['alice_public'])
         self.assertFalse(alice_uk.is_active(), "Inactive UserKey is_active() did not return False")
@@ -62,6 +64,7 @@ def test_04_master_key_retrieval(self):
         """
         Test the decryption of a master key using the user's private key.
         """
+        User = get_user_model()
         master_key = generate_random_key()
         alice_uk = UserKey(user=User.objects.get(username='alice'), public_key=self.TEST_KEYS['alice_public'])
         alice_uk.activate(master_key)
diff --git a/netbox_secrets/tests/test_views.py b/netbox_secrets/tests/test_views.py
index e27299c..aa3653e 100644
--- a/netbox_secrets/tests/test_views.py
+++ b/netbox_secrets/tests/test_views.py
@@ -1,8 +1,6 @@
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
-from utilities.testing import ViewTestCases
-
 from netbox_secrets.models import Secret, SecretRole, SessionKey, UserKey
-
+from utilities.testing import ViewTestCases
 from .constants import PRIVATE_KEY, PUBLIC_KEY
 
 
@@ -73,9 +71,9 @@ def setUpTestData(cls):
         devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
 
         devices = (
-            Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole),
-            Device(name='Device 2', site=site, device_type=devicetype, device_role=devicerole),
-            Device(name='Device 3', site=site, device_type=devicetype, device_role=devicerole),
+            Device(name='Device 1', site=site, device_type=devicetype, role=devicerole),
+            Device(name='Device 2', site=site, device_type=devicetype, role=devicerole),
+            Device(name='Device 3', site=site, device_type=devicetype, role=devicerole),
         )
         Device.objects.bulk_create(devices)