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

Issue:
Refactored code



Discussion / hints:

Fix patch:
diff --git a/.flake8 b/.flake8
index c6b09d2..55e13d2 100644
--- a/.flake8
+++ b/.flake8
@@ -1,3 +1,3 @@
 [flake8]
 max-line-length = 160
-extend-ignore = E203
\ No newline at end of file
+extend-ignore = E203
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index d3d9087..c033dc4 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -52,4 +52,4 @@ body:
       description: What happened instead?
       placeholder: A TypeError exception was raised
     validations:
-      required: true
\ No newline at end of file
+      required: true
diff --git a/.github/ISSUE_TEMPLATE/documentation_change.yml b/.github/ISSUE_TEMPLATE/documentation_change.yml
index e2853c2..e654210 100644
--- a/.github/ISSUE_TEMPLATE/documentation_change.yml
+++ b/.github/ISSUE_TEMPLATE/documentation_change.yml
@@ -32,4 +32,4 @@ body:
       label: Proposed Changes
       description: Describe the proposed changes and why they are necessary.
     validations:
-      required: true
\ No newline at end of file
+      required: true
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
index 3eafaf5..c56f50a 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -51,4 +51,4 @@ body:
       description: >
         List any new dependencies on external libraries or services that this new feature would
         introduce. For example, does the proposal require the installation of a new Python package?
-        (Not all new features introduce new dependencies.)
\ No newline at end of file
+        (Not all new features introduce new dependencies.)
diff --git a/.github/ISSUE_TEMPLATE/housekeeping.yml b/.github/ISSUE_TEMPLATE/housekeeping.yml
index d62052b..d25a283 100644
--- a/.github/ISSUE_TEMPLATE/housekeeping.yml
+++ b/.github/ISSUE_TEMPLATE/housekeeping.yml
@@ -22,4 +22,4 @@ body:
       label: Justification
       description: Please provide justification for the proposed change(s).
     validations:
-      required: true
\ No newline at end of file
+      required: true
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 0ed1dc8..eec3175 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -70,4 +70,4 @@ Please put an x into the brackets (like `[x]`) if you've completed that task.
 
 * [ ] I have explained my PR according to the information in the comments
  or in a linked issue.
-* [ ] My PR targets the `dev` branch.
\ No newline at end of file
+* [ ] My PR targets the `dev` branch.
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index c84667a..3372d4a 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -37,4 +37,4 @@ jobs:
       uses: github/codeql-action/autobuild@v2
 
     - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze@v2
\ No newline at end of file
+      uses: github/codeql-action/analyze@v2
diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml
index af2ef9c..a25de59 100644
--- a/.github/workflows/dependency-review.yml
+++ b/.github/workflows/dependency-review.yml
@@ -11,4 +11,4 @@ jobs:
       - name: 'Checkout Repository'
         uses: actions/checkout@v3
       - name: 'Dependency Review'
-        uses: actions/dependency-review-action@v2
\ No newline at end of file
+        uses: actions/dependency-review-action@v2
diff --git a/.gitignore b/.gitignore
index acdbc5b..028c480 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,27 +3,7 @@
 # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
 
 # User-specific stuff
-.idea/**/workspace.xml
-.idea/**/tasks.xml
-.idea/**/usage.statistics.xml
-.idea/**/dictionaries
-.idea/**/shelf
-
-# Generated files
-.idea/**/contentModel.xml
-
-# Sensitive or high-churn files
-.idea/**/dataSources/
-.idea/**/dataSources.ids
-.idea/**/dataSources.local.xml
-.idea/**/sqlDataSources.xml
-.idea/**/dynamic.xml
-.idea/**/uiDesigner.xml
-.idea/**/dbnavigator.xml
-
-# Gradle
-.idea/**/gradle.xml
-.idea/**/libraries
+.idea/
 
 # Gradle and Maven with auto-import
 # When using Gradle or Maven with auto-import, you should exclude module files,
diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index 13566b8..0000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -1,8 +0,0 @@
-# Default ignored files
-/shelf/
-/workspace.xml
-# Editor-based HTTP Client requests
-/httpRequests/
-# Datasource local storage ignored files
-/dataSources/
-/dataSources.local.xml
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
deleted file mode 100644
index 0838f44..0000000
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ /dev/null
@@ -1,26 +0,0 @@
-<component name="InspectionProjectProfileManager">
-  <profile version="1.0">
-    <option name="myName" value="Project Default" />
-    <inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
-    <inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
-      <option name="ignoredPackages">
-        <value>
-          <list size="11">
-            <item index="0" class="java.lang.String" itemvalue="fastapi" />
-            <item index="1" class="java.lang.String" itemvalue="pydantic" />
-            <item index="2" class="java.lang.String" itemvalue="fastapi-redis-cache" />
-            <item index="3" class="java.lang.String" itemvalue="pynetbox" />
-            <item index="4" class="java.lang.String" itemvalue="py-ldap" />
-            <item index="5" class="java.lang.String" itemvalue="requests" />
-            <item index="6" class="java.lang.String" itemvalue="uvicorn" />
-            <item index="7" class="java.lang.String" itemvalue="python-slugify" />
-            <item index="8" class="java.lang.String" itemvalue="py-influxdb" />
-            <item index="9" class="java.lang.String" itemvalue="python-jose" />
-            <item index="10" class="java.lang.String" itemvalue="chargebee" />
-          </list>
-        </value>
-      </option>
-    </inspection_tool>
-    <inspection_tool class="Stylelint" enabled="true" level="ERROR" enabled_by_default="true" />
-  </profile>
-</component>
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml
deleted file mode 100644
index 105ce2d..0000000
--- a/.idea/inspectionProfiles/profiles_settings.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-<component name="InspectionProjectProfileManager">
-  <settings>
-    <option name="USE_PROJECT_PROFILE" value="false" />
-    <version value="1.0" />
-  </settings>
-</component>
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
deleted file mode 100644
index 9216566..0000000
--- a/.idea/misc.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project version="4">
-  <component name="ProjectRootManager" version="2" project-jdk-name="Python 3.10 (netbox)" project-jdk-type="Python SDK" />
-</project>
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
deleted file mode 100644
index a1a41ea..0000000
--- a/.idea/modules.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project version="4">
-  <component name="ProjectModuleManager">
-    <modules>
-      <module fileurl="file://$PROJECT_DIR$/.idea/netbox-secrets.iml" filepath="$PROJECT_DIR$/.idea/netbox-secrets.iml" />
-    </modules>
-  </component>
-</project>
\ No newline at end of file
diff --git a/.idea/netbox-secrets.iml b/.idea/netbox-secrets.iml
deleted file mode 100644
index c410d0c..0000000
--- a/.idea/netbox-secrets.iml
+++ /dev/null
@@ -1,22 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<module type="PYTHON_MODULE" version="4">
-  <component name="NewModuleRootManager">
-    <content url="file://$MODULE_DIR$">
-      <excludeFolder url="file://$MODULE_DIR$/venv" />
-    </content>
-    <orderEntry type="jdk" jdkName="Python 3.10 (netbox)" jdkType="Python SDK" />
-    <orderEntry type="sourceFolder" forTests="false" />
-  </component>
-  <component name="PyDocumentationSettings">
-    <option name="format" value="PLAIN" />
-    <option name="myDocStringFormat" value="Plain" />
-  </component>
-  <component name="TemplatesService">
-    <option name="TEMPLATE_CONFIGURATION" value="Jinja2" />
-    <option name="TEMPLATE_FOLDERS">
-      <list>
-        <option value="$MODULE_DIR$/netbox_secretstore/templates" />
-      </list>
-    </option>
-  </component>
-</module>
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index 94a25f7..0000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project version="4">
-  <component name="VcsDirectoryMappings">
-    <mapping directory="$PROJECT_DIR$" vcs="Git" />
-  </component>
-</project>
\ No newline at end of file
diff --git a/.isort.cfg b/.isort.cfg
index 5a73791..a9a8d64 100644
--- a/.isort.cfg
+++ b/.isort.cfg
@@ -5,4 +5,4 @@ profile = black
 multi_line_output = 3
 
 ; necessary because black expect the trailing comma
-include_trailing_comma = true
\ No newline at end of file
+include_trailing_comma = true
diff --git a/.jscpd.json b/.jscpd.json
index 263079f..e435274 100644
--- a/.jscpd.json
+++ b/.jscpd.json
@@ -1,3 +1,3 @@
 {
   "ignore": ["**/tests/**"]
-}
\ No newline at end of file
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8cb3b91..6ddec6f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,21 @@
 # Changelog
 
+## [Unreleased](https://github.com/Onemind-Services-LLC/netbox-secrets/tree/HEAD)
+
+[Full Changelog](https://github.com/Onemind-Services-LLC/netbox-secrets/compare/v1.7.3...HEAD)
+
+**Closed issues:**
+
+- \[Bug\]: Secret role secrets count is always 0 [\#24](https://github.com/Onemind-Services-LLC/netbox-secrets/issues/24)
+
+## [v1.7.3](https://github.com/Onemind-Services-LLC/netbox-secrets/tree/v1.7.3) (2023-01-23)
+
+[Full Changelog](https://github.com/Onemind-Services-LLC/netbox-secrets/compare/v1.7.2...v1.7.3)
+
+**Merged pull requests:**
+
+- Prepare for Pypi [\#23](https://github.com/Onemind-Services-LLC/netbox-secrets/pull/23) ([abhi1693](https://github.com/abhi1693))
+
 ## [v1.7.2](https://github.com/Onemind-Services-LLC/netbox-secrets/tree/v1.7.2) (2023-01-19)
 
 [Full Changelog](https://github.com/Onemind-Services-LLC/netbox-secrets/compare/v1.7.1...v1.7.2)
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index 563f476..9432b4c 100644
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -73,4 +73,4 @@ available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.ht
 [homepage]: https://www.contributor-covenant.org
 
 For answers to common questions about this code of conduct, see
-https://www.contributor-covenant.org/faq
\ No newline at end of file
+https://www.contributor-covenant.org/faq
diff --git a/LICENSE.md b/LICENSE.md
index 4947287..f433b1a 100644
--- a/LICENSE.md
+++ b/LICENSE.md
@@ -174,4 +174,4 @@
       incurred by, or claims asserted against, such Contributor by reason
       of your accepting any such warranty or additional liability.
 
-   END OF TERMS AND CONDITIONS
\ No newline at end of file
+   END OF TERMS AND CONDITIONS
diff --git a/MANIFEST.in b/MANIFEST.in
index 3554610..44e1d68 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,4 +1,4 @@
 include README.md
 include LICENSE
 recursive-include netbox_secrets/templates *
-recursive-include netbox_secrets/static *
\ No newline at end of file
+recursive-include netbox_secrets/static *
diff --git a/README.md b/README.md
index dbe970e..a5d67a3 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,17 @@
 # Netbox Secrets
 
-This is the continuation of the [NetBox Secretstore](https://github.com/DanSheps/netbox-secretstore) app.
+This is the continuation of the [NetBox Secretstore](https://github.com/DanSheps/netbox-secretstore) app. The original plugin
+is minimally maintained and has a lot of issues. This plugin is a complete rewrite of the original plugin. It is more generic
+and flexible than the original plugin. It is also regularly tested with the latest NetBox releases to ensure compatibility
+and stability.
+
+# Features
+
+* Store secrets in the database encrypted with a public key (RSA)
+* More generic and flexible than the original plugin (e.g. secrets can be assigned to any object in NetBox)
+* Secrets can be assigned to contacts to associate them with a secret (e.g. SSH key)
+* Updated user interface to make it easier to use and more intuitive to navigate
+* Regularly tested with the latest NetBox releases to ensure compatibility and stability
 
 # Compatibility
 
@@ -51,6 +62,28 @@ The following options are available in the configuration file:
   - __Default__: `2048`
   - __Options__: `2048`, `4096`, `8192`
 
+## Screenshots
+
+### User key (Without Session)
+
+![user-key.png](./assets/user-key.png)
+
+### User key (With Session)
+
+![user-key-session.png](./assets/user-key-session.png)
+
+### Secret Object View
+
+![secret.png](./assets/secret.png)
+
+### Secret Panel on Device
+
+![secret-panel.png](./assets/secret-panel.png)
+
+### Secret Role Object View
+
+![role.png](./assets/role.png)
+
 ## FAQ
 
 1. How can I migrate the data from `netbox-secretstore`?
@@ -75,9 +108,9 @@ python manage.py migrate
 Finally, readjust the indices for the `netbox-secrets` plugin
 
 ```shell
-python manage.py sqlsequencereset netbox_secrets 
+python manage.py sqlsequencereset netbox_secrets
 ```
 
 Run the output of the previous command in the database.
 
-You can now remove `netbox-secretstore` from the application.
\ No newline at end of file
+You can now remove `netbox-secretstore` from the application.
diff --git a/assets/role.png b/assets/role.png
new file mode 100644
index 0000000..db7f427
Binary files /dev/null and b/assets/role.png differ
diff --git a/assets/secret-panel.png b/assets/secret-panel.png
new file mode 100644
index 0000000..00e5d45
Binary files /dev/null and b/assets/secret-panel.png differ
diff --git a/assets/secret.png b/assets/secret.png
new file mode 100644
index 0000000..9306325
Binary files /dev/null and b/assets/secret.png differ
diff --git a/assets/user-key-session.png b/assets/user-key-session.png
new file mode 100644
index 0000000..75e0aee
Binary files /dev/null and b/assets/user-key-session.png differ
diff --git a/assets/user-key.png b/assets/user-key.png
new file mode 100644
index 0000000..8522d3e
Binary files /dev/null and b/assets/user-key.png differ
diff --git a/configuration/configuration.py b/configuration/configuration.py
index cdc99a7..4dbbe0a 100644
--- a/configuration/configuration.py
+++ b/configuration/configuration.py
@@ -13,8 +13,8 @@
 # Read secret from file
 def _read_secret(secret_name, default=None):
     try:
-        f = open('/run/secrets/' + secret_name, 'r', encoding='utf-8')
-    except EnvironmentError:
+        f = open('/run/secrets/' + secret_name, encoding='utf-8')
+    except OSError:
         return default
     else:
         with f:
@@ -68,12 +68,14 @@ def _read_secret(secret_name, default=None):
         'HOST': environ.get('REDIS_CACHE_HOST', environ.get('REDIS_HOST', 'localhost')),
         'PORT': int(environ.get('REDIS_CACHE_PORT', environ.get('REDIS_PORT', 6379))),
         'PASSWORD': _read_secret(
-            'redis_cache_password', environ.get('REDIS_CACHE_PASSWORD', environ.get('REDIS_PASSWORD', ''))
+            'redis_cache_password',
+            environ.get('REDIS_CACHE_PASSWORD', environ.get('REDIS_PASSWORD', '')),
         ),
         'DATABASE': int(environ.get('REDIS_CACHE_DATABASE', 1)),
         'SSL': environ.get('REDIS_CACHE_SSL', environ.get('REDIS_SSL', 'False')).lower() == 'true',
         'INSECURE_SKIP_TLS_VERIFY': environ.get(
-            'REDIS_CACHE_INSECURE_SKIP_TLS_VERIFY', environ.get('REDIS_INSECURE_SKIP_TLS_VERIFY', 'False')
+            'REDIS_CACHE_INSECURE_SKIP_TLS_VERIFY',
+            environ.get('REDIS_INSECURE_SKIP_TLS_VERIFY', 'False'),
         ).lower()
         == 'true',
     },
@@ -84,3 +86,5 @@ def _read_secret(secret_name, default=None):
 # symbols. NetBox will not run without this defined. For more information, see
 # https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-SECRET_KEY
 SECRET_KEY = _read_secret('secret_key', environ.get('SECRET_KEY', ''))
+
+DEVELOPER = True
diff --git a/docker-compose.yml b/docker-compose.yml
index 9b82488..0b78731 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -26,4 +26,4 @@ services:
     - sh
     - -c # this is to evaluate the $REDIS_PASSWORD from the env
     - redis-server --appendonly yes --requirepass $$REDIS_PASSWORD ## $$ because of docker-compose
-    env_file: env/redis.env
\ No newline at end of file
+    env_file: env/redis.env
diff --git a/docs/models/secret.md b/docs/models/secret.md
index f11819f..7c5b5ce 100644
--- a/docs/models/secret.md
+++ b/docs/models/secret.md
@@ -1,8 +1,8 @@
 # Secrets
 
-A secret represents a single credential or other sensitive string of characters which must be stored securely. Each 
-secret is assigned to a device within NetBox. The plaintext value of a secret is encrypted to a ciphertext 
-immediately prior to storage within the database using a 256-bit AES master key. A SHA256 hash of the plaintext is also 
+A secret represents a single credential or other sensitive string of characters which must be stored securely. Each
+secret is assigned to a device within NetBox. The plaintext value of a secret is encrypted to a ciphertext
+immediately prior to storage within the database using a 256-bit AES master key. A SHA256 hash of the plaintext is also
 stored along with each ciphertext to validate the decrypted plaintext.
 
 Each secret can also store an optional name parameter, which is not encrypted. This may be useful for storing usernames.
diff --git a/docs/models/secretrole.md b/docs/models/secretrole.md
index d71eeb3..9faf65a 100644
--- a/docs/models/secretrole.md
+++ b/docs/models/secretrole.md
@@ -1,6 +1,6 @@
 # Secret Roles
 
-Each secret is assigned a functional role which indicates what it is used for. Secret roles are customizable. 
+Each secret is assigned a functional role which indicates what it is used for. Secret roles are customizable.
 
 Typical roles might include:
 
diff --git a/docs/models/userkey.md b/docs/models/userkey.md
index 7176b68..b3213d5 100644
--- a/docs/models/userkey.md
+++ b/docs/models/userkey.md
@@ -1,9 +1,9 @@
 # User Keys
 
-Each user within NetBox can associate his or her account with an RSA public key. If activated by an administrator, 
+Each user within NetBox can associate his or her account with an RSA public key. If activated by an administrator,
 this user key will contain a unique, encrypted copy of the AES master key needed to retrieve secret data.
 
-User keys may be created by users individually, however they are of no use until they have been activated by a user 
+User keys may be created by users individually, however they are of no use until they have been activated by a user
 who already possesses an active user key.
 
 ## Supported Key Format
@@ -22,26 +22,26 @@ Private key formats supported (unencrypted)
 
 ## Creating the First User Key
 
-When NetBox is first installed, it contains no encryption keys. Before it can store secrets, a user 
+When NetBox is first installed, it contains no encryption keys. Before it can store secrets, a user
 (typically the superuser) must create a user key. This can be done by navigating to Netbox Secrets > User Key.
 
-To create a user key, you can either generate a new RSA key pair, or upload the public key belonging to a pair you 
-already have. If generating a new key pair, **you must save the private key** locally before saving your new user key. 
+To create a user key, you can either generate a new RSA key pair, or upload the public key belonging to a pair you
+already have. If generating a new key pair, **you must save the private key** locally before saving your new user key.
 Once your user key has been created, its public key will be displayed under your User Key page.
 
-When the first user key is created in NetBox, a random master encryption key is generated automatically. This key is 
-then encrypted using the public key provided and stored as part of your user key. **The master key cannot be recovered** 
+When the first user key is created in NetBox, a random master encryption key is generated automatically. This key is
+then encrypted using the public key provided and stored as part of your user key. **The master key cannot be recovered**
 without your private key.
 
-Once a user key has been assigned an encrypted copy of the master key, it is considered activated and can now be used 
+Once a user key has been assigned an encrypted copy of the master key, it is considered activated and can now be used
 to encrypt and decrypt secrets.
 
 ## Creating Additional User Keys
 
-Any user can create his or her user key by generating or uploading a public RSA key. However, a user key cannot be used 
+Any user can create his or her user key by generating or uploading a public RSA key. However, a user key cannot be used
 to encrypt or decrypt secrets until it has been activated with an encrypted copy of the master key.
 
-Only an administrator with an active user key can activate other user keys. To do so, access the NetBox admin UI and 
-navigate to Admin > NetBox Secrets > User Keys. Select the user key(s) to be activated, and select 
-"activate selected user keys" from the action dropdown. You will need to provide your private key in order to decrypt 
+Only an administrator with an active user key can activate other user keys. To do so, access the NetBox admin UI and
+navigate to Admin > NetBox Secrets > User Keys. Select the user key(s) to be activated, and select
+"activate selected user keys" from the action dropdown. You will need to provide your private key in order to decrypt
 the master key. A copy of the master key is then encrypted using the public key associated with the user key being activated.
diff --git a/docs/rest-api/working-with-secrets.md b/docs/rest-api/working-with-secrets.md
index 325210c..fb3e1a8 100644
--- a/docs/rest-api/working-with-secrets.md
+++ b/docs/rest-api/working-with-secrets.md
@@ -1,12 +1,12 @@
 # Working with Secrets
 
-As with most other objects, the REST API can be used to view, create, modify, and delete secrets. However, additional 
+As with most other objects, the REST API can be used to view, create, modify, and delete secrets. However, additional
 steps are needed to encrypt or decrypt secret data.
 
 ## Generating a Session Key
 
-In order to encrypt or decrypt secret data, a session key must be attached to the API request. To generate a session key, 
-send an authenticated request to the `/api/plugins/secrets/get-session-key/` endpoint with the private RSA key which 
+In order to encrypt or decrypt secret data, a session key must be attached to the API request. To generate a session key,
+send an authenticated request to the `/api/plugins/secrets/get-session-key/` endpoint with the private RSA key which
 matches your [UserKey](../models/userkey.md). The private key must be POSTed with the name `private_key`.
 
 ```no-highlight
@@ -23,15 +23,15 @@ $ curl -X POST http://netbox/api/plugins/secrets/get-session-key/ \
 ```
 
 !!! note
-    To read the private key from a file, use the convention above. Alternatively, the private key can be read from an 
+    To read the private key from a file, use the convention above. Alternatively, the private key can be read from an
 environment variable using `--data-urlencode "private_key=$PRIVATE_KEY"`.
 
-The request uses the provided private key to unlock your stored copy of the master key and generate a temporary 
+The request uses the provided private key to unlock your stored copy of the master key and generate a temporary
 session key, which can be attached in the `X-Session-Key` header of future API requests.
 
 ## Retrieving Secrets
 
-A session key is not needed to retrieve unencrypted secrets: The secret is returned like any normal object with its 
+A session key is not needed to retrieve unencrypted secrets: The secret is returned like any normal object with its
 `plaintext` field set to null.
 
 ```no-highlight
@@ -145,7 +145,7 @@ $ curl http://netbox/api/plugins/secrets/secrets/secrets/?limit=3 \
 
 ## Creating and Updating Secrets
 
-Session keys are required when creating or modifying secrets. The secret's `plaintext` attribute is set to its 
+Session keys are required when creating or modifying secrets. The secret's `plaintext` attribute is set to its
 non-encrypted value, and NetBox uses the session key to compute and store the encrypted value.
 
 ```no-highlight
diff --git a/env/netbox.env b/env/netbox.env
index e39dab5..ee71174 100644
--- a/env/netbox.env
+++ b/env/netbox.env
@@ -20,4 +20,4 @@ SUPERUSER_EMAIL=admin@example.com
 SUPERUSER_NAME=admin
 SUPERUSER_PASSWORD=admin
 STARTUP_SCRIPTS=false
-WEBHOOKS_ENABLED=true
\ No newline at end of file
+WEBHOOKS_ENABLED=true
diff --git a/env/postgres.env b/env/postgres.env
index 045e64b..bb7b53c 100644
--- a/env/postgres.env
+++ b/env/postgres.env
@@ -1,3 +1,3 @@
 POSTGRES_DB=netbox
 POSTGRES_PASSWORD=J5brHrAXFLQSif0K
-POSTGRES_USER=netbox
\ No newline at end of file
+POSTGRES_USER=netbox
diff --git a/env/redis.env b/env/redis.env
index dcb03dd..44a1987 100644
--- a/env/redis.env
+++ b/env/redis.env
@@ -1,1 +1,1 @@
-REDIS_PASSWORD=H733Kdjndks81
\ No newline at end of file
+REDIS_PASSWORD=H733Kdjndks81
diff --git a/netbox_secrets/admin.py b/netbox_secrets/admin.py
index 71446a7..981fb5a 100644
--- a/netbox_secrets/admin.py
+++ b/netbox_secrets/admin.py
@@ -48,14 +48,19 @@ def activate_selected(self, request, queryset):
                     return redirect('admin:netbox_secrets_userkey_changelist')
                 else:
                     messages.error(
-                        request, "Invalid private key provided. Unable to retrieve master key.", extra_tags='error'
+                        request,
+                        "Invalid private key provided. Unable to retrieve master key.",
+                        extra_tags='error',
                     )
         else:
-            form = ActivateUserKeyForm(
-                initial={'_selected_action': request.POST.getlist(ACTION_CHECKBOX_NAME)}
-            )
+            form = ActivateUserKeyForm(initial={'_selected_action': request.POST.getlist(ACTION_CHECKBOX_NAME)})
+
+        return render(
+            request,
+            'netbox_secrets/activate_keys.html',
+            {
+                'form': form,
+            },
+        )
 
-        return render(request, 'netbox_secrets/activate_keys.html', {
-            'form': form,
-        })
     activate_selected.short_description = "Activate selected user keys"
diff --git a/netbox_secrets/api/nested_serializers.py b/netbox_secrets/api/nested_serializers.py
index 9ebbc54..61c1091 100644
--- a/netbox_secrets/api/nested_serializers.py
+++ b/netbox_secrets/api/nested_serializers.py
@@ -1,6 +1,6 @@
+from netbox.api.serializers import WritableNestedSerializer
 from rest_framework import serializers
 
-from netbox.api.serializers import WritableNestedSerializer
 from netbox_secrets.models import Secret, SecretRole
 
 __all__ = [
diff --git a/netbox_secrets/api/serializers.py b/netbox_secrets/api/serializers.py
index 83b3b50..d1c8750 100644
--- a/netbox_secrets/api/serializers.py
+++ b/netbox_secrets/api/serializers.py
@@ -1,20 +1,51 @@
 from django.contrib.contenttypes.models import ContentType
 from drf_yasg.utils import swagger_serializer_method
-from rest_framework import serializers
-
 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 .nested_serializers import *
+
 from ..constants import SECRET_ASSIGNABLE_MODELS
-from ..models import Secret, SecretRole
+from ..models import Secret, SecretRole, UserKey
+from .nested_serializers import *
+
+#
+# User Key
+#
+
+
+class UserKeySerializer(serializers.ModelSerializer):
+    public_key = serializers.CharField()
+    private_key = serializers.CharField(
+        write_only=True,
+    )
+
+    display = serializers.SerializerMethodField(read_only=True)
+
+    class Meta:
+        model = UserKey
+        fields = [
+            'pk',
+            'id',
+            'display',
+            'public_key',
+            'private_key',
+            'created',
+            'last_updated',
+            'is_active',
+            'is_filled',
+        ]
+
+    def get_display(self, obj):
+        return str(obj)
 
 
 #
 # Secret Roles
 #
 
+
 class SecretRoleSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='plugins-api:netbox_secrets-api:secretrole-detail')
     secret_count = serializers.IntegerField(read_only=True)
@@ -22,7 +53,15 @@ class SecretRoleSerializer(NetBoxModelSerializer):
     class Meta:
         model = SecretRole
         fields = [
-            'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated',
+            'id',
+            'url',
+            'display',
+            'name',
+            'slug',
+            'description',
+            'custom_fields',
+            'created',
+            'last_updated',
             'secret_count',
         ]
 
@@ -31,11 +70,10 @@ class Meta:
 # Secrets
 #
 
+
 class SecretSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='plugins-api:netbox_secrets-api:secret-detail')
-    assigned_object_type = ContentTypeField(
-        queryset=ContentType.objects.filter(SECRET_ASSIGNABLE_MODELS)
-    )
+    assigned_object_type = ContentTypeField(queryset=ContentType.objects.filter(SECRET_ASSIGNABLE_MODELS))
     assigned_object = serializers.SerializerMethodField(read_only=True)
     role = NestedSecretRoleSerializer()
     plaintext = serializers.CharField()
@@ -43,8 +81,20 @@ class SecretSerializer(NetBoxModelSerializer):
     class Meta:
         model = Secret
         fields = [
-            'id', 'url', 'display', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'role', 'name',
-            'plaintext', 'hash', 'tags', 'custom_fields', 'created', 'last_updated',
+            'id',
+            'url',
+            'display',
+            'assigned_object_type',
+            'assigned_object_id',
+            'assigned_object',
+            'role',
+            'name',
+            'plaintext',
+            'hash',
+            'tags',
+            'custom_fields',
+            'created',
+            'last_updated',
         ]
         validators = []
 
diff --git a/netbox_secrets/api/urls.py b/netbox_secrets/api/urls.py
index 9671e5f..f86662d 100644
--- a/netbox_secrets/api/urls.py
+++ b/netbox_secrets/api/urls.py
@@ -1,10 +1,13 @@
 from netbox.api.routers import NetBoxRouter
-from . import views
 
+from . import views
 
 router = NetBoxRouter()
 router.APIRootView = views.SecretsRootView
 
+# User Key
+router.register('user-keys', views.UserKeyViewSet)
+
 # Secrets
 router.register('secret-roles', views.SecretRoleViewSet)
 router.register('secrets', views.SecretViewSet)
diff --git a/netbox_secrets/api/views.py b/netbox_secrets/api/views.py
index 65a20a2..6d7d103 100644
--- a/netbox_secrets/api/views.py
+++ b/netbox_secrets/api/views.py
@@ -5,17 +5,18 @@
 from django.http import HttpResponseBadRequest
 from drf_yasg import openapi
 from drf_yasg.utils import swagger_auto_schema
+from netbox.api.viewsets import NetBoxModelViewSet
 from rest_framework.exceptions import ValidationError
 from rest_framework.permissions import IsAuthenticated
 from rest_framework.response import Response
 from rest_framework.routers import APIRootView
-from rest_framework.viewsets import ViewSet
+from rest_framework.viewsets import ModelViewSet, ViewSet
+from utilities.utils import count_related
 
-from netbox.api.viewsets import NetBoxModelViewSet
 from netbox_secrets import filtersets
 from netbox_secrets.exceptions import InvalidKey
 from netbox_secrets.models import Secret, SecretRole, SessionKey, UserKey
-from utilities.utils import count_related
+
 from . import serializers
 
 plugin_settings = settings.PLUGINS_CONFIG.get('netbox_secrets', {})
@@ -36,14 +37,24 @@ def get_view_name(self):
         return 'Secrets'
 
 
+#
+# User Key
+#
+class UserKeyViewSet(ModelViewSet):
+    queryset = UserKey.objects.all()
+    serializer_class = serializers.UserKeySerializer
+
+    def get_queryset(self):
+        return UserKey.objects.filter(user=self.request.user)
+
+
 #
 # Secret Roles
 #
 
+
 class SecretRoleViewSet(NetBoxModelViewSet):
-    queryset = SecretRole.objects.annotate(
-        secret_count=count_related(Secret, 'role')
-    ).prefetch_related('tags')
+    queryset = SecretRole.objects.annotate(secret_count=count_related(Secret, 'role')).prefetch_related('tags')
     serializer_class = serializers.SecretRoleSerializer
     filterset_class = filtersets.SecretRoleFilterSet
 
@@ -52,6 +63,7 @@ class SecretRoleViewSet(NetBoxModelViewSet):
 # Secrets
 #
 
+
 class SecretViewSet(NetBoxModelViewSet):
     queryset = Secret.objects.prefetch_related('role', 'tags')
     serializer_class = serializers.SecretSerializer
@@ -136,6 +148,7 @@ class GetSessionKeyViewSet(ViewSet):
     This endpoint accepts one optional parameter: `preserve_key`. If True and a session key exists, the existing session
     key will be returned instead of a new one.
     """
+
     permission_classes = [IsAuthenticated]
 
     @swagger_auto_schema(
@@ -150,8 +163,8 @@ class GetSessionKeyViewSet(ViewSet):
                 'preserve_key': openapi.Schema(
                     type=openapi.TYPE_BOOLEAN,
                     description='Preserve existing session key',
-                    default=False
-                )
+                    default=False,
+                ),
             },
         ),
     )
@@ -197,9 +210,11 @@ def create(self, request):
         encoded_key = base64.b64encode(key).decode()
 
         # Craft the response
-        response = Response({
-            'session_key': encoded_key,
-        })
+        response = Response(
+            {
+                'session_key': encoded_key,
+            },
+        )
 
         # If token authentication is not in use, assign the session key as a cookie
         if request.auth is None:
@@ -217,6 +232,7 @@ class GenerateRSAKeyPairViewSet(ViewSet):
             "private_key": "<private key>"
         }
     """
+
     permission_classes = [IsAuthenticated]
 
     @swagger_auto_schema(
@@ -226,7 +242,7 @@ class GenerateRSAKeyPairViewSet(ViewSet):
                 in_=openapi.IN_QUERY,
                 type=openapi.TYPE_INTEGER,
                 description='Number of bits in the key',
-                default=public_key_size
+                default=public_key_size,
             ),
         ],
         responses={
@@ -241,10 +257,10 @@ class GenerateRSAKeyPairViewSet(ViewSet):
                     'private_key': openapi.Schema(
                         type=openapi.TYPE_STRING,
                         description='Private RSA key',
-                    )
+                    ),
                 },
-            )
-        }
+            ),
+        },
     )
     def list(self, request):
 
@@ -263,7 +279,9 @@ def list(self, request):
         private_key = key.exportKey('PEM')
         public_key = key.publickey().exportKey('PEM')
 
-        return Response({
-            'private_key': private_key,
-            'public_key': public_key,
-        })
+        return Response(
+            {
+                'private_key': private_key,
+                'public_key': public_key,
+            },
+        )
diff --git a/netbox_secrets/exceptions.py b/netbox_secrets/exceptions.py
index c163e59..12d5145 100644
--- a/netbox_secrets/exceptions.py
+++ b/netbox_secrets/exceptions.py
@@ -2,4 +2,5 @@ class InvalidKey(Exception):
     """
     Raised when a provided key is invalid.
     """
+
     pass
diff --git a/netbox_secrets/filtersets.py b/netbox_secrets/filtersets.py
index 5edffb6..bc6ba56 100644
--- a/netbox_secrets/filtersets.py
+++ b/netbox_secrets/filtersets.py
@@ -9,10 +9,10 @@
 from .constants import SECRET_ASSIGNABLE_MODELS
 from .models import Secret, SecretRole
 
-__all__ = (
+__all__ = [
     'SecretFilterSet',
     'SecretRoleFilterSet',
-)
+]
 
 plugin_settings = settings.PLUGINS_CONFIG['netbox_secrets']
 
@@ -22,10 +22,7 @@ class SecretRoleFilterSet(NetBoxModelFilterSet):
         method='search',
         label='Search',
     )
-    name = django_filters.ModelMultipleChoiceFilter(
-        queryset=SecretRole.objects.all(),
-        field_name='name'
-    )
+    name = django_filters.ModelMultipleChoiceFilter(queryset=SecretRole.objects.all(), field_name='name')
 
     class Meta:
         model = SecretRole
@@ -34,13 +31,11 @@ class Meta:
     def search(self, queryset, name, value):
         if not value.strip():
             return queryset
-        return queryset.filter(
-            Q(name__icontains=value) |
-            Q(slug__icontains=value)
-        )
+        return queryset.filter(Q(name__icontains=value) | Q(slug__icontains=value))
 
 
 if plugin_settings.get('enable_contacts', False):
+
     class SecretFilterSet(NetBoxModelFilterSet):
         q = django_filters.CharFilter(
             method='search',
@@ -83,10 +78,10 @@ class Meta:
         def search(self, queryset, name, value):
             if not value.strip():
                 return queryset
-            return queryset.filter(
-                Q(name__icontains=value)
-            )
+            return queryset.filter(Q(name__icontains=value))
+
 else:
+
     class SecretFilterSet(NetBoxModelFilterSet):
         q = django_filters.CharFilter(
             method='search',
@@ -123,6 +118,4 @@ class Meta:
         def search(self, queryset, name, value):
             if not value.strip():
                 return queryset
-            return queryset.filter(
-                Q(name__icontains=value)
-            )
+            return queryset.filter(Q(name__icontains=value))
diff --git a/netbox_secrets/forms/__init__.py b/netbox_secrets/forms/__init__.py
index e889185..84f63fc 100644
--- a/netbox_secrets/forms/__init__.py
+++ b/netbox_secrets/forms/__init__.py
@@ -1,6 +1,6 @@
 from .secrets import *
 
-__all__ = (
+__all__ = [
     'validate_rsa_key',
     'SecretRoleForm',
     'SecretRoleBulkEditForm',
@@ -8,5 +8,5 @@
     'SecretForm',
     'SecretFilterForm',
     'UserKeyForm',
-    'ActivateUserKeyForm'
-)
+    'ActivateUserKeyForm',
+]
diff --git a/netbox_secrets/forms/secrets.py b/netbox_secrets/forms/secrets.py
index 392453f..be03dd1 100644
--- a/netbox_secrets/forms/secrets.py
+++ b/netbox_secrets/forms/secrets.py
@@ -3,12 +3,22 @@
 from django import forms
 from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext as _
+from netbox.forms import (
+    NetBoxModelBulkEditForm,
+    NetBoxModelFilterSetForm,
+    NetBoxModelForm,
+    NetBoxModelImportForm,
+)
+from utilities.forms import (
+    ContentTypeMultipleChoiceField,
+    DynamicModelChoiceField,
+    DynamicModelMultipleChoiceField,
+    SlugField,
+    SmallTextarea,
+)
 
-from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm, NetBoxModelImportForm
 from netbox_secrets.constants import *
 from netbox_secrets.models import Secret, SecretRole, UserKey
-from utilities.forms import ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, \
-    SlugField
 
 
 def validate_rsa_key(key, is_secret=True):
@@ -17,13 +27,14 @@ def validate_rsa_key(key, is_secret=True):
     """
     if key.startswith('ssh-rsa '):
         raise forms.ValidationError(
-            "OpenSSH line format is not supported. Please ensure that your public is in PEM (base64) format.")
+            "OpenSSH line format is not supported. Please ensure that your public is in PEM (base64) format.",
+        )
     try:
         key = RSA.importKey(key)
     except ValueError:
         raise forms.ValidationError("Invalid RSA key. Please ensure that your key is in PEM (base64) format.")
     except Exception as e:
-        raise forms.ValidationError("Invalid key detected: {}".format(e))
+        raise forms.ValidationError(f"Invalid key detected: {e}")
     if is_secret and not key.has_private():
         raise forms.ValidationError("This looks like a public key. Please provide your private RSA key.")
     elif not is_secret and key.has_private():
@@ -38,6 +49,7 @@ def validate_rsa_key(key, is_secret=True):
 # Secret roles
 #
 
+
 class SecretRoleForm(NetBoxModelForm):
     slug = SlugField()
 
@@ -55,14 +67,8 @@ class Meta:
 
 
 class SecretRoleBulkEditForm(NetBoxModelBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=SecretRole.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-    description = forms.CharField(
-        max_length=200,
-        required=False
-    )
+    pk = forms.ModelMultipleChoiceField(queryset=SecretRole.objects.all(), widget=forms.MultipleHiddenInput)
+    description = forms.CharField(max_length=200, required=False)
 
     model = SecretRole
 
@@ -72,20 +78,15 @@ class Meta:
 
 class SecretRoleFilterForm(NetBoxModelFilterSetForm):
     model = SecretRole
-    q = forms.CharField(
-        required=False,
-        label=_('Search')
-    )
-    name = DynamicModelMultipleChoiceField(
-        queryset=SecretRole.objects.all(),
-        required=False
-    )
+    q = forms.CharField(required=False, label=_('Search'))
+    name = DynamicModelMultipleChoiceField(queryset=SecretRole.objects.all(), required=False)
 
 
 #
 # Secrets
 #
 
+
 class SecretForm(NetBoxModelForm):
     plaintext = forms.CharField(
         max_length=SECRET_PLAINTEXT_MAX_LENGTH,
@@ -95,8 +96,8 @@ class SecretForm(NetBoxModelForm):
             attrs={
                 'class': 'requires-session-key',
                 'autocomplete': 'new-password',
-            }
-        )
+            },
+        ),
     )
     plaintext2 = forms.CharField(
         max_length=SECRET_PLAINTEXT_MAX_LENGTH,
@@ -105,17 +106,19 @@ class SecretForm(NetBoxModelForm):
         widget=forms.PasswordInput(
             attrs={
                 'autocomplete': 'new-password',
-            }
-        )
-    )
-    role = DynamicModelChoiceField(
-        queryset=SecretRole.objects.all()
+            },
+        ),
     )
+    role = DynamicModelChoiceField(queryset=SecretRole.objects.all())
 
     class Meta:
         model = Secret
         fields = (
-            'role', 'name', 'plaintext', 'plaintext2', 'tags',
+            'role',
+            'name',
+            'plaintext',
+            'plaintext2',
+            'tags',
         )
 
     def __init__(self, *args, **kwargs):
@@ -130,40 +133,41 @@ def clean(self):
 
         # Verify that the provided plaintext values match
         if self.cleaned_data['plaintext'] != self.cleaned_data['plaintext2']:
-            raise forms.ValidationError({
-                'plaintext2': "The two given plaintext values do not match. Please check your input."
-            })
+            raise forms.ValidationError(
+                {'plaintext2': "The two given plaintext values do not match. Please check your input."},
+            )
 
 
 class SecretFilterForm(NetBoxModelFilterSetForm):
     model = Secret
-    q = forms.CharField(
-        required=False,
-        label=_('Search')
-    )
+    q = forms.CharField(required=False, label=_('Search'))
     assigned_object_type_id = ContentTypeMultipleChoiceField(
         queryset=ContentType.objects.filter(SECRET_ASSIGNABLE_MODELS),
         required=False,
-        label='Object type(s)'
-    )
-    role_id = DynamicModelMultipleChoiceField(
-        queryset=SecretRole.objects.all(),
-        required=False,
-        label=_('Role')
+        label='Object type(s)',
     )
+    role_id = DynamicModelMultipleChoiceField(queryset=SecretRole.objects.all(), required=False, label=_('Role'))
 
 
 #
 # UserKeys
 #
 
+
 class UserKeyForm(forms.ModelForm):
+    public_key = forms.CharField(
+        widget=SmallTextarea(
+            attrs={
+                'class': 'form-control',
+            },
+        ),
+        label='Public Key (PEM format)',
+        help_text='Enter your public RSA key. Keep the private one with you; you will need it for decryption. Please note that passphrase-protected keys are not supported.',
+    )
+
     class Meta:
         model = UserKey
         fields = ['public_key']
-        labels = {
-            'public_key': ''
-        }
 
     def clean_public_key(self):
         key = self.cleaned_data['public_key']
@@ -175,15 +179,12 @@ def clean_public_key(self):
 
 
 class ActivateUserKeyForm(forms.Form):
-    _selected_action = forms.ModelMultipleChoiceField(
-        queryset=UserKey.objects.all(),
-        label='User Keys'
-    )
+    _selected_action = forms.ModelMultipleChoiceField(queryset=UserKey.objects.all(), label='User Keys')
     secret_key = forms.CharField(
         widget=forms.Textarea(
             attrs={
                 'class': 'vLargeTextField',
-            }
+            },
         ),
-        label='Your private key'
+        label='Your private key',
     )
diff --git a/netbox_secrets/graphql/schema.py b/netbox_secrets/graphql/schema.py
index 6a90bea..1a00ca3 100644
--- a/netbox_secrets/graphql/schema.py
+++ b/netbox_secrets/graphql/schema.py
@@ -1,6 +1,6 @@
 import graphene
-
 from netbox.graphql.fields import ObjectField, ObjectListField
+
 from .types import *
 
 
diff --git a/netbox_secrets/graphql/types.py b/netbox_secrets/graphql/types.py
index 9e9859b..e4bdd3e 100644
--- a/netbox_secrets/graphql/types.py
+++ b/netbox_secrets/graphql/types.py
@@ -1,14 +1,14 @@
+from netbox.graphql.types import NetBoxObjectType, ObjectType
+
 from netbox_secrets import filtersets, models
-from netbox.graphql.types import ObjectType, NetBoxObjectType
 
-__all__ = (
+__all__ = [
     'SecretRoleType',
     'SecretType',
-)
+]
 
 
 class SecretRoleType(ObjectType):
-
     class Meta:
         model = models.SecretRole
         fields = '__all__'
@@ -16,7 +16,6 @@ class Meta:
 
 
 class SecretType(NetBoxObjectType):
-
     class Meta:
         model = models.Secret
         fields = '__all__'
diff --git a/netbox_secrets/hashers.py b/netbox_secrets/hashers.py
index fc5066f..3956962 100644
--- a/netbox_secrets/hashers.py
+++ b/netbox_secrets/hashers.py
@@ -6,4 +6,5 @@ class SecretValidationHasher(PBKDF2PasswordHasher):
     We're using Django's stock SHA256 hasher with a low iteration count to avoid introducing excessive delay when
     retrieving a large number of Secrets (the plaintext of each Secret is validated against its hash upon decryption).
     """
+
     iterations = 1000
diff --git a/netbox_secrets/migrations/0001_initial.py b/netbox_secrets/migrations/0001_initial.py
index 890271f..775d05c 100644
--- a/netbox_secrets/migrations/0001_initial.py
+++ b/netbox_secrets/migrations/0001_initial.py
@@ -1,10 +1,10 @@
 # Generated by Django 4.0.8 on 2022-12-28 11:35
 
-from django.conf import settings
 import django.core.serializers.json
-from django.db import migrations, models
 import django.db.models.deletion
 import taggit.managers
+from django.conf import settings
+from django.db import migrations, models
 
 
 class Migration(migrations.Migration):
@@ -26,7 +26,15 @@ class Migration(migrations.Migration):
                 ('last_updated', models.DateTimeField(auto_now=True)),
                 ('public_key', models.TextField()),
                 ('master_key_cipher', models.BinaryField(blank=True, max_length=512, null=True)),
-                ('user', models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='user_key', to=settings.AUTH_USER_MODEL)),
+                (
+                    'user',
+                    models.OneToOneField(
+                        editable=False,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name='user_key',
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
             ],
             options={
                 'ordering': ['user__username'],
@@ -39,7 +47,15 @@ class Migration(migrations.Migration):
                 ('cipher', models.BinaryField(max_length=512)),
                 ('hash', models.CharField(editable=False, max_length=128)),
                 ('created', models.DateTimeField(auto_now_add=True)),
-                ('userkey', models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='session_key', to='netbox_secrets.userkey')),
+                (
+                    'userkey',
+                    models.OneToOneField(
+                        editable=False,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name='session_key',
+                        to='netbox_secrets.userkey',
+                    ),
+                ),
             ],
             options={
                 'ordering': ['userkey__user__username'],
@@ -51,7 +67,10 @@ class Migration(migrations.Migration):
                 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
                 ('created', models.DateTimeField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                (
+                    'custom_field_data',
+                    models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
+                ),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('slug', models.SlugField(max_length=100, unique=True)),
                 ('description', models.CharField(blank=True, max_length=200)),
@@ -67,13 +86,30 @@ class Migration(migrations.Migration):
                 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
                 ('created', models.DateTimeField(auto_now_add=True, null=True)),
                 ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                (
+                    'custom_field_data',
+                    models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
+                ),
                 ('assigned_object_id', models.PositiveIntegerField()),
                 ('name', models.CharField(blank=True, max_length=100)),
                 ('ciphertext', models.BinaryField(max_length=65568)),
                 ('hash', models.CharField(editable=False, max_length=128)),
-                ('assigned_object_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='secrets', to='contenttypes.contenttype')),
-                ('role', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='secrets', to='netbox_secrets.secretrole')),
+                (
+                    'assigned_object_type',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.PROTECT,
+                        related_name='secrets',
+                        to='contenttypes.contenttype',
+                    ),
+                ),
+                (
+                    'role',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.PROTECT,
+                        related_name='secrets',
+                        to='netbox_secrets.secretrole',
+                    ),
+                ),
                 ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
             ],
             options={
diff --git a/netbox_secrets/migrations/0002_populate_userkeys.py b/netbox_secrets/migrations/0002_populate_userkeys.py
index 7fe0dd7..be010ef 100644
--- a/netbox_secrets/migrations/0002_populate_userkeys.py
+++ b/netbox_secrets/migrations/0002_populate_userkeys.py
@@ -11,22 +11,22 @@ def populate_userkeys(apps, schema_editor):
     UserKey = apps.get_model('netbox_secrets', 'UserKey')
 
     # Retrieve the necessary data from SecretStore objects
-    userkeys = UserKeyOld.objects.values(
-        'id', 'created', 'last_updated', 'user', 'public_key', 'master_key_cipher'
-    )
+    userkeys = UserKeyOld.objects.values('id', 'created', 'last_updated', 'user', 'public_key', 'master_key_cipher')
 
     # Queue UserKeys to be created
     userkeys_to_create = []
     userkey_count = userkeys.count()
     for i, userkey in enumerate(userkeys, start=1):
-        userkeys_to_create.append(UserKey(
-            id=userkey['id'],
-            created=userkey['created'],
-            last_updated=userkey['last_updated'],
-            user_id=userkey['user'],
-            public_key=userkey['public_key'],
-            master_key_cipher=userkey['master_key_cipher'],
-        ))
+        userkeys_to_create.append(
+            UserKey(
+                id=userkey['id'],
+                created=userkey['created'],
+                last_updated=userkey['last_updated'],
+                user_id=userkey['user'],
+                public_key=userkey['public_key'],
+                master_key_cipher=userkey['master_key_cipher'],
+            ),
+        )
 
     # Bulk create the userkey objects
     UserKey.objects.bulk_create(userkeys_to_create, batch_size=100)
@@ -38,8 +38,5 @@ class Migration(migrations.Migration):
     ]
 
     operations = [
-        migrations.RunPython(
-            code=populate_userkeys,
-            reverse_code=migrations.RunPython.noop
-        ),
+        migrations.RunPython(code=populate_userkeys, reverse_code=migrations.RunPython.noop),
     ]
diff --git a/netbox_secrets/migrations/0003_populate_secretroles.py b/netbox_secrets/migrations/0003_populate_secretroles.py
index e6f42ae..6ab3032 100644
--- a/netbox_secrets/migrations/0003_populate_secretroles.py
+++ b/netbox_secrets/migrations/0003_populate_secretroles.py
@@ -12,22 +12,30 @@ def populate_secretroles(apps, schema_editor):
 
     # Retrieve the necessary data from SecretStore objects
     roles = SecretRoleOld.objects.values(
-        'id', 'name', 'slug', 'description', 'created', 'last_updated', 'custom_field_data'
+        'id',
+        'name',
+        'slug',
+        'description',
+        'created',
+        'last_updated',
+        'custom_field_data',
     )
 
     # Queue SecretRoles to be created
     roles_to_create = []
     role_count = roles.count()
     for i, role in enumerate(roles, start=1):
-        roles_to_create.append(SecretRole(
-            id=role['id'],
-            name=role['name'],
-            slug=role['slug'],
-            description=role['description'],
-            created=role['created'],
-            last_updated=role['last_updated'],
-            custom_field_data=role['custom_field_data'],
-        ))
+        roles_to_create.append(
+            SecretRole(
+                id=role['id'],
+                name=role['name'],
+                slug=role['slug'],
+                description=role['description'],
+                created=role['created'],
+                last_updated=role['last_updated'],
+                custom_field_data=role['custom_field_data'],
+            ),
+        )
 
     # Bulk create the role objects
     SecretRole.objects.bulk_create(roles_to_create, batch_size=100)
@@ -39,8 +47,5 @@ class Migration(migrations.Migration):
     ]
 
     operations = [
-        migrations.RunPython(
-            code=populate_secretroles,
-            reverse_code=migrations.RunPython.noop
-        ),
+        migrations.RunPython(code=populate_secretroles, reverse_code=migrations.RunPython.noop),
     ]
diff --git a/netbox_secrets/migrations/0004_populate_secrets.py b/netbox_secrets/migrations/0004_populate_secrets.py
index 79cb434..6777e7a 100644
--- a/netbox_secrets/migrations/0004_populate_secrets.py
+++ b/netbox_secrets/migrations/0004_populate_secrets.py
@@ -12,26 +12,36 @@ def populate_secrets(apps, schema_editor):
 
     # Retrieve the necessary data from SecretStore objects
     secrets = SecretOld.objects.values(
-        'created', 'last_updated', 'custom_field_data', 'id', 'assigned_object_id', 'name', 'ciphertext', 'hash',
-        'assigned_object_type_id', 'role_id',
+        'created',
+        'last_updated',
+        'custom_field_data',
+        'id',
+        'assigned_object_id',
+        'name',
+        'ciphertext',
+        'hash',
+        'assigned_object_type_id',
+        'role_id',
     )
 
     # Queue Secrets to be created
     secrets_to_create = []
     secret_count = secrets.count()
     for i, secret in enumerate(secrets, start=1):
-        secrets_to_create.append(Secret(
-            created=secret['created'],
-            last_updated=secret['last_updated'],
-            custom_field_data=secret['custom_field_data'],
-            id=secret['id'],
-            assigned_object_id=secret['assigned_object_id'],
-            name=secret['name'],
-            ciphertext=secret['ciphertext'],
-            assigned_object_type_id=secret['assigned_object_type_id'],
-            role_id=secret['role_id'],
-            hash=secret['hash'],
-        ))
+        secrets_to_create.append(
+            Secret(
+                created=secret['created'],
+                last_updated=secret['last_updated'],
+                custom_field_data=secret['custom_field_data'],
+                id=secret['id'],
+                assigned_object_id=secret['assigned_object_id'],
+                name=secret['name'],
+                ciphertext=secret['ciphertext'],
+                assigned_object_type_id=secret['assigned_object_type_id'],
+                role_id=secret['role_id'],
+                hash=secret['hash'],
+            ),
+        )
 
     # Bulk create the secret objects
     Secret.objects.bulk_create(secrets_to_create, batch_size=100)
@@ -42,9 +52,4 @@ class Migration(migrations.Migration):
         ('netbox_secrets', '0003_populate_secretroles'),
     ]
 
-    operations = [
-        migrations.RunPython(
-            code=populate_secrets,
-            reverse_code=migrations.RunPython.noop
-        )
-    ]
+    operations = [migrations.RunPython(code=populate_secrets, reverse_code=migrations.RunPython.noop)]
diff --git a/netbox_secrets/migrations/0005_alter_secret_custom_field_data_and_more.py b/netbox_secrets/migrations/0005_alter_secret_custom_field_data_and_more.py
index 3bce728..2d22a51 100644
--- a/netbox_secrets/migrations/0005_alter_secret_custom_field_data_and_more.py
+++ b/netbox_secrets/migrations/0005_alter_secret_custom_field_data_and_more.py
@@ -1,7 +1,7 @@
 # Generated by Django 4.1.5 on 2023-01-13 11:59
 
-from django.db import migrations, models
 import utilities.json
+from django.db import migrations, models
 
 
 class Migration(migrations.Migration):
diff --git a/netbox_secrets/migrations/0006_alter_userkey_created_alter_userkey_last_updated.py b/netbox_secrets/migrations/0006_alter_userkey_created_alter_userkey_last_updated.py
new file mode 100644
index 0000000..799d023
--- /dev/null
+++ b/netbox_secrets/migrations/0006_alter_userkey_created_alter_userkey_last_updated.py
@@ -0,0 +1,23 @@
+# Generated by Django 4.1.5 on 2023-01-26 14:27
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('netbox_secrets', '0005_alter_secret_custom_field_data_and_more'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='userkey',
+            name='created',
+            field=models.DateTimeField(auto_now_add=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='userkey',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+    ]
diff --git a/netbox_secrets/models/__init__.py b/netbox_secrets/models/__init__.py
index 20ea6a6..f8b0035 100644
--- a/netbox_secrets/models/__init__.py
+++ b/netbox_secrets/models/__init__.py
@@ -1,8 +1,3 @@
 from .secrets import *
 
-__all__ = (
-    'Secret',
-    'SecretRole',
-    'UserKey',
-    'SessionKey'
-)
+__all__ = ['Secret', 'SecretRole', 'UserKey', 'SessionKey']
diff --git a/netbox_secrets/models/secrets.py b/netbox_secrets/models/secrets.py
index 2f82fb8..f8f79e5 100644
--- a/netbox_secrets/models/secrets.py
+++ b/netbox_secrets/models/secrets.py
@@ -4,8 +4,7 @@
 from Crypto.PublicKey import RSA
 from Crypto.Util import strxor
 from django.conf import settings
-from django.utils.translation import gettext_lazy as _
-from django.contrib.auth.hashers import make_password, check_password
+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
@@ -13,54 +12,42 @@
 from django.db import models
 from django.urls import reverse
 from django.utils.encoding import force_bytes
-
-from netbox.models import OrganizationalModel, NetBoxModel
+from netbox.models import NetBoxModel
+from netbox.models.features import ChangeLoggingMixin, WebhooksMixin
 from utilities.querysets import RestrictedQuerySet
+
 from netbox_secrets.exceptions import InvalidKey
 from netbox_secrets.hashers import SecretValidationHasher
 from netbox_secrets.querysets import UserKeyQuerySet
-from netbox_secrets.utils import encrypt_master_key, decrypt_master_key, generate_random_key
+from netbox_secrets.utils import (
+    decrypt_master_key,
+    encrypt_master_key,
+    generate_random_key,
+)
 
-__all__ = (
+__all__ = [
     'Secret',
     'SecretRole',
     'SessionKey',
     'UserKey',
-)
+]
 
 plugin_settings = settings.PLUGINS_CONFIG.get('netbox_secrets', {})
 
 
-class UserKey(models.Model):
+class UserKey(ChangeLoggingMixin, WebhooksMixin):
     """
     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
     matching (private) decryption key.
     """
+
     id = models.BigAutoField(primary_key=True)
-    created = models.DateField(
-        auto_now_add=True
-    )
-    last_updated = models.DateTimeField(
-        auto_now=True
-    )
-    user = models.OneToOneField(
-        to=User,
-        on_delete=models.CASCADE,
-        related_name='user_key',
-        editable=False
-    )
+    user = models.OneToOneField(to=User, on_delete=models.CASCADE, related_name='user_key', editable=False)
     public_key = models.TextField(
         verbose_name='RSA public key',
-        help_text=_(
-            'Enter your public RSA key. Keep the private one with you; you will need it for decryption. Please note that passphrase-protected keys are not supported.')
-    )
-    master_key_cipher = models.BinaryField(
-        max_length=512,
-        blank=True,
-        null=True,
-        editable=False
     )
+    master_key_cipher = models.BinaryField(max_length=512, blank=True, null=True, editable=False)
 
     objects = UserKeyQuerySet.as_manager()
 
@@ -86,28 +73,32 @@ def clean(self):
             try:
                 pubkey = RSA.import_key(self.public_key)
             except ValueError:
-                raise ValidationError({
-                    'public_key': "Invalid RSA key format."
-                })
+                raise ValidationError({'public_key': "Invalid RSA key format."})
             except Exception:
-                raise ValidationError("Something went wrong while trying to save your key. Please ensure that you're "
-                                      "uploading a valid RSA public key in PEM format (no SSH/PGP).")
+                raise ValidationError(
+                    "Something went wrong while trying to save your key. Please ensure that you're "
+                    "uploading a valid RSA public key in PEM format (no SSH/PGP).",
+                )
 
             # Validate the public key length
             pubkey_length = pubkey.size_in_bits()
             if pubkey_length < settings.PLUGINS_CONFIG['netbox_secrets']['public_key_size']:
-                raise ValidationError({
-                    'public_key': "Insufficient key length. Keys must be at least {} bits long.".format(
-                        settings.PLUGINS_CONFIG['netbox_secrets']['public_key_size']
-                    )
-                })
+                raise ValidationError(
+                    {
+                        'public_key': "Insufficient key length. Keys must be at least {} bits long.".format(
+                            settings.PLUGINS_CONFIG['netbox_secrets']['public_key_size'],
+                        ),
+                    },
+                )
             # We can't use keys bigger than our master_key_cipher field can hold
             if pubkey_length > 4096:
-                raise ValidationError({
-                    'public_key': "Public key size ({}) is too large. Maximum key size is 4096 bits.".format(
-                        pubkey_length
-                    )
-                })
+                raise ValidationError(
+                    {
+                        'public_key': "Public key size ({}) is too large. Maximum key size is 4096 bits.".format(
+                            pubkey_length,
+                        ),
+                    },
+                )
 
     def save(self, *args, **kwargs):
 
@@ -127,8 +118,10 @@ def delete(self, *args, **kwargs):
         # If Secrets exist and this is the last active UserKey, prevent its deletion. Deleting the last UserKey will
         # result in the master key being destroyed and rendering all Secrets inaccessible.
         if Secret.objects.count() and [uk.pk for uk in UserKey.objects.active()] == [self.pk]:
-            raise Exception("Cannot delete the last active UserKey when Secrets exist! This would render all secrets "
-                            "inaccessible.")
+            raise Exception(
+                "Cannot delete the last active UserKey when Secrets exist! This would render all secrets "
+                "inaccessible.",
+            )
 
         super().delete(*args, **kwargs)
 
@@ -173,24 +166,12 @@ class SessionKey(models.Model):
     """
     A SessionKey stores a User's temporary key to be used for the encryption and decryption of secrets.
     """
+
     id = models.BigAutoField(primary_key=True)
-    userkey = models.OneToOneField(
-        to='UserKey',
-        on_delete=models.CASCADE,
-        related_name='session_key',
-        editable=False
-    )
-    cipher = models.BinaryField(
-        max_length=512,
-        editable=False
-    )
-    hash = models.CharField(
-        max_length=128,
-        editable=False
-    )
-    created = models.DateTimeField(
-        auto_now_add=True
-    )
+    userkey = models.OneToOneField(to='UserKey', on_delete=models.CASCADE, related_name='session_key', editable=False)
+    cipher = models.BinaryField(max_length=512, editable=False)
+    hash = models.CharField(max_length=128, editable=False)
+    created = models.DateTimeField(auto_now_add=True)
 
     key = None
 
@@ -247,14 +228,9 @@ class SecretRole(NetBoxModel):
     A SecretRole represents an arbitrary functional classification of Secrets. For example, a user might define roles
     such as "Login Credentials" or "SNMP Communities."
     """
-    name = models.CharField(
-        max_length=100,
-        unique=True
-    )
-    slug = models.SlugField(
-        max_length=100,
-        unique=True
-    )
+
+    name = models.CharField(max_length=100, unique=True)
+    slug = models.SlugField(max_length=100, unique=True)
     description = models.CharField(
         max_length=200,
         blank=True,
@@ -289,33 +265,21 @@ class Secret(NetBoxModel):
     A Secret can be up to 65,535 bytes (64KB - 1B) in length. Each secret string will be padded with random data to
     a minimum of 64 bytes during encryption in order to protect short strings from ciphertext analysis.
     """
+
     assigned_object_type = models.ForeignKey(
         to=ContentType,
         on_delete=models.PROTECT,
         related_name='secrets',
     )
     assigned_object_id = models.PositiveIntegerField()
-    assigned_object = GenericForeignKey(
-        ct_field='assigned_object_type',
-        fk_field='assigned_object_id'
-    )
-    role = models.ForeignKey(
-        to='SecretRole',
-        on_delete=models.PROTECT,
-        related_name='secrets'
-    )
-    name = models.CharField(
-        max_length=100,
-        blank=True
-    )
+    assigned_object = GenericForeignKey(ct_field='assigned_object_type', fk_field='assigned_object_id')
+    role = models.ForeignKey(to='SecretRole', on_delete=models.PROTECT, related_name='secrets')
+    name = models.CharField(max_length=100, blank=True)
     ciphertext = models.BinaryField(
-        max_length=65568,  # 128-bit IV + 16-bit pad length + 65535B secret + 15B padding
-        editable=False
-    )
-    hash = models.CharField(
-        max_length=128,
-        editable=False
+        max_length=65568,
+        editable=False,  # 128-bit IV + 16-bit pad length + 65535B secret + 15B padding
     )
+    hash = models.CharField(max_length=128, editable=False)
 
     objects = RestrictedQuerySet.as_manager()
 
@@ -381,7 +345,7 @@ def _unpad(self, s):
             plaintext_length = (ord(s[0]) << 8) + ord(s[1])
         else:
             plaintext_length = (s[0] << 8) + s[1]
-        return s[2:plaintext_length + 2].decode('utf8')
+        return s[2 : plaintext_length + 2].decode('utf8')
 
     def encrypt(self, secret_key):
         """
@@ -437,6 +401,7 @@ def validate(self, plaintext):
     def enable_contacts(self):
         return plugin_settings.get('enable_contacts', False)
 
+
 if plugin_settings.get('enable_contacts', False):
     GenericRelation(
         to='tenancy.ContactAssignment',
diff --git a/netbox_secrets/project-static/src/secrets.ts b/netbox_secrets/project-static/src/secrets.ts
index ba7002f..5f79133 100644
--- a/netbox_secrets/project-static/src/secrets.ts
+++ b/netbox_secrets/project-static/src/secrets.ts
@@ -171,11 +171,17 @@ function initLockUnlock() {
 function requestSessionKey(privateKey: string) {
   apiPostForm('/api/plugins/secrets/get-session-key/', {
     private_key: privateKey,
+    preserve: true,
   }).then(res => {
     if (!hasError(res)) {
-      // If the response received was not an error, show the user a success message.
-      const toast = createToast('success', 'Session Key Received', 'You may now unlock secrets.');
-      toast.show();
+      // If the session key has been added from the user key page, reload the page.
+      if (window.location.pathname === '/plugins/secrets/user-key/') {
+        window.location.reload();
+      } else {
+        // If the response received was not an error, show the user a success message.
+        const toast = createToast('success', 'Session Key Received', 'You may now unlock secrets.');
+        toast.show();
+      }
     } else {
       // Otherwise, show the user an error message.
       let message = res.error;
diff --git a/netbox_secrets/querysets.py b/netbox_secrets/querysets.py
index c9732c5..dc262a6 100644
--- a/netbox_secrets/querysets.py
+++ b/netbox_secrets/querysets.py
@@ -2,7 +2,6 @@
 
 
 class UserKeyQuerySet(QuerySet):
-
     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 9bdb8a1..3d2e450 100644
--- a/netbox_secrets/static/netbox_secrets/secrets.js
+++ b/netbox_secrets/static/netbox_secrets/secrets.js
@@ -1,6 +1,6 @@
-(()=>{var R=Object.create;var E=Object.defineProperty;var I=Object.getOwnPropertyDescriptor;var M=Object.getOwnPropertyNames;var P=Object.getPrototypeOf,C=Object.prototype.hasOwnProperty;var B=e=>E(e,"__esModule",{value:!0});var D=(e,r)=>()=>(r||e((r={exports:{}}).exports,r),r.exports);var H=(e,r,o)=>{if(r&&typeof r=="object"||typeof r=="function")for(let t of M(r))!C.call(e,t)&&t!=="default"&&E(e,t,{get:()=>r[t],enumerable:!(o=I(r,t))||o.enumerable});return e},_=e=>H(B(E(e!=null?R(P(e)):{},"default",e&&e.__esModule&&"default"in e?{get:()=>e.default,enumerable:!0}:{value:e,enumerable:!0})),e);var p=(e,r,o)=>new Promise((t,i)=>{var c=a=>{try{s(o.next(a))}catch(l){i(l)}},n=a=>{try{s(o.throw(a))}catch(l){i(l)}},s=a=>a.done?t(a.value):Promise.resolve(a.value).then(c,n);s((o=o.apply(e,r)).next())});var T=D(v=>{"use strict";v.parse=O;v.serialize=F;var q=decodeURIComponent,$=encodeURIComponent,K=/; */,m=/^[\u0009\u0020-\u007e\u0080-\u00ff]+$/;function O(e,r){if(typeof e!="string")throw new TypeError("argument str must be a string");for(var o={},t=r||{},i=e.split(K),c=t.decode||q,n=0;n<i.length;n++){var s=i[n],a=s.indexOf("=");if(!(a<0)){var l=s.substr(0,a).trim(),u=s.substr(++a,s.length).trim();u[0]=='"'&&(u=u.slice(1,-1)),o[l]==null&&(o[l]=U(u,c))}}return o}function F(e,r,o){var t=o||{},i=t.encode||$;if(typeof i!="function")throw new TypeError("option encode is invalid");if(!m.test(e))throw new TypeError("argument name is invalid");var c=i(r);if(c&&!m.test(c))throw new TypeError("argument val is invalid");var n=e+"="+c;if(t.maxAge!=null){var s=t.maxAge-0;if(isNaN(s)||!isFinite(s))throw new TypeError("option maxAge is invalid");n+="; Max-Age="+Math.floor(s)}if(t.domain){if(!m.test(t.domain))throw new TypeError("option domain is invalid");n+="; Domain="+t.domain}if(t.path){if(!m.test(t.path))throw new TypeError("option path is invalid");n+="; Path="+t.path}if(t.expires){if(typeof t.expires.toUTCString!="function")throw new TypeError("option expires is invalid");n+="; Expires="+t.expires.toUTCString()}if(t.httpOnly&&(n+="; HttpOnly"),t.secure&&(n+="; Secure"),t.sameSite){var a=typeof t.sameSite=="string"?t.sameSite.toLowerCase():t.sameSite;switch(a){case!0:n+="; SameSite=Strict";break;case"lax":n+="; SameSite=Lax";break;case"strict":n+="; SameSite=Strict";break;case"none":n+="; SameSite=None";break;default:throw new TypeError("option sameSite is invalid")}}return n}function U(e,r){try{return r(e)}catch(o){return e}}});function d(e,r,o,t){let i="mdi-alert";switch(e){case"warning":i="mdi-alert";break;case"success":i="mdi-check-circle";break;case"info":i="mdi-information";break;case"danger":i="mdi-alert";break}let c=document.createElement("div");c.setAttribute("class","toast-container position-fixed bottom-0 end-0 m-3");let n=document.createElement("div");n.setAttribute("class",`toast bg-${e}`),n.setAttribute("role","alert"),n.setAttribute("aria-live","assertive"),n.setAttribute("aria-atomic","true");let s=document.createElement("div");s.setAttribute("class",`toast-header bg-${e} text-body`);let a=document.createElement("i");a.setAttribute("class",`mdi ${i}`);let l=document.createElement("strong");l.setAttribute("class","me-auto ms-1"),l.innerText=r;let u=document.createElement("button");u.setAttribute("type","button"),u.setAttribute("class","btn-close"),u.setAttribute("data-bs-dismiss","toast"),u.setAttribute("aria-label","Close");let y=document.createElement("div");if(y.setAttribute("class","toast-body"),s.appendChild(a),s.appendChild(l),typeof t!="undefined"){let w=document.createElement("small");w.setAttribute("class","text-muted"),s.appendChild(w)}return s.appendChild(u),y.innerText=o.trim(),n.appendChild(s),n.appendChild(y),c.appendChild(n),document.body.appendChild(c),new window.Toast(n)}var h=_(T());function S(e){return"error"in e&&"exception"in e}function f(e){return"error"in e}function k(e){return"value"in e&&"required"in e}function j(){let{csrftoken:e}=h.default.parse(document.cookie);if(typeof e=="undefined")throw new Error("Invalid or missing CSRF token");return e}function A(e,r,o){return p(this,null,function*(){let t=j(),i=new Headers({"X-CSRFToken":t}),c;typeof o!="undefined"&&(c=JSON.stringify(o),i.set("content-type","application/json"),i.set("Accept","application/json"));let n=yield fetch(e,{method:r,body:c,headers:i,credentials:"same-origin"}),s=n.headers.get("Content-Type");if(typeof s=="string"&&s.includes("text"))return{error:yield n.text()};let a=yield n.json();return!n.ok&&Array.isArray(a)?{error:a.join(`
-`)}:!n.ok&&"detail"in a?{error:a.detail}:a})}function x(e,r){return p(this,null,function*(){return yield A(e,"POST",r)})}function g(e){return p(this,null,function*(){return yield A(e,"GET")})}function G(){let e=document.getElementById("new_keypair_modal"),r=document.getElementById("use_new_pubkey");if(e===null||r===null)return;let o=e.querySelector("textarea#new_pubkey"),t=e.querySelector("textarea#new_privkey");function i(){for(let n of[o,t])n!==null&&n.setAttribute("readonly","");g("/api/plugins/secrets/generate-rsa-key-pair/").then(n=>{if(f(n))d("danger","Error",n.error).show();else{let{private_key:s,public_key:a}=n;o!==null&&t!==null&&(o.value=a,t.value=s)}})}function c(){let n=document.getElementById("id_public_key");o!==null&&(n.value=o.value,n.innerText=o.value)}e.addEventListener("shown.bs.modal",()=>i()),r.addEventListener("click",()=>c())}function L(e,r){let o=document.querySelector(`button.unlock-secret[secret-id='${e}']`),t=document.querySelector(`button.lock-secret[secret-id='${e}']`),i=document.querySelector(`button.copy-secret[secret-id='${e}']`);o!==null&&(r==="unlock"&&o.classList.add("d-none"),r==="lock"&&o.classList.remove("d-none")),t!==null&&(r==="unlock"&&t.classList.remove("d-none"),r==="lock"&&t.classList.add("d-none")),i!==null&&(r==="unlock"&&i.classList.remove("d-none"),r==="lock"&&i.classList.add("d-none"))}function N(){let e=new window.Modal("#privkey_modal");function r(t){let i=document.getElementById(`secret_${t}`);typeof t=="string"&&t!==""&&g(`/api/plugins/secrets/secrets/${t}/`).then(c=>{if(f(c))c.error.toLowerCase().includes("invalid session key")?e.show():d("danger","Error",c.error).show();else{let{plaintext:n}=c;i!==null&&n!==null?(k(i)?i.value=n:i.innerText=n,L(t,"unlock")):e.show()}})}function o(t){if(typeof t=="string"&&t!==""){let i=document.getElementById(`secret_${t}`);k(i)?i.value="********":i.innerText="********",L(t,"lock")}}for(let t of document.querySelectorAll("button.unlock-secret"))t.addEventListener("click",()=>r(t.getAttribute("secret-id")));for(let t of document.querySelectorAll("button.lock-secret"))t.addEventListener("click",()=>o(t.getAttribute("secret-id")))}function z(e){x("/api/plugins/secrets/get-session-key/",{private_key:e}).then(r=>{if(!f(r))d("success","Session Key Received","You may now unlock secrets.").show();else{let o=r.error;S(r)&&(o+=`
-${r.exception}`),d("danger","Failed to Retrieve Session Key",o).show()}})}function J(){for(let e of document.querySelectorAll("#request_session_key")){let r=function(){for(let o of document.querySelectorAll("#user_privkey"))z(o.value),o.value=""};e.addEventListener("click",r)}}function X(){let e=new window.Modal("#privkey_modal");function r(o){document.cookie.indexOf("session_key")===-1&&(o.preventDefault(),e.show())}for(let o of document.querySelectorAll(".requires-session-key")){let t=o.closest("form");t!==null&&t.addEventListener("submit",r)}}function b(){for(let e of[G,N,J,X])e()}document.readyState!=="loading"?b():document.addEventListener("DOMContentLoaded",b);})();
+(()=>{var R=Object.create;var E=Object.defineProperty;var I=Object.getOwnPropertyDescriptor;var M=Object.getOwnPropertyNames;var P=Object.getPrototypeOf,C=Object.prototype.hasOwnProperty;var B=e=>E(e,"__esModule",{value:!0});var D=(e,r)=>()=>(r||e((r={exports:{}}).exports,r),r.exports);var H=(e,r,o)=>{if(r&&typeof r=="object"||typeof r=="function")for(let t of M(r))!C.call(e,t)&&t!=="default"&&E(e,t,{get:()=>r[t],enumerable:!(o=I(r,t))||o.enumerable});return e},_=e=>H(B(E(e!=null?R(P(e)):{},"default",e&&e.__esModule&&"default"in e?{get:()=>e.default,enumerable:!0}:{value:e,enumerable:!0})),e);var p=(e,r,o)=>new Promise((t,i)=>{var c=a=>{try{s(o.next(a))}catch(l){i(l)}},n=a=>{try{s(o.throw(a))}catch(l){i(l)}},s=a=>a.done?t(a.value):Promise.resolve(a.value).then(c,n);s((o=o.apply(e,r)).next())});var T=D(v=>{"use strict";v.parse=O;v.serialize=F;var q=decodeURIComponent,$=encodeURIComponent,K=/; */,m=/^[\u0009\u0020-\u007e\u0080-\u00ff]+$/;function O(e,r){if(typeof e!="string")throw new TypeError("argument str must be a string");for(var o={},t=r||{},i=e.split(K),c=t.decode||q,n=0;n<i.length;n++){var s=i[n],a=s.indexOf("=");if(!(a<0)){var l=s.substr(0,a).trim(),u=s.substr(++a,s.length).trim();u[0]=='"'&&(u=u.slice(1,-1)),o[l]==null&&(o[l]=U(u,c))}}return o}function F(e,r,o){var t=o||{},i=t.encode||$;if(typeof i!="function")throw new TypeError("option encode is invalid");if(!m.test(e))throw new TypeError("argument name is invalid");var c=i(r);if(c&&!m.test(c))throw new TypeError("argument val is invalid");var n=e+"="+c;if(t.maxAge!=null){var s=t.maxAge-0;if(isNaN(s)||!isFinite(s))throw new TypeError("option maxAge is invalid");n+="; Max-Age="+Math.floor(s)}if(t.domain){if(!m.test(t.domain))throw new TypeError("option domain is invalid");n+="; Domain="+t.domain}if(t.path){if(!m.test(t.path))throw new TypeError("option path is invalid");n+="; Path="+t.path}if(t.expires){if(typeof t.expires.toUTCString!="function")throw new TypeError("option expires is invalid");n+="; Expires="+t.expires.toUTCString()}if(t.httpOnly&&(n+="; HttpOnly"),t.secure&&(n+="; Secure"),t.sameSite){var a=typeof t.sameSite=="string"?t.sameSite.toLowerCase():t.sameSite;switch(a){case!0:n+="; SameSite=Strict";break;case"lax":n+="; SameSite=Lax";break;case"strict":n+="; SameSite=Strict";break;case"none":n+="; SameSite=None";break;default:throw new TypeError("option sameSite is invalid")}}return n}function U(e,r){try{return r(e)}catch(o){return e}}});function d(e,r,o,t){let i="mdi-alert";switch(e){case"warning":i="mdi-alert";break;case"success":i="mdi-check-circle";break;case"info":i="mdi-information";break;case"danger":i="mdi-alert";break}let c=document.createElement("div");c.setAttribute("class","toast-container position-fixed bottom-0 end-0 m-3");let n=document.createElement("div");n.setAttribute("class",`toast bg-${e}`),n.setAttribute("role","alert"),n.setAttribute("aria-live","assertive"),n.setAttribute("aria-atomic","true");let s=document.createElement("div");s.setAttribute("class",`toast-header bg-${e} text-body`);let a=document.createElement("i");a.setAttribute("class",`mdi ${i}`);let l=document.createElement("strong");l.setAttribute("class","me-auto ms-1"),l.innerText=r;let u=document.createElement("button");u.setAttribute("type","button"),u.setAttribute("class","btn-close"),u.setAttribute("data-bs-dismiss","toast"),u.setAttribute("aria-label","Close");let y=document.createElement("div");if(y.setAttribute("class","toast-body"),s.appendChild(a),s.appendChild(l),typeof t!="undefined"){let b=document.createElement("small");b.setAttribute("class","text-muted"),s.appendChild(b)}return s.appendChild(u),y.innerText=o.trim(),n.appendChild(s),n.appendChild(y),c.appendChild(n),document.body.appendChild(c),new window.Toast(n)}var h=_(T());function S(e){return"error"in e&&"exception"in e}function f(e){return"error"in e}function k(e){return"value"in e&&"required"in e}function j(){let{csrftoken:e}=h.default.parse(document.cookie);if(typeof e=="undefined")throw new Error("Invalid or missing CSRF token");return e}function A(e,r,o){return p(this,null,function*(){let t=j(),i=new Headers({"X-CSRFToken":t}),c;typeof o!="undefined"&&(c=JSON.stringify(o),i.set("content-type","application/json"),i.set("Accept","application/json"));let n=yield fetch(e,{method:r,body:c,headers:i,credentials:"same-origin"}),s=n.headers.get("Content-Type");if(typeof s=="string"&&s.includes("text"))return{error:yield n.text()};let a=yield n.json();return!n.ok&&Array.isArray(a)?{error:a.join(`
+`)}:!n.ok&&"detail"in a?{error:a.detail}:a})}function x(e,r){return p(this,null,function*(){return yield A(e,"POST",r)})}function g(e){return p(this,null,function*(){return yield A(e,"GET")})}function G(){let e=document.getElementById("new_keypair_modal"),r=document.getElementById("use_new_pubkey");if(e===null||r===null)return;let o=e.querySelector("textarea#new_pubkey"),t=e.querySelector("textarea#new_privkey");function i(){for(let n of[o,t])n!==null&&n.setAttribute("readonly","");g("/api/plugins/secrets/generate-rsa-key-pair/").then(n=>{if(f(n))d("danger","Error",n.error).show();else{let{private_key:s,public_key:a}=n;o!==null&&t!==null&&(o.value=a,t.value=s)}})}function c(){let n=document.getElementById("id_public_key");o!==null&&(n.value=o.value,n.innerText=o.value)}e.addEventListener("shown.bs.modal",()=>i()),r.addEventListener("click",()=>c())}function L(e,r){let o=document.querySelector(`button.unlock-secret[secret-id='${e}']`),t=document.querySelector(`button.lock-secret[secret-id='${e}']`),i=document.querySelector(`button.copy-secret[secret-id='${e}']`);o!==null&&(r==="unlock"&&o.classList.add("d-none"),r==="lock"&&o.classList.remove("d-none")),t!==null&&(r==="unlock"&&t.classList.remove("d-none"),r==="lock"&&t.classList.add("d-none")),i!==null&&(r==="unlock"&&i.classList.remove("d-none"),r==="lock"&&i.classList.add("d-none"))}function N(){let e=new window.Modal("#privkey_modal");function r(t){let i=document.getElementById(`secret_${t}`);typeof t=="string"&&t!==""&&g(`/api/plugins/secrets/secrets/${t}/`).then(c=>{if(f(c))c.error.toLowerCase().includes("invalid session key")?e.show():d("danger","Error",c.error).show();else{let{plaintext:n}=c;i!==null&&n!==null?(k(i)?i.value=n:i.innerText=n,L(t,"unlock")):e.show()}})}function o(t){if(typeof t=="string"&&t!==""){let i=document.getElementById(`secret_${t}`);k(i)?i.value="********":i.innerText="********",L(t,"lock")}}for(let t of document.querySelectorAll("button.unlock-secret"))t.addEventListener("click",()=>r(t.getAttribute("secret-id")));for(let t of document.querySelectorAll("button.lock-secret"))t.addEventListener("click",()=>o(t.getAttribute("secret-id")))}function z(e){x("/api/plugins/secrets/get-session-key/",{private_key:e,preserve:!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;S(r)&&(o+=`
+${r.exception}`),d("danger","Failed to Retrieve Session Key",o).show()}})}function J(){for(let e of document.querySelectorAll("#request_session_key")){let r=function(){for(let o of document.querySelectorAll("#user_privkey"))z(o.value),o.value=""};e.addEventListener("click",r)}}function X(){let e=new window.Modal("#privkey_modal");function r(o){document.cookie.indexOf("session_key")===-1&&(o.preventDefault(),e.show())}for(let o of document.querySelectorAll(".requires-session-key")){let t=o.closest("form");t!==null&&t.addEventListener("submit",r)}}function w(){for(let e of[G,N,J,X])e()}document.readyState!=="loading"?w():document.addEventListener("DOMContentLoaded",w);})();
 /*!
  * cookie
  * Copyright(c) 2012-2014 Roman Shtylman
diff --git a/netbox_secrets/static/netbox_secrets/secrets.js.map b/netbox_secrets/static/netbox_secrets/secrets.js.map
index 89bc004..77c5a1d 100644
--- a/netbox_secrets/static/netbox_secrets/secrets.js.map
+++ b/netbox_secrets/static/netbox_secrets/secrets.js.map
@@ -1,7 +1,7 @@
 {
   "version": 3,
   "sources": ["../node_modules/cookie/index.js", "../src/bs.ts", "../src/util.ts", "../src/secrets.ts", "../src/index.ts"],
-  "sourcesContent": ["/*!\n * cookie\n * Copyright(c) 2012-2014 Roman Shtylman\n * Copyright(c) 2015 Douglas Christopher Wilson\n * MIT Licensed\n */\n\n'use strict';\n\n/**\n * Module exports.\n * @public\n */\n\nexports.parse = parse;\nexports.serialize = serialize;\n\n/**\n * Module variables.\n * @private\n */\n\nvar decode = decodeURIComponent;\nvar encode = encodeURIComponent;\nvar pairSplitRegExp = /; */;\n\n/**\n * RegExp to match field-content in RFC 7230 sec 3.2\n *\n * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ]\n * field-vchar   = VCHAR / obs-text\n * obs-text      = %x80-FF\n */\n\nvar fieldContentRegExp = /^[\\u0009\\u0020-\\u007e\\u0080-\\u00ff]+$/;\n\n/**\n * Parse a cookie header.\n *\n * Parse the given cookie header string into an object\n * The object has the various cookies as keys(names) => values\n *\n * @param {string} str\n * @param {object} [options]\n * @return {object}\n * @public\n */\n\nfunction parse(str, options) {\n  if (typeof str !== 'string') {\n    throw new TypeError('argument str must be a string');\n  }\n\n  var obj = {}\n  var opt = options || {};\n  var pairs = str.split(pairSplitRegExp);\n  var dec = opt.decode || decode;\n\n  for (var i = 0; i < pairs.length; i++) {\n    var pair = pairs[i];\n    var eq_idx = pair.indexOf('=');\n\n    // skip things that don't look like key=value\n    if (eq_idx < 0) {\n      continue;\n    }\n\n    var key = pair.substr(0, eq_idx).trim()\n    var val = pair.substr(++eq_idx, pair.length).trim();\n\n    // quoted values\n    if ('\"' == val[0]) {\n      val = val.slice(1, -1);\n    }\n\n    // only assign once\n    if (undefined == obj[key]) {\n      obj[key] = tryDecode(val, dec);\n    }\n  }\n\n  return obj;\n}\n\n/**\n * Serialize data into a cookie header.\n *\n * Serialize the a name value pair into a cookie string suitable for\n * http headers. An optional options object specified cookie parameters.\n *\n * serialize('foo', 'bar', { httpOnly: true })\n *   => \"foo=bar; httpOnly\"\n *\n * @param {string} name\n * @param {string} val\n * @param {object} [options]\n * @return {string}\n * @public\n */\n\nfunction serialize(name, val, options) {\n  var opt = options || {};\n  var enc = opt.encode || encode;\n\n  if (typeof enc !== 'function') {\n    throw new TypeError('option encode is invalid');\n  }\n\n  if (!fieldContentRegExp.test(name)) {\n    throw new TypeError('argument name is invalid');\n  }\n\n  var value = enc(val);\n\n  if (value && !fieldContentRegExp.test(value)) {\n    throw new TypeError('argument val is invalid');\n  }\n\n  var str = name + '=' + value;\n\n  if (null != opt.maxAge) {\n    var maxAge = opt.maxAge - 0;\n\n    if (isNaN(maxAge) || !isFinite(maxAge)) {\n      throw new TypeError('option maxAge is invalid')\n    }\n\n    str += '; Max-Age=' + Math.floor(maxAge);\n  }\n\n  if (opt.domain) {\n    if (!fieldContentRegExp.test(opt.domain)) {\n      throw new TypeError('option domain is invalid');\n    }\n\n    str += '; Domain=' + opt.domain;\n  }\n\n  if (opt.path) {\n    if (!fieldContentRegExp.test(opt.path)) {\n      throw new TypeError('option path is invalid');\n    }\n\n    str += '; Path=' + opt.path;\n  }\n\n  if (opt.expires) {\n    if (typeof opt.expires.toUTCString !== 'function') {\n      throw new TypeError('option expires is invalid');\n    }\n\n    str += '; Expires=' + opt.expires.toUTCString();\n  }\n\n  if (opt.httpOnly) {\n    str += '; HttpOnly';\n  }\n\n  if (opt.secure) {\n    str += '; Secure';\n  }\n\n  if (opt.sameSite) {\n    var sameSite = typeof opt.sameSite === 'string'\n      ? opt.sameSite.toLowerCase() : opt.sameSite;\n\n    switch (sameSite) {\n      case true:\n        str += '; SameSite=Strict';\n        break;\n      case 'lax':\n        str += '; SameSite=Lax';\n        break;\n      case 'strict':\n        str += '; SameSite=Strict';\n        break;\n      case 'none':\n        str += '; SameSite=None';\n        break;\n      default:\n        throw new TypeError('option sameSite is invalid');\n    }\n  }\n\n  return str;\n}\n\n/**\n * Try decoding a string using a decoding function.\n *\n * @param {string} str\n * @param {function} decode\n * @private\n */\n\nfunction tryDecode(str, decode) {\n  try {\n    return decode(str);\n  } catch (e) {\n    return str;\n  }\n}\n", "type ToastLevel = 'danger' | 'warning' | 'success' | 'info';\n\nexport function createToast(\n  level: ToastLevel,\n  title: string,\n  message: string,\n  extra?: string,\n): InstanceType<typeof window.Toast> {\n  let iconName = 'mdi-alert';\n  switch (level) {\n    case 'warning':\n      iconName = 'mdi-alert';\n      break;\n    case 'success':\n      iconName = 'mdi-check-circle';\n      break;\n    case 'info':\n      iconName = 'mdi-information';\n      break;\n    case 'danger':\n      iconName = 'mdi-alert';\n      break;\n  }\n\n  const container = document.createElement('div');\n  container.setAttribute('class', 'toast-container position-fixed bottom-0 end-0 m-3');\n\n  const main = document.createElement('div');\n  main.setAttribute('class', `toast bg-${level}`);\n  main.setAttribute('role', 'alert');\n  main.setAttribute('aria-live', 'assertive');\n  main.setAttribute('aria-atomic', 'true');\n\n  const header = document.createElement('div');\n  header.setAttribute('class', `toast-header bg-${level} text-body`);\n\n  const icon = document.createElement('i');\n  icon.setAttribute('class', `mdi ${iconName}`);\n\n  const titleElement = document.createElement('strong');\n  titleElement.setAttribute('class', 'me-auto ms-1');\n  titleElement.innerText = title;\n\n  const button = document.createElement('button');\n  button.setAttribute('type', 'button');\n  button.setAttribute('class', 'btn-close');\n  button.setAttribute('data-bs-dismiss', 'toast');\n  button.setAttribute('aria-label', 'Close');\n\n  const body = document.createElement('div');\n  body.setAttribute('class', 'toast-body');\n\n  header.appendChild(icon);\n  header.appendChild(titleElement);\n\n  if (typeof extra !== 'undefined') {\n    const extraElement = document.createElement('small');\n    extraElement.setAttribute('class', 'text-muted');\n    header.appendChild(extraElement);\n  }\n\n  header.appendChild(button);\n\n  body.innerText = message.trim();\n\n  main.appendChild(header);\n  main.appendChild(body);\n  container.appendChild(main);\n  document.body.appendChild(container);\n\n  const toast = new window.Toast(main);\n  return toast;\n}\n", "import Cookie from 'cookie';\n\ntype APIRes<T> = T | ErrorBase | APIError;\ntype Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';\ntype ReqData = URLSearchParams | Dict | undefined | unknown;\n\n/**\n * Type guard to determine if an API response is a detailed error.\n *\n * @param data API JSON Response\n * @returns Type guard for `data`.\n */\nexport function isApiError(data: Record<string, unknown>): data is APIError {\n  return 'error' in data && 'exception' in data;\n}\n\n/**\n * Type guard to determine if an API response is an error.\n *\n * @param data API JSON Response\n * @returns Type guard for `data`.\n */\nexport function hasError(data: Record<string, unknown>): data is ErrorBase {\n  return 'error' in data;\n}\n\n/**\n * Type guard to determine if an element is an `HTMLInputElement`.\n *\n * @param element HTML Element.\n */\nexport function isInputElement(element: HTMLElement): element is HTMLInputElement {\n  return 'value' in element && 'required' in element;\n}\n\n/**\n * Retrieve the CSRF token from cookie storage.\n */\nexport function getCsrfToken(): string {\n  const { csrftoken: csrfToken } = Cookie.parse(document.cookie);\n  if (typeof csrfToken === 'undefined') {\n    throw new Error('Invalid or missing CSRF token');\n  }\n  return csrfToken;\n}\n\n/**\n * Authenticate and interact with the NetBox API.\n *\n * @param url Request URL\n * @param method Request Method\n * @param data Data to `POST`, `PATCH`, or `PUT`, if applicable.\n * @returns JSON Response\n */\nexport async function apiRequest<R extends Dict, D extends ReqData = undefined>(\n  url: string,\n  method: Method,\n  data?: D,\n): Promise<APIRes<R>> {\n  const token = getCsrfToken();\n  const headers = new Headers({ 'X-CSRFToken': token });\n\n  let body;\n  if (typeof data !== 'undefined') {\n    body = JSON.stringify(data);\n    headers.set('content-type', 'application/json');\n    headers.set('Accept', 'application/json');\n  }\n\n  const res = await fetch(url, { method, body, headers, credentials: 'same-origin' });\n  const contentType = res.headers.get('Content-Type');\n  if (typeof contentType === 'string' && contentType.includes('text')) {\n    const error = await res.text();\n    return { error } as ErrorBase;\n  }\n  const json = (await res.json()) as R | APIError;\n  if (!res.ok && Array.isArray(json)) {\n    const error = json.join('\\n');\n    return { error } as ErrorBase;\n  } else if (!res.ok && 'detail' in json) {\n    return { error: json.detail } as ErrorBase;\n  }\n  return json;\n}\n\n/**\n * `POST` an object as form data to the NetBox API.\n *\n * @param url Request URL\n * @param data Object to convert to form data\n * @returns JSON Response\n */\nexport async function apiPostForm<R extends Dict, D extends Dict>(\n  url: string,\n  data: D,\n): Promise<APIRes<R>> {\n  return await apiRequest<R, D>(url, 'POST', data);\n}\n\n/**\n * `GET` data from the NetBox API.\n *\n * @param url Request URL\n * @returns JSON Response\n */\nexport async function apiGetBase<R extends Dict>(url: string): Promise<APIRes<R>> {\n  return await apiRequest<R>(url, 'GET');\n}\n", "import { createToast } from './bs';\nimport { apiGetBase, apiPostForm, isApiError, isInputElement, hasError } from './util';\n\nimport type { APISecret, APIKeyPair } from './types';\n\n/**\n * Initialize Generate Private Key Pair Elements.\n */\nfunction initGenerateKeyPair() {\n  const element = document.getElementById('new_keypair_modal') as HTMLDivElement;\n  const accept = document.getElementById('use_new_pubkey') as HTMLButtonElement;\n  // If the elements are not loaded, stop.\n  if (element === null || accept === null) {\n    return;\n  }\n  const publicElem = element.querySelector<HTMLTextAreaElement>('textarea#new_pubkey');\n  const privateElem = element.querySelector<HTMLTextAreaElement>('textarea#new_privkey');\n\n  /**\n   * Handle Generate Private Key Pair Modal opening.\n   */\n  function handleOpen() {\n    // When the modal opens, set the `readonly` attribute on the textarea elements.\n    for (const elem of [publicElem, privateElem]) {\n      if (elem !== null) {\n        elem.setAttribute('readonly', '');\n      }\n    }\n    // Fetch the key pair from the API.\n    apiGetBase<APIKeyPair>('/api/plugins/secrets/generate-rsa-key-pair/').then(data => {\n      if (!hasError(data)) {\n        // If key pair generation was successful, set the textarea elements' value to the generated\n        // values.\n        const { private_key: priv, public_key: pub } = data;\n        if (publicElem !== null && privateElem !== null) {\n          publicElem.value = pub;\n          privateElem.value = priv;\n        }\n      } else {\n        // Otherwise, show an error.\n        const toast = createToast('danger', 'Error', data.error);\n        toast.show();\n      }\n    });\n  }\n\n  /**\n   * Set the public key form field's value to the generated public key.\n   */\n  function handleAccept() {\n    const publicKeyField = document.getElementById('id_public_key') as HTMLTextAreaElement;\n    if (publicElem !== null) {\n      publicKeyField.value = publicElem.value;\n      publicKeyField.innerText = publicElem.value;\n    }\n  }\n  element.addEventListener('shown.bs.modal', () => handleOpen());\n  accept.addEventListener('click', () => handleAccept());\n}\n\n/**\n * Toggle copy/lock/unlock button visibility based on the action occurring.\n * @param id Secret ID.\n * @param action Lock or Unlock, so we know which buttons to display.\n */\nfunction toggleSecretButtons(id: string, action: 'lock' | 'unlock') {\n  const unlockButton = document.querySelector(`button.unlock-secret[secret-id='${id}']`);\n  const lockButton = document.querySelector(`button.lock-secret[secret-id='${id}']`);\n  const copyButton = document.querySelector(`button.copy-secret[secret-id='${id}']`);\n  // If we're unlocking, hide the unlock button. Otherwise, show it.\n  if (unlockButton !== null) {\n    if (action === 'unlock') unlockButton.classList.add('d-none');\n    if (action === 'lock') unlockButton.classList.remove('d-none');\n  }\n  // If we're unlocking, show the lock button. Otherwise, hide it.\n  if (lockButton !== null) {\n    if (action === 'unlock') lockButton.classList.remove('d-none');\n    if (action === 'lock') lockButton.classList.add('d-none');\n  }\n  // If we're unlocking, show the copy button. Otherwise, hide it.\n  if (copyButton !== null) {\n    if (action === 'unlock') copyButton.classList.remove('d-none');\n    if (action === 'lock') copyButton.classList.add('d-none');\n  }\n}\n\n/**\n * Initialize Lock & Unlock button event listeners & callbacks.\n */\nfunction initLockUnlock() {\n  const privateKeyModal = new window.Modal('#privkey_modal');\n\n  /**\n   * Unlock a secret, or prompt the user for their private key, if a session key is not available.\n   *\n   * @param id Secret ID\n   */\n  function unlock(id: string | null) {\n    const target = document.getElementById(`secret_${id}`) as HTMLDivElement | HTMLInputElement;\n    if (typeof id === 'string' && id !== '') {\n      apiGetBase<APISecret>(`/api/plugins/secrets/secrets/${id}/`).then(data => {\n        if (!hasError(data)) {\n          const { plaintext } = data;\n          // `plaintext` is the plain text value of the secret. If it is null, it has not been\n          // decrypted, likely due to a mission session key.\n\n          if (target !== null && plaintext !== null) {\n            // If `plaintext` is not null, we have the decrypted value. Set the target element's\n            // inner text to the decrypted value and toggle copy/lock button visibility.\n            if (isInputElement(target)) {\n              target.value = plaintext;\n            } else {\n              target.innerText = plaintext;\n            }\n\n            toggleSecretButtons(id, 'unlock');\n          } else {\n            // Otherwise, we do _not_ have the decrypted value and need to prompt the user for\n            // their private RSA key, in order to get a session key. The session key is then sent\n            // as a cookie in future requests.\n            privateKeyModal.show();\n          }\n        } else {\n          if (data.error.toLowerCase().includes('invalid session key')) {\n            // If, for some reason, a request was made but resulted in an API error that complains\n            // of a missing session key, prompt the user for their session key.\n            privateKeyModal.show();\n          } else {\n            // If we received an API error but it doesn't contain 'invalid session key', show the\n            // user an error message.\n            const toast = createToast('danger', 'Error', data.error);\n            toast.show();\n          }\n        }\n      });\n    }\n  }\n\n  /**\n   * Lock a secret and toggle visibility of the unlock button.\n   * @param id Secret ID\n   */\n  function lock(id: string | null) {\n    if (typeof id === 'string' && id !== '') {\n      const target = document.getElementById(`secret_${id}`) as HTMLDivElement | HTMLInputElement;\n\n      // Obscure the inner text of the secret element.\n      if (isInputElement(target)) {\n        target.value = '********';\n      } else {\n        target.innerText = '********';\n      }\n\n      // Toggle visibility of the copy/lock/unlock buttons.\n      toggleSecretButtons(id, 'lock');\n    }\n  }\n\n  for (const element of document.querySelectorAll<HTMLButtonElement>('button.unlock-secret')) {\n    element.addEventListener('click', () => unlock(element.getAttribute('secret-id')));\n  }\n  for (const element of document.querySelectorAll<HTMLButtonElement>('button.lock-secret')) {\n    element.addEventListener('click', () => lock(element.getAttribute('secret-id')));\n  }\n}\n\n/**\n * Request a session key from the API.\n * @param privateKey RSA Private Key (valid JSON string)\n */\nfunction requestSessionKey(privateKey: string) {\n  apiPostForm('/api/plugins/secrets/get-session-key/', {\n    private_key: privateKey,\n  }).then(res => {\n    if (!hasError(res)) {\n      // If the response received was not an error, show the user a success message.\n      const toast = createToast('success', 'Session Key Received', 'You may now unlock secrets.');\n      toast.show();\n    } else {\n      // Otherwise, show the user an error message.\n      let message = res.error;\n      if (isApiError(res)) {\n        // If the error received was a standard API error containing a Python exception message,\n        // append it to the error.\n        message += `\\n${res.exception}`;\n      }\n      const toast = createToast('danger', 'Failed to Retrieve Session Key', message);\n      toast.show();\n    }\n  });\n}\n\n/**\n * Initialize Request Session Key Elements.\n */\nfunction initGetSessionKey() {\n  for (const element of document.querySelectorAll<HTMLButtonElement>('#request_session_key')) {\n    /**\n     * Send the user's input private key to the API to get a session key, which will be stored as\n     * a cookie for future requests.\n     */\n    function handleClick() {\n      for (const pk of document.querySelectorAll<HTMLTextAreaElement>('#user_privkey')) {\n        requestSessionKey(pk.value);\n        // Clear the private key form field value.\n        pk.value = '';\n      }\n    }\n    element.addEventListener('click', handleClick);\n  }\n}\n\n/**\n * Initialize Secret Edit Form Handler.\n */\nfunction initSecretsEdit() {\n  const privateKeyModal = new window.Modal('#privkey_modal');\n\n  /**\n   * Check the cookie store for a `session_key`. If not present, prompt the user to submit their\n   * private key.\n   */\n  function handleSubmit(event: Event): void {\n    if (document.cookie.indexOf('session_key') === -1) {\n      event.preventDefault();\n      privateKeyModal.show();\n    }\n  }\n\n  for (const element of document.querySelectorAll<HTMLInputElement>('.requires-session-key')) {\n    const form = element.closest<HTMLFormElement>('form');\n    if (form !== null) {\n      form.addEventListener('submit', handleSubmit);\n    }\n  }\n}\n\nexport function initSecrets() {\n  for (const func of [initGenerateKeyPair, initLockUnlock, initGetSessionKey, initSecretsEdit]) {\n    func();\n  }\n}\n", "import { initSecrets } from './secrets';\n\nif (document.readyState !== 'loading') {\n  initSecrets();\n} else {\n  document.addEventListener('DOMContentLoaded', initSecrets);\n}\n"],
-  "mappings": "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",
+  "sourcesContent": ["/*!\n * cookie\n * Copyright(c) 2012-2014 Roman Shtylman\n * Copyright(c) 2015 Douglas Christopher Wilson\n * MIT Licensed\n */\n\n'use strict';\n\n/**\n * Module exports.\n * @public\n */\n\nexports.parse = parse;\nexports.serialize = serialize;\n\n/**\n * Module variables.\n * @private\n */\n\nvar decode = decodeURIComponent;\nvar encode = encodeURIComponent;\nvar pairSplitRegExp = /; */;\n\n/**\n * RegExp to match field-content in RFC 7230 sec 3.2\n *\n * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ]\n * field-vchar   = VCHAR / obs-text\n * obs-text      = %x80-FF\n */\n\nvar fieldContentRegExp = /^[\\u0009\\u0020-\\u007e\\u0080-\\u00ff]+$/;\n\n/**\n * Parse a cookie header.\n *\n * Parse the given cookie header string into an object\n * The object has the various cookies as keys(names) => values\n *\n * @param {string} str\n * @param {object} [options]\n * @return {object}\n * @public\n */\n\nfunction parse(str, options) {\n  if (typeof str !== 'string') {\n    throw new TypeError('argument str must be a string');\n  }\n\n  var obj = {}\n  var opt = options || {};\n  var pairs = str.split(pairSplitRegExp);\n  var dec = opt.decode || decode;\n\n  for (var i = 0; i < pairs.length; i++) {\n    var pair = pairs[i];\n    var eq_idx = pair.indexOf('=');\n\n    // skip things that don't look like key=value\n    if (eq_idx < 0) {\n      continue;\n    }\n\n    var key = pair.substr(0, eq_idx).trim()\n    var val = pair.substr(++eq_idx, pair.length).trim();\n\n    // quoted values\n    if ('\"' == val[0]) {\n      val = val.slice(1, -1);\n    }\n\n    // only assign once\n    if (undefined == obj[key]) {\n      obj[key] = tryDecode(val, dec);\n    }\n  }\n\n  return obj;\n}\n\n/**\n * Serialize data into a cookie header.\n *\n * Serialize the a name value pair into a cookie string suitable for\n * http headers. An optional options object specified cookie parameters.\n *\n * serialize('foo', 'bar', { httpOnly: true })\n *   => \"foo=bar; httpOnly\"\n *\n * @param {string} name\n * @param {string} val\n * @param {object} [options]\n * @return {string}\n * @public\n */\n\nfunction serialize(name, val, options) {\n  var opt = options || {};\n  var enc = opt.encode || encode;\n\n  if (typeof enc !== 'function') {\n    throw new TypeError('option encode is invalid');\n  }\n\n  if (!fieldContentRegExp.test(name)) {\n    throw new TypeError('argument name is invalid');\n  }\n\n  var value = enc(val);\n\n  if (value && !fieldContentRegExp.test(value)) {\n    throw new TypeError('argument val is invalid');\n  }\n\n  var str = name + '=' + value;\n\n  if (null != opt.maxAge) {\n    var maxAge = opt.maxAge - 0;\n\n    if (isNaN(maxAge) || !isFinite(maxAge)) {\n      throw new TypeError('option maxAge is invalid')\n    }\n\n    str += '; Max-Age=' + Math.floor(maxAge);\n  }\n\n  if (opt.domain) {\n    if (!fieldContentRegExp.test(opt.domain)) {\n      throw new TypeError('option domain is invalid');\n    }\n\n    str += '; Domain=' + opt.domain;\n  }\n\n  if (opt.path) {\n    if (!fieldContentRegExp.test(opt.path)) {\n      throw new TypeError('option path is invalid');\n    }\n\n    str += '; Path=' + opt.path;\n  }\n\n  if (opt.expires) {\n    if (typeof opt.expires.toUTCString !== 'function') {\n      throw new TypeError('option expires is invalid');\n    }\n\n    str += '; Expires=' + opt.expires.toUTCString();\n  }\n\n  if (opt.httpOnly) {\n    str += '; HttpOnly';\n  }\n\n  if (opt.secure) {\n    str += '; Secure';\n  }\n\n  if (opt.sameSite) {\n    var sameSite = typeof opt.sameSite === 'string'\n      ? opt.sameSite.toLowerCase() : opt.sameSite;\n\n    switch (sameSite) {\n      case true:\n        str += '; SameSite=Strict';\n        break;\n      case 'lax':\n        str += '; SameSite=Lax';\n        break;\n      case 'strict':\n        str += '; SameSite=Strict';\n        break;\n      case 'none':\n        str += '; SameSite=None';\n        break;\n      default:\n        throw new TypeError('option sameSite is invalid');\n    }\n  }\n\n  return str;\n}\n\n/**\n * Try decoding a string using a decoding function.\n *\n * @param {string} str\n * @param {function} decode\n * @private\n */\n\nfunction tryDecode(str, decode) {\n  try {\n    return decode(str);\n  } catch (e) {\n    return str;\n  }\n}\n", "type ToastLevel = 'danger' | 'warning' | 'success' | 'info';\n\nexport function createToast(\n  level: ToastLevel,\n  title: string,\n  message: string,\n  extra?: string,\n): InstanceType<typeof window.Toast> {\n  let iconName = 'mdi-alert';\n  switch (level) {\n    case 'warning':\n      iconName = 'mdi-alert';\n      break;\n    case 'success':\n      iconName = 'mdi-check-circle';\n      break;\n    case 'info':\n      iconName = 'mdi-information';\n      break;\n    case 'danger':\n      iconName = 'mdi-alert';\n      break;\n  }\n\n  const container = document.createElement('div');\n  container.setAttribute('class', 'toast-container position-fixed bottom-0 end-0 m-3');\n\n  const main = document.createElement('div');\n  main.setAttribute('class', `toast bg-${level}`);\n  main.setAttribute('role', 'alert');\n  main.setAttribute('aria-live', 'assertive');\n  main.setAttribute('aria-atomic', 'true');\n\n  const header = document.createElement('div');\n  header.setAttribute('class', `toast-header bg-${level} text-body`);\n\n  const icon = document.createElement('i');\n  icon.setAttribute('class', `mdi ${iconName}`);\n\n  const titleElement = document.createElement('strong');\n  titleElement.setAttribute('class', 'me-auto ms-1');\n  titleElement.innerText = title;\n\n  const button = document.createElement('button');\n  button.setAttribute('type', 'button');\n  button.setAttribute('class', 'btn-close');\n  button.setAttribute('data-bs-dismiss', 'toast');\n  button.setAttribute('aria-label', 'Close');\n\n  const body = document.createElement('div');\n  body.setAttribute('class', 'toast-body');\n\n  header.appendChild(icon);\n  header.appendChild(titleElement);\n\n  if (typeof extra !== 'undefined') {\n    const extraElement = document.createElement('small');\n    extraElement.setAttribute('class', 'text-muted');\n    header.appendChild(extraElement);\n  }\n\n  header.appendChild(button);\n\n  body.innerText = message.trim();\n\n  main.appendChild(header);\n  main.appendChild(body);\n  container.appendChild(main);\n  document.body.appendChild(container);\n\n  const toast = new window.Toast(main);\n  return toast;\n}\n", "import Cookie from 'cookie';\n\ntype APIRes<T> = T | ErrorBase | APIError;\ntype Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';\ntype ReqData = URLSearchParams | Dict | undefined | unknown;\n\n/**\n * Type guard to determine if an API response is a detailed error.\n *\n * @param data API JSON Response\n * @returns Type guard for `data`.\n */\nexport function isApiError(data: Record<string, unknown>): data is APIError {\n  return 'error' in data && 'exception' in data;\n}\n\n/**\n * Type guard to determine if an API response is an error.\n *\n * @param data API JSON Response\n * @returns Type guard for `data`.\n */\nexport function hasError(data: Record<string, unknown>): data is ErrorBase {\n  return 'error' in data;\n}\n\n/**\n * Type guard to determine if an element is an `HTMLInputElement`.\n *\n * @param element HTML Element.\n */\nexport function isInputElement(element: HTMLElement): element is HTMLInputElement {\n  return 'value' in element && 'required' in element;\n}\n\n/**\n * Retrieve the CSRF token from cookie storage.\n */\nexport function getCsrfToken(): string {\n  const { csrftoken: csrfToken } = Cookie.parse(document.cookie);\n  if (typeof csrfToken === 'undefined') {\n    throw new Error('Invalid or missing CSRF token');\n  }\n  return csrfToken;\n}\n\n/**\n * Authenticate and interact with the NetBox API.\n *\n * @param url Request URL\n * @param method Request Method\n * @param data Data to `POST`, `PATCH`, or `PUT`, if applicable.\n * @returns JSON Response\n */\nexport async function apiRequest<R extends Dict, D extends ReqData = undefined>(\n  url: string,\n  method: Method,\n  data?: D,\n): Promise<APIRes<R>> {\n  const token = getCsrfToken();\n  const headers = new Headers({ 'X-CSRFToken': token });\n\n  let body;\n  if (typeof data !== 'undefined') {\n    body = JSON.stringify(data);\n    headers.set('content-type', 'application/json');\n    headers.set('Accept', 'application/json');\n  }\n\n  const res = await fetch(url, { method, body, headers, credentials: 'same-origin' });\n  const contentType = res.headers.get('Content-Type');\n  if (typeof contentType === 'string' && contentType.includes('text')) {\n    const error = await res.text();\n    return { error } as ErrorBase;\n  }\n  const json = (await res.json()) as R | APIError;\n  if (!res.ok && Array.isArray(json)) {\n    const error = json.join('\\n');\n    return { error } as ErrorBase;\n  } else if (!res.ok && 'detail' in json) {\n    return { error: json.detail } as ErrorBase;\n  }\n  return json;\n}\n\n/**\n * `POST` an object as form data to the NetBox API.\n *\n * @param url Request URL\n * @param data Object to convert to form data\n * @returns JSON Response\n */\nexport async function apiPostForm<R extends Dict, D extends Dict>(\n  url: string,\n  data: D,\n): Promise<APIRes<R>> {\n  return await apiRequest<R, D>(url, 'POST', data);\n}\n\n/**\n * `GET` data from the NetBox API.\n *\n * @param url Request URL\n * @returns JSON Response\n */\nexport async function apiGetBase<R extends Dict>(url: string): Promise<APIRes<R>> {\n  return await apiRequest<R>(url, 'GET');\n}\n", "import { createToast } from './bs';\nimport { apiGetBase, apiPostForm, isApiError, isInputElement, hasError } from './util';\n\nimport type { APISecret, APIKeyPair } from './types';\n\n/**\n * Initialize Generate Private Key Pair Elements.\n */\nfunction initGenerateKeyPair() {\n  const element = document.getElementById('new_keypair_modal') as HTMLDivElement;\n  const accept = document.getElementById('use_new_pubkey') as HTMLButtonElement;\n  // If the elements are not loaded, stop.\n  if (element === null || accept === null) {\n    return;\n  }\n  const publicElem = element.querySelector<HTMLTextAreaElement>('textarea#new_pubkey');\n  const privateElem = element.querySelector<HTMLTextAreaElement>('textarea#new_privkey');\n\n  /**\n   * Handle Generate Private Key Pair Modal opening.\n   */\n  function handleOpen() {\n    // When the modal opens, set the `readonly` attribute on the textarea elements.\n    for (const elem of [publicElem, privateElem]) {\n      if (elem !== null) {\n        elem.setAttribute('readonly', '');\n      }\n    }\n    // Fetch the key pair from the API.\n    apiGetBase<APIKeyPair>('/api/plugins/secrets/generate-rsa-key-pair/').then(data => {\n      if (!hasError(data)) {\n        // If key pair generation was successful, set the textarea elements' value to the generated\n        // values.\n        const { private_key: priv, public_key: pub } = data;\n        if (publicElem !== null && privateElem !== null) {\n          publicElem.value = pub;\n          privateElem.value = priv;\n        }\n      } else {\n        // Otherwise, show an error.\n        const toast = createToast('danger', 'Error', data.error);\n        toast.show();\n      }\n    });\n  }\n\n  /**\n   * Set the public key form field's value to the generated public key.\n   */\n  function handleAccept() {\n    const publicKeyField = document.getElementById('id_public_key') as HTMLTextAreaElement;\n    if (publicElem !== null) {\n      publicKeyField.value = publicElem.value;\n      publicKeyField.innerText = publicElem.value;\n    }\n  }\n  element.addEventListener('shown.bs.modal', () => handleOpen());\n  accept.addEventListener('click', () => handleAccept());\n}\n\n/**\n * Toggle copy/lock/unlock button visibility based on the action occurring.\n * @param id Secret ID.\n * @param action Lock or Unlock, so we know which buttons to display.\n */\nfunction toggleSecretButtons(id: string, action: 'lock' | 'unlock') {\n  const unlockButton = document.querySelector(`button.unlock-secret[secret-id='${id}']`);\n  const lockButton = document.querySelector(`button.lock-secret[secret-id='${id}']`);\n  const copyButton = document.querySelector(`button.copy-secret[secret-id='${id}']`);\n  // If we're unlocking, hide the unlock button. Otherwise, show it.\n  if (unlockButton !== null) {\n    if (action === 'unlock') unlockButton.classList.add('d-none');\n    if (action === 'lock') unlockButton.classList.remove('d-none');\n  }\n  // If we're unlocking, show the lock button. Otherwise, hide it.\n  if (lockButton !== null) {\n    if (action === 'unlock') lockButton.classList.remove('d-none');\n    if (action === 'lock') lockButton.classList.add('d-none');\n  }\n  // If we're unlocking, show the copy button. Otherwise, hide it.\n  if (copyButton !== null) {\n    if (action === 'unlock') copyButton.classList.remove('d-none');\n    if (action === 'lock') copyButton.classList.add('d-none');\n  }\n}\n\n/**\n * Initialize Lock & Unlock button event listeners & callbacks.\n */\nfunction initLockUnlock() {\n  const privateKeyModal = new window.Modal('#privkey_modal');\n\n  /**\n   * Unlock a secret, or prompt the user for their private key, if a session key is not available.\n   *\n   * @param id Secret ID\n   */\n  function unlock(id: string | null) {\n    const target = document.getElementById(`secret_${id}`) as HTMLDivElement | HTMLInputElement;\n    if (typeof id === 'string' && id !== '') {\n      apiGetBase<APISecret>(`/api/plugins/secrets/secrets/${id}/`).then(data => {\n        if (!hasError(data)) {\n          const { plaintext } = data;\n          // `plaintext` is the plain text value of the secret. If it is null, it has not been\n          // decrypted, likely due to a mission session key.\n\n          if (target !== null && plaintext !== null) {\n            // If `plaintext` is not null, we have the decrypted value. Set the target element's\n            // inner text to the decrypted value and toggle copy/lock button visibility.\n            if (isInputElement(target)) {\n              target.value = plaintext;\n            } else {\n              target.innerText = plaintext;\n            }\n\n            toggleSecretButtons(id, 'unlock');\n          } else {\n            // Otherwise, we do _not_ have the decrypted value and need to prompt the user for\n            // their private RSA key, in order to get a session key. The session key is then sent\n            // as a cookie in future requests.\n            privateKeyModal.show();\n          }\n        } else {\n          if (data.error.toLowerCase().includes('invalid session key')) {\n            // If, for some reason, a request was made but resulted in an API error that complains\n            // of a missing session key, prompt the user for their session key.\n            privateKeyModal.show();\n          } else {\n            // If we received an API error but it doesn't contain 'invalid session key', show the\n            // user an error message.\n            const toast = createToast('danger', 'Error', data.error);\n            toast.show();\n          }\n        }\n      });\n    }\n  }\n\n  /**\n   * Lock a secret and toggle visibility of the unlock button.\n   * @param id Secret ID\n   */\n  function lock(id: string | null) {\n    if (typeof id === 'string' && id !== '') {\n      const target = document.getElementById(`secret_${id}`) as HTMLDivElement | HTMLInputElement;\n\n      // Obscure the inner text of the secret element.\n      if (isInputElement(target)) {\n        target.value = '********';\n      } else {\n        target.innerText = '********';\n      }\n\n      // Toggle visibility of the copy/lock/unlock buttons.\n      toggleSecretButtons(id, 'lock');\n    }\n  }\n\n  for (const element of document.querySelectorAll<HTMLButtonElement>('button.unlock-secret')) {\n    element.addEventListener('click', () => unlock(element.getAttribute('secret-id')));\n  }\n  for (const element of document.querySelectorAll<HTMLButtonElement>('button.lock-secret')) {\n    element.addEventListener('click', () => lock(element.getAttribute('secret-id')));\n  }\n}\n\n/**\n * Request a session key from the API.\n * @param privateKey RSA Private Key (valid JSON string)\n */\nfunction requestSessionKey(privateKey: string) {\n  apiPostForm('/api/plugins/secrets/get-session-key/', {\n    private_key: privateKey,\n    preserve: 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 `session_key`. If not present, prompt the user to submit their\n   * private key.\n   */\n  function handleSubmit(event: Event): void {\n    if (document.cookie.indexOf('session_key') === -1) {\n      event.preventDefault();\n      privateKeyModal.show();\n    }\n  }\n\n  for (const element of document.querySelectorAll<HTMLInputElement>('.requires-session-key')) {\n    const form = element.closest<HTMLFormElement>('form');\n    if (form !== null) {\n      form.addEventListener('submit', handleSubmit);\n    }\n  }\n}\n\nexport function initSecrets() {\n  for (const func of [initGenerateKeyPair, initLockUnlock, initGetSessionKey, initSecretsEdit]) {\n    func();\n  }\n}\n", "import { initSecrets } from './secrets';\n\nif (document.readyState !== 'loading') {\n  initSecrets();\n} else {\n  document.addEventListener('DOMContentLoaded', initSecrets);\n}\n"],
+  "mappings": "qyBAAA,YAOA,aAOA,EAAQ,MAAQ,EAChB,EAAQ,UAAY,EAOpB,GAAI,GAAS,mBACT,EAAS,mBACT,EAAkB,MAUlB,EAAqB,wCAczB,WAAe,EAAK,EAAS,CAC3B,GAAI,MAAO,IAAQ,SACjB,KAAM,IAAI,WAAU,iCAQtB,OALI,GAAM,GACN,EAAM,GAAW,GACjB,EAAQ,EAAI,MAAM,GAClB,EAAM,EAAI,QAAU,EAEf,EAAI,EAAG,EAAI,EAAM,OAAQ,IAAK,CACrC,GAAI,GAAO,EAAM,GACb,EAAS,EAAK,QAAQ,KAG1B,GAAI,IAAS,GAIb,IAAI,GAAM,EAAK,OAAO,EAAG,GAAQ,OAC7B,EAAM,EAAK,OAAO,EAAE,EAAQ,EAAK,QAAQ,OAG7C,AAAI,AAAO,EAAI,IAAX,KACF,GAAM,EAAI,MAAM,EAAG,KAIjB,AAAa,EAAI,IAAjB,MACF,GAAI,GAAO,EAAU,EAAK,KAI9B,MAAO,GAmBT,WAAmB,EAAM,EAAK,EAAS,CACrC,GAAI,GAAM,GAAW,GACjB,EAAM,EAAI,QAAU,EAExB,GAAI,MAAO,IAAQ,WACjB,KAAM,IAAI,WAAU,4BAGtB,GAAI,CAAC,EAAmB,KAAK,GAC3B,KAAM,IAAI,WAAU,4BAGtB,GAAI,GAAQ,EAAI,GAEhB,GAAI,GAAS,CAAC,EAAmB,KAAK,GACpC,KAAM,IAAI,WAAU,2BAGtB,GAAI,GAAM,EAAO,IAAM,EAEvB,GAAI,AAAQ,EAAI,QAAZ,KAAoB,CACtB,GAAI,GAAS,EAAI,OAAS,EAE1B,GAAI,MAAM,IAAW,CAAC,SAAS,GAC7B,KAAM,IAAI,WAAU,4BAGtB,GAAO,aAAe,KAAK,MAAM,GAGnC,GAAI,EAAI,OAAQ,CACd,GAAI,CAAC,EAAmB,KAAK,EAAI,QAC/B,KAAM,IAAI,WAAU,4BAGtB,GAAO,YAAc,EAAI,OAG3B,GAAI,EAAI,KAAM,CACZ,GAAI,CAAC,EAAmB,KAAK,EAAI,MAC/B,KAAM,IAAI,WAAU,0BAGtB,GAAO,UAAY,EAAI,KAGzB,GAAI,EAAI,QAAS,CACf,GAAI,MAAO,GAAI,QAAQ,aAAgB,WACrC,KAAM,IAAI,WAAU,6BAGtB,GAAO,aAAe,EAAI,QAAQ,cAWpC,GARI,EAAI,UACN,IAAO,cAGL,EAAI,QACN,IAAO,YAGL,EAAI,SAAU,CAChB,GAAI,GAAW,MAAO,GAAI,UAAa,SACnC,EAAI,SAAS,cAAgB,EAAI,SAErC,OAAQ,OACD,GACH,GAAO,oBACP,UACG,MACH,GAAO,iBACP,UACG,SACH,GAAO,oBACP,UACG,OACH,GAAO,kBACP,cAEA,KAAM,IAAI,WAAU,+BAI1B,MAAO,GAWT,WAAmB,EAAK,EAAQ,CAC9B,GAAI,CACF,MAAO,GAAO,SACP,EAAP,CACA,MAAO,OCrMJ,WACL,EACA,EACA,EACA,EACmC,CACnC,GAAI,GAAW,YACf,OAAQ,OACD,UACH,EAAW,YACX,UACG,UACH,EAAW,mBACX,UACG,OACH,EAAW,kBACX,UACG,SACH,EAAW,YACX,MAGJ,GAAM,GAAY,SAAS,cAAc,OACzC,EAAU,aAAa,QAAS,qDAEhC,GAAM,GAAO,SAAS,cAAc,OACpC,EAAK,aAAa,QAAS,YAAY,KACvC,EAAK,aAAa,OAAQ,SAC1B,EAAK,aAAa,YAAa,aAC/B,EAAK,aAAa,cAAe,QAEjC,GAAM,GAAS,SAAS,cAAc,OACtC,EAAO,aAAa,QAAS,mBAAmB,eAEhD,GAAM,GAAO,SAAS,cAAc,KACpC,EAAK,aAAa,QAAS,OAAO,KAElC,GAAM,GAAe,SAAS,cAAc,UAC5C,EAAa,aAAa,QAAS,gBACnC,EAAa,UAAY,EAEzB,GAAM,GAAS,SAAS,cAAc,UACtC,EAAO,aAAa,OAAQ,UAC5B,EAAO,aAAa,QAAS,aAC7B,EAAO,aAAa,kBAAmB,SACvC,EAAO,aAAa,aAAc,SAElC,GAAM,GAAO,SAAS,cAAc,OAMpC,GALA,EAAK,aAAa,QAAS,cAE3B,EAAO,YAAY,GACnB,EAAO,YAAY,GAEf,MAAO,IAAU,YAAa,CAChC,GAAM,GAAe,SAAS,cAAc,SAC5C,EAAa,aAAa,QAAS,cACnC,EAAO,YAAY,GAGrB,SAAO,YAAY,GAEnB,EAAK,UAAY,EAAQ,OAEzB,EAAK,YAAY,GACjB,EAAK,YAAY,GACjB,EAAU,YAAY,GACtB,SAAS,KAAK,YAAY,GAEZ,GAAI,QAAO,MAAM,GCtEjC,MAAmB,OAYZ,WAAoB,EAAiD,CAC1E,MAAO,SAAW,IAAQ,aAAe,GASpC,WAAkB,EAAkD,CACzE,MAAO,SAAW,GAQb,WAAwB,EAAmD,CAChF,MAAO,SAAW,IAAW,YAAc,GAMtC,YAAgC,CACrC,GAAM,CAAE,UAAW,GAAc,UAAO,MAAM,SAAS,QACvD,GAAI,MAAO,IAAc,YACvB,KAAM,IAAI,OAAM,iCAElB,MAAO,GAWT,WACE,EACA,EACA,EACoB,gCACpB,GAAM,GAAQ,IACR,EAAU,GAAI,SAAQ,CAAE,cAAe,IAEzC,EACJ,AAAI,MAAO,IAAS,aAClB,GAAO,KAAK,UAAU,GACtB,EAAQ,IAAI,eAAgB,oBAC5B,EAAQ,IAAI,SAAU,qBAGxB,GAAM,GAAM,KAAM,OAAM,EAAK,CAAE,SAAQ,OAAM,UAAS,YAAa,gBAC7D,EAAc,EAAI,QAAQ,IAAI,gBACpC,GAAI,MAAO,IAAgB,UAAY,EAAY,SAAS,QAE1D,MAAO,CAAE,MADK,KAAM,GAAI,QAG1B,GAAM,GAAQ,KAAM,GAAI,OACxB,MAAI,CAAC,EAAI,IAAM,MAAM,QAAQ,GAEpB,CAAE,MADK,EAAK,KAAK;AAAA,IAEf,CAAC,EAAI,IAAM,UAAY,GACzB,CAAE,MAAO,EAAK,QAEhB,IAUT,WACE,EACA,EACoB,gCACpB,MAAO,MAAM,GAAiB,EAAK,OAAQ,KAS7C,WAAiD,EAAiC,gCAChF,MAAO,MAAM,GAAc,EAAK,SClGlC,YAA+B,CAC7B,GAAM,GAAU,SAAS,eAAe,qBAClC,EAAS,SAAS,eAAe,kBAEvC,GAAI,IAAY,MAAQ,IAAW,KACjC,OAEF,GAAM,GAAa,EAAQ,cAAmC,uBACxD,EAAc,EAAQ,cAAmC,wBAK/D,YAAsB,CAEpB,OAAW,KAAQ,CAAC,EAAY,GAC9B,AAAI,IAAS,MACX,EAAK,aAAa,WAAY,IAIlC,EAAuB,+CAA+C,KAAK,GAAQ,CACjF,GAAK,EAAS,GAWZ,AADc,EAAY,SAAU,QAAS,EAAK,OAC5C,WAXa,CAGnB,GAAM,CAAE,YAAa,EAAM,WAAY,GAAQ,EAC/C,AAAI,IAAe,MAAQ,IAAgB,MACzC,GAAW,MAAQ,EACnB,EAAY,MAAQ,MAa5B,YAAwB,CACtB,GAAM,GAAiB,SAAS,eAAe,iBAC/C,AAAI,IAAe,MACjB,GAAe,MAAQ,EAAW,MAClC,EAAe,UAAY,EAAW,OAG1C,EAAQ,iBAAiB,iBAAkB,IAAM,KACjD,EAAO,iBAAiB,QAAS,IAAM,KAQzC,WAA6B,EAAY,EAA2B,CAClE,GAAM,GAAe,SAAS,cAAc,mCAAmC,OACzE,EAAa,SAAS,cAAc,iCAAiC,OACrE,EAAa,SAAS,cAAc,iCAAiC,OAE3E,AAAI,IAAiB,MACf,KAAW,UAAU,EAAa,UAAU,IAAI,UAChD,IAAW,QAAQ,EAAa,UAAU,OAAO,WAGnD,IAAe,MACb,KAAW,UAAU,EAAW,UAAU,OAAO,UACjD,IAAW,QAAQ,EAAW,UAAU,IAAI,WAG9C,IAAe,MACb,KAAW,UAAU,EAAW,UAAU,OAAO,UACjD,IAAW,QAAQ,EAAW,UAAU,IAAI,WAOpD,YAA0B,CACxB,GAAM,GAAkB,GAAI,QAAO,MAAM,kBAOzC,WAAgB,EAAmB,CACjC,GAAM,GAAS,SAAS,eAAe,UAAU,KACjD,AAAI,MAAO,IAAO,UAAY,IAAO,IACnC,EAAsB,gCAAgC,MAAO,KAAK,GAAQ,CACxE,GAAK,EAAS,GAsBZ,AAAI,EAAK,MAAM,cAAc,SAAS,uBAGpC,EAAgB,OAKhB,AADc,EAAY,SAAU,QAAS,EAAK,OAC5C,WA9BW,CACnB,GAAM,CAAE,aAAc,EAItB,AAAI,IAAW,MAAQ,IAAc,KAGnC,CAAI,EAAe,GACjB,EAAO,MAAQ,EAEf,EAAO,UAAY,EAGrB,EAAoB,EAAI,WAKxB,EAAgB,UAsB1B,WAAc,EAAmB,CAC/B,GAAI,MAAO,IAAO,UAAY,IAAO,GAAI,CACvC,GAAM,GAAS,SAAS,eAAe,UAAU,KAGjD,AAAI,EAAe,GACjB,EAAO,MAAQ,WAEf,EAAO,UAAY,WAIrB,EAAoB,EAAI,SAI5B,OAAW,KAAW,UAAS,iBAAoC,wBACjE,EAAQ,iBAAiB,QAAS,IAAM,EAAO,EAAQ,aAAa,eAEtE,OAAW,KAAW,UAAS,iBAAoC,sBACjE,EAAQ,iBAAiB,QAAS,IAAM,EAAK,EAAQ,aAAa,eAQtE,WAA2B,EAAoB,CAC7C,EAAY,wCAAyC,CACnD,YAAa,EACb,SAAU,KACT,KAAK,GAAO,CACb,GAAI,CAAC,EAAS,GAEZ,AAAI,OAAO,SAAS,WAAa,6BAC/B,OAAO,SAAS,SAIhB,AADc,EAAY,UAAW,uBAAwB,+BACvD,WAEH,CAEL,GAAI,GAAU,EAAI,MAClB,AAAI,EAAW,IAGb,IAAW;AAAA,EAAK,EAAI,aAGtB,AADc,EAAY,SAAU,iCAAkC,GAChE,UAQZ,YAA6B,CAC3B,OAAW,KAAW,UAAS,iBAAoC,wBAAyB,CAK1F,GAAS,GAAT,UAAuB,CACrB,OAAW,KAAM,UAAS,iBAAsC,iBAC9D,EAAkB,EAAG,OAErB,EAAG,MAAQ,IAGf,EAAQ,iBAAiB,QAAS,IAOtC,YAA2B,CACzB,GAAM,GAAkB,GAAI,QAAO,MAAM,kBAMzC,WAAsB,EAAoB,CACxC,AAAI,SAAS,OAAO,QAAQ,iBAAmB,IAC7C,GAAM,iBACN,EAAgB,QAIpB,OAAW,KAAW,UAAS,iBAAmC,yBAA0B,CAC1F,GAAM,GAAO,EAAQ,QAAyB,QAC9C,AAAI,IAAS,MACX,EAAK,iBAAiB,SAAU,IAK/B,YAAuB,CAC5B,OAAW,KAAQ,CAAC,EAAqB,EAAgB,EAAmB,GAC1E,ICnPJ,AAAI,SAAS,aAAe,UAC1B,IAEA,SAAS,iBAAiB,mBAAoB",
   "names": []
 }
diff --git a/netbox_secrets/tables.py b/netbox_secrets/tables.py
index 642de9d..8290935 100644
--- a/netbox_secrets/tables.py
+++ b/netbox_secrets/tables.py
@@ -1,21 +1,19 @@
 import django_tables2 as tables
-
 from netbox.tables import NetBoxTable, columns
-from .models import Secret, SecretRole
 
+from .models import Secret, SecretRole
 
 #
 # Secret roles
 #
 
+
 class SecretRoleTable(NetBoxTable):
-    name = tables.Column(
-        linkify=True
-    )
+    name = tables.Column(linkify=True)
     secret_count = columns.LinkedCountColumn(
         viewname='plugins:netbox_secrets:secret_list',
         url_params={'role_id': 'pk'},
-        verbose_name='Secrets'
+        verbose_name='Secrets',
     )
 
     class Meta(NetBoxTable.Meta):
@@ -28,22 +26,25 @@ class Meta(NetBoxTable.Meta):
 # Secrets
 #
 
+
 class SecretTable(NetBoxTable):
-    name = tables.Column(
-        linkify=True
-    )
+    name = tables.Column(linkify=True)
     assigned_object_type = columns.ContentTypeColumn(verbose_name='Object type')
     assigned_object = tables.Column(linkify=True, orderable=False, verbose_name='Object')
-    role = tables.Column(
-        linkify=True
-    )
-    tags = columns.TagColumn(
-        url_name='plugins:netbox_secrets:secret_list'
-    )
+    role = tables.Column(linkify=True)
+    tags = columns.TagColumn(url_name='plugins:netbox_secrets:secret_list')
 
     class Meta(NetBoxTable.Meta):
         model = Secret
         fields = (
-            'pk', 'id', 'name', 'assigned_object_type', 'assigned_object', 'role', 'created', 'last_updated', 'tags',
+            'pk',
+            'id',
+            'name',
+            'assigned_object_type',
+            'assigned_object',
+            'role',
+            'created',
+            'last_updated',
+            'tags',
         )
         default_columns = ('pk', 'id', 'name', 'assigned_object_type', 'assigned_object', 'role', 'actions')
diff --git a/netbox_secrets/template_content.py b/netbox_secrets/template_content.py
index ff25ac1..1a0b1af 100644
--- a/netbox_secrets/template_content.py
+++ b/netbox_secrets/template_content.py
@@ -3,8 +3,8 @@
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.db.utils import OperationalError
-
 from extras.plugins import PluginTemplateExtension
+
 from .models import Secret
 
 logger = logging.getLogger(__name__)
@@ -20,11 +20,8 @@ def secrets_panel(self):
     return self.render(
         'netbox_secrets/inc/secrets_panel.html',
         extra_context={
-            'secrets': Secret.objects.filter(
-                assigned_object_type=assigned_object_type,
-                assigned_object_id=obj.id
-            )
-        }
+            'secrets': Secret.objects.filter(assigned_object_type=assigned_object_type, assigned_object_id=obj.id),
+        },
     )
 
 
@@ -40,22 +37,18 @@ def get_display_on(app_model):
 
 # Generate plugin extensions for the defined classes
 try:
-    for content_type in ContentType.objects.all():
-        app_label = content_type.app_label
-        model = content_type.model
-        app_model_name = f'{app_label}.{model}'
-
-        if app_model_name in plugin_settings.get('apps'):
-            klass_name = f'{app_label}_{model}_plugin_template_extension'
-            dynamic_klass = type(
-                klass_name,
-                (PluginTemplateExtension,),
-                {'model': app_model_name, get_display_on(app_model_name): secrets_panel}
-            )
-            template_extensions.append(dynamic_klass)
+    for app_model in plugin_settings.get('apps'):
+        app_label, model = app_model.split('.')
+        klass_name = f'{app_label}_{model}_plugin_template_extension'
+        dynamic_klass = type(
+            klass_name,
+            (PluginTemplateExtension,),
+            {'model': app_model, get_display_on(app_model): secrets_panel},
+        )
+        template_extensions.append(dynamic_klass)
 except OperationalError as e:
     # This happens when the database is not yet ready
     logger.warning(f'Database not ready, skipping plugin extensions: {e}')
 except Exception as e:
     # Unexpected error
-    logger.error(e)
+    raise Exception(f'Unexpected error: {e}')
diff --git a/netbox_secrets/templates/netbox_secrets/inc/secret_actions.html b/netbox_secrets/templates/netbox_secrets/inc/secret_actions.html
index e682502..a0fb857 100644
--- a/netbox_secrets/templates/netbox_secrets/inc/secret_actions.html
+++ b/netbox_secrets/templates/netbox_secrets/inc/secret_actions.html
@@ -22,4 +22,4 @@
            href="{% url 'plugins:netbox_secrets:secret_delete' pk=object.pk %}?return_url={{ request.path }}"><i
                 class="mdi mdi-trash-can-outline" aria-hidden="true"></i></a>
     {% endif %}
-{% endif %}
\ No newline at end of file
+{% endif %}
diff --git a/netbox_secrets/templates/netbox_secrets/inc/secrets_panel.html b/netbox_secrets/templates/netbox_secrets/inc/secrets_panel.html
index d0df6cd..496a306 100644
--- a/netbox_secrets/templates/netbox_secrets/inc/secrets_panel.html
+++ b/netbox_secrets/templates/netbox_secrets/inc/secrets_panel.html
@@ -41,4 +41,4 @@ <h5 class="card-header">Secrets</h5>
         {% endif %}
     </div>
     {% include 'netbox_secrets/inc/private_key_modal.html' %}
-{% endif %}
\ No newline at end of file
+{% endif %}
diff --git a/netbox_secrets/templates/netbox_secrets/inc/view_tab.html b/netbox_secrets/templates/netbox_secrets/inc/view_tab.html
index 3f4bdd3..6f6cf71 100644
--- a/netbox_secrets/templates/netbox_secrets/inc/view_tab.html
+++ b/netbox_secrets/templates/netbox_secrets/inc/view_tab.html
@@ -19,4 +19,4 @@
 {% block modals %}
     {{ block.super }}
     {% table_config_form table %}
-{% endblock modals %}
\ No newline at end of file
+{% endblock modals %}
diff --git a/netbox_secrets/templates/netbox_secrets/secretrole.html b/netbox_secrets/templates/netbox_secrets/secretrole.html
index 343880e..455142c 100644
--- a/netbox_secrets/templates/netbox_secrets/secretrole.html
+++ b/netbox_secrets/templates/netbox_secrets/secretrole.html
@@ -27,7 +27,7 @@ <h5 class="card-header">
             <tr>
               <th scope="row">Secrets</th>
               <td>
-                <a href="{% url 'plugins:netbox_secrets:secret_list' %}?role_id={{ object.pk }}">{{ secrets_table.rows|length }}</a>
+                <a href="{% url 'plugins:netbox_secrets:secret_list' %}?role_id={{ object.pk }}">{{ object.secrets.count }}</a>
               </td>
             </tr>
           </table>
diff --git a/netbox_secrets/templates/netbox_secrets/userkey.html b/netbox_secrets/templates/netbox_secrets/userkey.html
index b5d43d2..080fd2f 100644
--- a/netbox_secrets/templates/netbox_secrets/userkey.html
+++ b/netbox_secrets/templates/netbox_secrets/userkey.html
@@ -1,53 +1,131 @@
-{% extends 'base/layout.html' %}
+{% extends 'generic/object.html' %}
+{% load perms %}
+
+{% block breadcrumbs %}
+{% endblock %}
+
+{% block object_identifier %}
+    {% if object %}
+        {{ block.super }}
+    {% endif %}
+{% endblock %}
 
 {% block title %}User Key{% endblock %}
 
-{% block content %}
+{% block subtitle %}
     {% if object %}
-        <div class="float-end noprint">
-            <a href="{% url 'plugins:netbox_secrets:userkey_edit' %}" class="btn btn-warning">
-                <span class="mdi mdi-pencil" aria-hidden="true"></span>
-                Edit User Key
-            </a>
-        </div>
-        <h4>
-            Your User Key is 
-            {% if object.is_active %}
-                <span class="badge bg-success">Active</span>
-            {% else %}
-                <span class="badge bg-danger">Inactive</span>
+        {{ block.super }}
+    {% endif %}
+{% endblock %}
+
+{% block controls %}
+    <div class="controls">
+        <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>
+                {% endif %}
             {% endif %}
-        </h4>
-        <p>
-            <small class="text-muted">Created {{ object.created }} &middot; Updated <span title="{{ object.last_updated }}">{{ object.last_updated|timesince }}</span> ago</small>
-        </p>
-        {% if not object.is_active %}
-            <div class="alert alert-warning" role="alert">
-                <i class="mdi mdi-alert"></i>
-                Your user key is inactive. Ask an administrator to enable it for you.
+        </div>
+    </div>
+{% endblock %}
+
+{% block tabs %}
+    {% if object %}
+        {{ block.super }}
+    {% endif %}
+{% endblock %}
+
+{% block content %}
+    <div class="row mb-3">
+        <div class="col col-md-6">
+            <div class="card">
+                <h5 class="card-header">Overview</h5>
+                <div class="card-body">
+                    <table class="table table-hover">
+                        <tr>
+                            <th scope="row">Status</th>
+                            <td>
+                                {% if object.is_active %}
+                                    <i class="mdi mdi-check-circle text-success"></i> Active
+                                {% else %}
+                                    <i class="mdi mdi-close-circle text-danger"></i>
+                                    {% 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.
+                                    {% endif %}
+                                {% endif %}
+                            </td>
+                        </tr>
+                        {% if object.is_filled %}
+                            <tr>
+                                <th scope="row">Public Key</th>
+                                <td>
+                                    <pre class="copyable">{{ object.public_key }}</pre>
+                                </td>
+                            </tr>
+                        {% endif %}
+                    </table>
+                    {% if not object %}
+                        <div class="card-footer">
+                            <div class="text-end noprint">
+                                <a class="btn btn-sm btn-primary"
+                                   href="{% url 'plugins:netbox_secrets:userkey_edit' %}">
+                                    <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add User Key
+                                </a>
+                            </div>
+                        </div>
+                    {% endif %}
+                </div>
             </div>
-        {% endif %}
-        <pre class="copyable">{{ object.public_key }}</pre>
-        <hr />
-        {% if object.session_key %}
-            <div class="float-end noprint">
-                <a href="{% url 'plugins:netbox_secrets:sessionkey_delete' %}" class="btn btn-danger">
-                    <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span>
-                    Delete Session Key
-                </a>
+        </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 %}
+                        </div>
+                    </div>
+                </div>
             </div>
-            <h4>Session Key: <span class="badge bg-success">Active</span></h4>
-            <small class="text-muted">Created {{ object.session_key.created }}</small>
-        {% else %}
-            <h4>No Active Session Key</h4>
         {% endif %}
-    {% else %}
-        <p>You don't have a user key on file.</p>
-        <p>
-            <a href="{% url 'plugins:netbox_secrets:userkey_edit' %}" class="btn btn-primary">
-                <span class="mdi mdi-plus-thick" aria-hidden="true"></span>
-                Create a User Key
-            </a>
-        </p>
-    {% endif %}
-{% endblock %}
+    </div>
+{% endblock content %}
diff --git a/netbox_secrets/templates/netbox_secrets/userkey_edit.html b/netbox_secrets/templates/netbox_secrets/userkey_edit.html
index 6432c32..d39426d 100644
--- a/netbox_secrets/templates/netbox_secrets/userkey_edit.html
+++ b/netbox_secrets/templates/netbox_secrets/userkey_edit.html
@@ -4,49 +4,87 @@
 {% load static %}
 
 {% block content-wrapper %}
-    {% 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 %}
-    <form action="." method="post" class="form">
-        {% csrf_token %}
-        <div class="field-group">
-            {% render_field form.public_key %}
-        </div>
-        <div class="row my-3">
-            <div class="col-4 text-start">
-                <button type="button" class="btn btn-info" id="generate_keypair" data-bs-toggle="modal" data-bs-target="#new_keypair_modal">Generate a New Key Pair</button>
-            </div>
-            <div class="col-8 text-end">
-                <a href="{% url 'plugins:netbox_secrets:userkey' %}" class="btn btn-outline-danger">Cancel</a>
-                <button type="submit" name="_update" class="btn btn-primary">Save</button>    
-            </div>
-        </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 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>
                 </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>
+            {% endif %}
+
+            <form action="." method="post" class="form form-object-edit mt-5">
+                {% csrf_token %}
+
+                {% 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 %}
+
+                <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
+                        </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>
-                <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>
+            </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="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>
+                    </div>
                 </div>
             </div>
         </div>
@@ -54,10 +92,10 @@ <h5>New Private Key</h5>
 {% endblock %}
 
 {% block javascript %}
-{# Javascript #}
-<script
-  type="text/javascript"
-  src="{% static 'netbox_secrets/secrets.js' %}"
-  onerror="window.location='{% url 'media_failure' %}?filename=netbox_secrets/secrets.js'">
-</script>
-{% endblock %}
\ No newline at end of file
+    {# Javascript #}
+    <script
+            type="text/javascript"
+            src="{% static 'netbox_secrets/secrets.js' %}"
+            onerror="window.location='{% url 'media_failure' %}?filename=netbox_secrets/secrets.js'">
+    </script>
+{% endblock %}
diff --git a/netbox_secrets/urls.py b/netbox_secrets/urls.py
index 5289197..f0e56d6 100644
--- a/netbox_secrets/urls.py
+++ b/netbox_secrets/urls.py
@@ -1,10 +1,9 @@
 from django.urls import include, path
-
 from utilities.urls import get_model_urls
+
 from . import views
 
 urlpatterns = [
-
     # Secret roles
     path('secret-roles/', views.SecretRoleListView.as_view(), name='secretrole_list'),
     path('secret-roles/add/', views.SecretRoleEditView.as_view(), name='secretrole_add'),
@@ -12,13 +11,11 @@
     path('secret-roles/edit/', views.SecretRoleBulkEditView.as_view(), name='secretrole_bulk_edit'),
     path('secret-roles/delete/', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'),
     path('secret-roles/<int:pk>/', include(get_model_urls('netbox_secrets', 'secretrole'))),
-
     # Secrets
     path('secrets/', views.SecretListView.as_view(), name='secret_list'),
     path('secrets/add/', views.SecretEditView.as_view(), name='secret_add'),
     path('secrets/delete/', views.SecretBulkDeleteView.as_view(), name='secret_bulk_delete'),
     path('secrets/<int:pk>/', 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'),
diff --git a/netbox_secrets/utils/__init__.py b/netbox_secrets/utils/__init__.py
index d3a9773..77667e5 100644
--- a/netbox_secrets/utils/__init__.py
+++ b/netbox_secrets/utils/__init__.py
@@ -1,9 +1,9 @@
 from .crypto import *
 from .helpers import *
 
-__all__ = (
+__all__ = [
     'encrypt_master_key',
     'decrypt_master_key',
     'generate_random_key',
     'get_session_key',
-)
+]
diff --git a/netbox_secrets/utils/crypto.py b/netbox_secrets/utils/crypto.py
index c43a392..b67032b 100644
--- a/netbox_secrets/utils/crypto.py
+++ b/netbox_secrets/utils/crypto.py
@@ -9,7 +9,7 @@ def generate_random_key(bits=256):
     Generate a random encryption key. Sizes is given in bits and must be in increments of 32.
     """
     if bits % 32:
-        raise Exception("Invalid key size ({}). Key sizes must be in increments of 32 bits.".format(bits))
+        raise Exception(f"Invalid key size ({bits}). Key sizes must be in increments of 32 bits.")
     return os.urandom(int(bits / 8))
 
 
diff --git a/netbox_secrets/utils/hashers.py b/netbox_secrets/utils/hashers.py
index fc5066f..3956962 100644
--- a/netbox_secrets/utils/hashers.py
+++ b/netbox_secrets/utils/hashers.py
@@ -6,4 +6,5 @@ class SecretValidationHasher(PBKDF2PasswordHasher):
     We're using Django's stock SHA256 hasher with a low iteration count to avoid introducing excessive delay when
     retrieving a large number of Secrets (the plaintext of each Secret is validated against its hash upon decryption).
     """
+
     iterations = 1000
diff --git a/netbox_secrets/views.py b/netbox_secrets/views.py
index 90443b6..51326c7 100644
--- a/netbox_secrets/views.py
+++ b/netbox_secrets/views.py
@@ -5,25 +5,24 @@
 from django.contrib.contenttypes.models import ContentType
 from django.db import transaction
 from django.shortcuts import get_object_or_404, redirect, render
-from django.urls import reverse
 from django.utils.html import escape
 from django.utils.safestring import mark_safe
 from django.utils.translation import gettext as _
 from django.views.generic.base import View
-
 from extras.signals import clear_webhooks
 from netbox.views import generic
 from utilities.exceptions import AbortRequest, PermissionsViolation
 from utilities.forms import ConfirmationForm, restrict_form_fields
 from utilities.utils import count_related, prepare_cloned_fields
-from utilities.views import ViewTab, register_model_view
-from . import exceptions, filtersets, forms, models, tables, utils
+from utilities.views import GetReturnURLMixin, ViewTab, register_model_view
 
+from . import exceptions, filtersets, forms, models, tables, utils
 
 #
 # Mixins
 #
 
+
 class ObjectChildrenViewMixin(generic.ObjectChildrenView):
     def get_extra_context(self, request, instance):
         return {
@@ -35,10 +34,11 @@ def get_extra_context(self, request, instance):
 # Secret roles
 #
 
+
 class SecretRoleListView(generic.ObjectListView):
-    queryset = models.SecretRole.objects.annotate(
-        secret_count=count_related(models.Secret, 'role')
-    ).prefetch_related('tags')
+    queryset = models.SecretRole.objects.annotate(secret_count=count_related(models.Secret, 'role')).prefetch_related(
+        'tags',
+    )
     table = tables.SecretRoleTable
     filterset = filtersets.SecretRoleFilterSet
     filterset_form = forms.SecretRoleFilterForm
@@ -60,17 +60,19 @@ class SecretRoleSecretView(ObjectChildrenViewMixin):
         label=_('Secrets'),
         badge=lambda obj: models.Secret.objects.filter(role=obj).count(),
         weight=500,
-        hide_if_empty=True
+        hide_if_empty=True,
     )
 
     def get_children(self, request, parent):
         return models.Secret.objects.filter(role=parent)
 
+
 @register_model_view(models.SecretRole, 'edit')
 class SecretRoleEditView(generic.ObjectEditView):
     queryset = models.SecretRole.objects.prefetch_related('tags')
     form = forms.SecretRoleForm
 
+
 @register_model_view(models.SecretRole, 'delete')
 class SecretRoleDeleteView(generic.ObjectDeleteView):
     queryset = models.SecretRole.objects.prefetch_related('tags')
@@ -83,18 +85,18 @@ class SecretRoleBulkImportView(generic.BulkImportView):
 
 
 class SecretRoleBulkEditView(generic.BulkEditView):
-    queryset = models.SecretRole.objects.annotate(
-        secret_count=count_related(models.Secret, 'role')
-    ).prefetch_related('tags')
+    queryset = models.SecretRole.objects.annotate(secret_count=count_related(models.Secret, 'role')).prefetch_related(
+        'tags',
+    )
     filterset = filtersets.SecretRoleFilterSet
     table = tables.SecretRoleTable
     form = forms.SecretRoleBulkEditForm
 
 
 class SecretRoleBulkDeleteView(generic.BulkDeleteView):
-    queryset = models.SecretRole.objects.annotate(
-        secret_count=count_related(models.Secret, 'role')
-    ).prefetch_related('tags')
+    queryset = models.SecretRole.objects.annotate(secret_count=count_related(models.Secret, 'role')).prefetch_related(
+        'tags',
+    )
     table = tables.SecretRoleTable
 
 
@@ -102,6 +104,7 @@ class SecretRoleBulkDeleteView(generic.BulkDeleteView):
 # Secrets
 #
 
+
 class SecretListView(generic.ObjectListView):
     queryset = models.Secret.objects.prefetch_related('role', 'tags')
     filterset = filtersets.SecretFilterSet
@@ -109,10 +112,12 @@ class SecretListView(generic.ObjectListView):
     table = tables.SecretTable
     actions = ('bulk_delete', 'bulk_edit')
 
+
 @register_model_view(models.Secret)
 class SecretView(generic.ObjectView):
     queryset = models.Secret.objects.prefetch_related('role', 'tags')
 
+
 @register_model_view(models.Secret, 'edit')
 class SecretEditView(generic.ObjectEditView):
     queryset = models.Secret.objects.prefetch_related('role', 'tags')
@@ -124,7 +129,8 @@ def alter_object(self, instance, request, url_args, url_kwargs):
             # Assign the assigned_object based on the URL parameters
             content_type = get_object_or_404(ContentType, pk=request.GET.get('assigned_object_type'))
             instance.assigned_object = get_object_or_404(
-                content_type.model_class(), pk=request.GET.get('assigned_object_id')
+                content_type.model_class(),
+                pk=request.GET.get('assigned_object_id'),
             )
 
         return instance
@@ -173,8 +179,10 @@ def post(self, request, *args, **kwargs):
                     # We must have a session key in order to set the plaintext of a Secret
                     if form.cleaned_data['plaintext'] and session_key is None:
                         logger.debug("Unable to proceed: No session key was provided with the request")
-                        form.add_error(None,
-                                       "No session key was provided with the request. Unable to encrypt secret data.")
+                        form.add_error(
+                            None,
+                            "No session key was provided with the request. Unable to encrypt secret data.",
+                        )
 
                     elif form.cleaned_data['plaintext']:
                         master_key = None
@@ -195,19 +203,23 @@ def post(self, request, *args, **kwargs):
 
                     if form.errors:
                         logger.debug("Form validation failed")
-                        return render(request, self.template_name, {
-                            'object': obj,
-                            'form': form,
-                            'return_url': self.get_return_url(request, obj),
-                            **self.get_extra_context(request, obj),
-                        })
+                        return render(
+                            request,
+                            self.template_name,
+                            {
+                                'object': obj,
+                                'form': form,
+                                'return_url': self.get_return_url(request, obj),
+                                **self.get_extra_context(request, obj),
+                            },
+                        )
 
                     obj.save()
                     form.save_m2m()
 
                     msg = '{} {}'.format(
                         'Created' if object_created else 'Modified',
-                        self.queryset.model._meta.verbose_name
+                        self.queryset.model._meta.verbose_name,
                     )
                     logger.info(f"{msg} {obj} (PK: {obj.pk})")
                     if hasattr(obj, 'get_absolute_url'):
@@ -240,12 +252,17 @@ def post(self, request, *args, **kwargs):
         else:
             logger.debug("Form validation failed")
 
-        return render(request, self.template_name, {
-            'object': obj,
-            'form': form,
-            'return_url': self.get_return_url(request, obj),
-            **self.get_extra_context(request, obj),
-        })
+        return render(
+            request,
+            self.template_name,
+            {
+                'object': obj,
+                'form': form,
+                'return_url': self.get_return_url(request, obj),
+                **self.get_extra_context(request, obj),
+            },
+        )
+
 
 @register_model_view(models.Secret, 'delete')
 class SecretDeleteView(generic.ObjectDeleteView):
@@ -267,13 +284,16 @@ def get(self, request):
         except models.UserKey.DoesNotExist:
             userkey = None
 
-        return render(request, self.template_name, {
-            'object': userkey,
-            'active_tab': 'userkey',
-        })
+        return render(
+            request,
+            self.template_name,
+            {
+                'object': userkey,
+            },
+        )
 
 
-class UserKeyEditView(LoginRequiredMixin, View):
+class UserKeyEditView(LoginRequiredMixin, GetReturnURLMixin, View):
     queryset = models.SessionKey.objects.all()
     template_name = 'netbox_secrets/userkey_edit.html'
 
@@ -287,14 +307,18 @@ def dispatch(self, request, *args, **kwargs):
 
     def get(self, request):
         form = forms.UserKeyForm(instance=self.userkey)
-        print(self.template_name)
-        return render(request, self.template_name, {
-            'object': self.userkey,
-            'form': form,
-            'active_tab': 'userkey',
-        })
+        return render(
+            request,
+            self.template_name,
+            {
+                'object': self.userkey,
+                'form': form,
+                'return_url': self.get_return_url(request, self.userkey),
+            },
+        )
 
     def post(self, request):
+        logger = logging.getLogger('netbox.views.ObjectEditView')
         form = forms.UserKeyForm(data=request.POST, instance=self.userkey)
         if form.is_valid():
             uk = form.save(commit=False)
@@ -302,44 +326,20 @@ def post(self, request):
             uk.save()
             messages.success(request, "Your user key has been saved.")
             return redirect('plugins:netbox_secrets:userkey')
+        else:
+            logger.debug("Form validation failed")
+            messages.error(request, "Unable to save your user key.")
 
-        return render(request, self.template_name, {
-            'userkey': self.userkey,
-            'form': form,
-            'active_tab': 'userkey',
-        })
+        return render(
+            request,
+            self.template_name,
+            {
+                'userkey': self.userkey,
+                'form': form,
+            },
+        )
 
 
+@register_model_view(models.SessionKey, 'delete')
 class SessionKeyDeleteView(generic.ObjectDeleteView):
     queryset = models.SessionKey.objects.all()
-
-    def get(self, request):
-        sessionkey = get_object_or_404(models.SessionKey, userkey__user=request.user)
-        form = ConfirmationForm()
-
-        return render(request, 'netbox_secrets/sessionkey_delete.html', {
-            'object': sessionkey,
-            'obj_type': sessionkey._meta.verbose_name,
-            'form': form,
-            'return_url': reverse('plugins:netbox_secrets:userkey'),
-        })
-
-    def post(self, request):
-        sessionkey = get_object_or_404(models.SessionKey, userkey__user=request.user)
-        form = ConfirmationForm(request.POST)
-        if form.is_valid():
-            # Delete session key
-            sessionkey.delete()
-            messages.success(request, "Session key deleted")
-
-            # Delete cookie
-            response = redirect('plugins:netbox_secrets:userkey')
-            response.delete_cookie('session_key')
-
-            return response
-
-        return render(request, 'netbox_secrets/sessionkey_delete.html', {
-            'obj_type': sessionkey._meta.verbose_name,
-            'form': form,
-            'return_url': reverse('plugins:netbox_secrets:userkey'),
-        })
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 310cb6b..13a832d 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -11,4 +11,4 @@ pydocstyle
 pylint
 pylint-django
 wily
-yapf
\ No newline at end of file
+yapf
diff --git a/setup.py b/setup.py
index 9d8f971..4273ac6 100644
--- a/setup.py
+++ b/setup.py
@@ -2,7 +2,7 @@
 
 setup(
     name='netbox-secrets',
-    version='1.7.3',
+    version='1.7.4',
     description='Netbox Secrets',
     long_description='A Secret store for NetBox',
     url='https://github.com/Onemind-Services-LLC/netbox-secrets/',


Test patch:
diff --git a/netbox_secrets/tests/test_api.py b/netbox_secrets/tests/test_api.py
index ff78984..fb01173 100644
--- a/netbox_secrets/tests/test_api.py
+++ b/netbox_secrets/tests/test_api.py
@@ -1,11 +1,12 @@
 import base64
 
+from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from django.urls import reverse
 from rest_framework import status
+from utilities.testing import APITestCase, APIViewTestCases
 
-from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from netbox_secrets.models import Secret, SecretRole, SessionKey, UserKey
-from utilities.testing import APITestCase, APIViewTestCases
+
 from .constants import PRIVATE_KEY, PUBLIC_KEY
 
 
@@ -22,10 +23,9 @@ def test_graphql_list_objects(self):
 
 
 class AppTest(APITestCase):
-
     def test_root(self):
         url = reverse('plugins-api:netbox_secrets-api:api-root')
-        response = self.client.get('{}?format=api'.format(url), **self.header)
+        response = self.client.get(f'{url}?format=api', **self.header)
 
         self.assertEqual(response.status_code, 200)
 
@@ -148,7 +148,6 @@ def prepare_instance(self, instance):
 
 
 class GetSessionKeyTest(APITestCase):
-
     def setUp(self):
         super().setUp()
 
@@ -159,7 +158,7 @@ def setUp(self):
         self.session_key.save(master_key)
 
         self.header = {
-            'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key),
+            'HTTP_AUTHORIZATION': f'Token {self.token.key}',
         }
 
     def test_get_session_key(self):
diff --git a/netbox_secrets/tests/test_filters.py b/netbox_secrets/tests/test_filters.py
index cf543fc..b0a549f 100644
--- a/netbox_secrets/tests/test_filters.py
+++ b/netbox_secrets/tests/test_filters.py
@@ -1,9 +1,9 @@
+from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from django.test import TestCase
+from virtualization.models import Cluster, ClusterType, VirtualMachine
 
-from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from netbox_secrets.filtersets import *
 from netbox_secrets.models import Secret, SecretRole
-from virtualization.models import Cluster, ClusterType, VirtualMachine
 
 
 class SecretRoleTestCase(TestCase):
diff --git a/netbox_secrets/tests/test_form.py b/netbox_secrets/tests/test_form.py
index 378e7d3..56f43db 100644
--- a/netbox_secrets/tests/test_form.py
+++ b/netbox_secrets/tests/test_form.py
@@ -1,19 +1,19 @@
 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
 
 
 class UserKeyFormTestCase(TestCase):
-
     def setUp(self):
         user = create_test_user(
             permissions=[
                 'netbox_secrets.view_secretrole',
                 'netbox_secrets.add_secretrole',
-            ]
+            ],
         )
         self.userkey = UserKey(user=user)
 
diff --git a/netbox_secrets/tests/test_models.py b/netbox_secrets/tests/test_models.py
index 2e8c186..83b14e4 100644
--- a/netbox_secrets/tests/test_models.py
+++ b/netbox_secrets/tests/test_models.py
@@ -7,20 +7,23 @@
 from django.test import TestCase
 
 from netbox_secrets.models import Secret, UserKey
-from netbox_secrets.utils.crypto import decrypt_master_key, encrypt_master_key, generate_random_key
+from netbox_secrets.utils.crypto import (
+    decrypt_master_key,
+    encrypt_master_key,
+    generate_random_key,
+)
 from netbox_secrets.utils.hashers import SecretValidationHasher
 
 
 class UserKeyTestCase(TestCase):
-
     def setUp(self):
         self.TEST_KEYS = {}
         key_size = settings.PLUGINS_CONFIG['netbox_secrets'].get('public_key_size')
         for username in ['alice', 'bob']:
             User.objects.create_user(username=username, password=username)
             key = RSA.generate(key_size)
-            self.TEST_KEYS['{}_public'.format(username)] = key.publickey().exportKey('PEM')
-            self.TEST_KEYS['{}_private'.format(username)] = key.exportKey('PEM')
+            self.TEST_KEYS[f'{username}_public'] = key.publickey().exportKey('PEM')
+            self.TEST_KEYS[f'{username}_private'] = key.exportKey('PEM')
 
     def test_01_fill(self):
         """
@@ -84,7 +87,6 @@ def test_05_invalid_private_key(self):
 
 
 class SecretTestCase(TestCase):
-
     @classmethod
     def setUpTestData(cls):
         # Generate a random key for encryption/decryption of secrets
@@ -106,10 +108,13 @@ def test_01_encrypt_decrypt(self):
 
         # Ensure proper hashing algorithm is used
         hasher, iterations, salt, sha256 = s.hash.split('$')
-        self.assertEqual(hasher, 'pbkdf2_sha256', "Hashing algorithm has been modified to: {}".format(hasher))
-        self.assertGreaterEqual(int(iterations), SecretValidationHasher.iterations,
-                                "Insufficient iteration count ({}) for hash".format(iterations))
-        self.assertGreaterEqual(len(salt), 12, "Hash salt is too short ({} chars)".format(len(salt)))
+        self.assertEqual(hasher, 'pbkdf2_sha256', f"Hashing algorithm has been modified to: {hasher}")
+        self.assertGreaterEqual(
+            int(iterations),
+            SecretValidationHasher.iterations,
+            f"Insufficient iteration count ({iterations}) for hash",
+        )
+        self.assertGreaterEqual(len(salt), 12, f"Hash salt is too short ({len(salt)} chars)")
 
         # Test hash validation
         self.assertTrue(s.validate(plaintext), "Plaintext does not validate against the generated hash")
diff --git a/netbox_secrets/tests/test_views.py b/netbox_secrets/tests/test_views.py
index a0e6b29..e27299c 100644
--- a/netbox_secrets/tests/test_views.py
+++ b/netbox_secrets/tests/test_views.py
@@ -1,6 +1,8 @@
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
-from netbox_secrets.models import Secret, SecretRole, SessionKey, UserKey
 from utilities.testing import ViewTestCases
+
+from netbox_secrets.models import Secret, SecretRole, SessionKey, UserKey
+
 from .constants import PRIVATE_KEY, PUBLIC_KEY
 
 
@@ -10,11 +12,7 @@ def _get_base_url(self):
         Return the base format for a URL for the test's model. Override this to test for a model which belongs
         to a different app (e.g. testing Interfaces within the virtualization app).
         """
-        return '{}:{}:{}_{{}}'.format(
-            'plugins',
-            self.model._meta.app_label,
-            self.model._meta.model_name
-        )
+        return '{}:{}:{}_{{}}'.format('plugins', self.model._meta.app_label, self.model._meta.model_name)
 
 
 class SecretRoleTestCase(
@@ -24,17 +22,19 @@ class SecretRoleTestCase(
     ViewTestCases.DeleteObjectViewTestCase,
     ViewTestCases.ListObjectsViewTestCase,
     ViewTestCases.BulkEditObjectsViewTestCase,
-    ViewTestCases.BulkDeleteObjectsViewTestCase
+    ViewTestCases.BulkDeleteObjectsViewTestCase,
 ):
     model = SecretRole
 
     @classmethod
     def setUpTestData(cls):
-        SecretRole.objects.bulk_create([
-            SecretRole(name='Secret Role 1', slug='secret-role-1'),
-            SecretRole(name='Secret Role 2', slug='secret-role-2'),
-            SecretRole(name='Secret Role 3', slug='secret-role-3'),
-        ])
+        SecretRole.objects.bulk_create(
+            [
+                SecretRole(name='Secret Role 1', slug='secret-role-1'),
+                SecretRole(name='Secret Role 2', slug='secret-role-2'),
+                SecretRole(name='Secret Role 3', slug='secret-role-3'),
+            ],
+        )
 
         cls.form_data = {
             'name': 'Secret Role X',
@@ -61,7 +61,7 @@ class SecretTestCase(
     ViewTestCases.GetObjectChangelogViewTestCase,
     ViewTestCases.DeleteObjectViewTestCase,
     ViewTestCases.ListObjectsViewTestCase,
-    ViewTestCases.BulkDeleteObjectsViewTestCase
+    ViewTestCases.BulkDeleteObjectsViewTestCase,
 ):
     model = Secret
 
@@ -86,11 +86,13 @@ def setUpTestData(cls):
         SecretRole.objects.bulk_create(secretroles)
 
         # Create one secret per device to allow bulk-editing of names (which must be unique per device/role)
-        Secret.objects.bulk_create((
-            Secret(assigned_object=devices[0], role=secretroles[0], name='Secret 1', ciphertext=b'1234567890'),
-            Secret(assigned_object=devices[1], role=secretroles[0], name='Secret 2', ciphertext=b'1234567890'),
-            Secret(assigned_object=devices[2], role=secretroles[0], name='Secret 3', ciphertext=b'1234567890'),
-        ))
+        Secret.objects.bulk_create(
+            (
+                Secret(assigned_object=devices[0], role=secretroles[0], name='Secret 1', ciphertext=b'1234567890'),
+                Secret(assigned_object=devices[1], role=secretroles[0], name='Secret 2', ciphertext=b'1234567890'),
+                Secret(assigned_object=devices[2], role=secretroles[0], name='Secret 3', ciphertext=b'1234567890'),
+            ),
+        )
 
         cls.form_data = {
             'assigned_object_type': 'dcim.device',
diff --git a/test.sh b/test.sh
index 6bbaf9d..da0144b 100755
--- a/test.sh
+++ b/test.sh
@@ -16,6 +16,7 @@ doco="docker compose --file docker-compose.yml"
 
 test_netbox_unit_tests() {
   echo "⏱  Running NetBox Unit Tests"
+  $doco run --rm netbox python manage.py makemigrations netbox_secrets --check
   $doco run --rm netbox python manage.py test netbox_secrets
 }