Explorar o código

migrate secrets to bootstrap 5 and deprecate jquery functions

checktheroads %!s(int64=4) %!d(string=hai) anos
pai
achega
eb951fdaf1

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/netbox.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 22 - 2
netbox/project-static/src/global.d.ts

@@ -11,12 +11,15 @@ type APIAnswer<T> = {
   results: T[];
 };
 
-type APIError = {
+type ErrorBase = {
   error: string;
+};
+
+type APIError = {
   exception: string;
   netbox_version: string;
   python_version: string;
-};
+} & ErrorBase;
 
 type APIObjectBase = {
   id: number;
@@ -39,6 +42,23 @@ type APIReference = {
   _depth: number;
 };
 
+type APISecret = {
+  assigned_object: APIObjectBase;
+  assigned_object_id: number;
+  assigned_object_type: string;
+  created: string;
+  custom_fields: Record<string, unknown>;
+  display: string;
+  hash: string;
+  id: number;
+  last_updated: string;
+  name: string;
+  plaintext: Nullable<string>;
+  role: APIObjectBase;
+  tags: number[];
+  url: string;
+};
+
 interface ObjectWithGroup extends APIObjectBase {
   group: Nullable<APIReference>;
 }

+ 4 - 3
netbox/project-static/src/netbox.ts

@@ -7,7 +7,7 @@ import { initSpeedSelector, initForms } from './forms';
 import { initRackElevation } from './buttons';
 import { initClipboard } from './clipboard';
 import { initSearchBar } from './search';
-// import { initGenerateKeyPair } from './secrets';
+import { initGenerateKeyPair, initLockUnlock, initGetSessionKey } from './secrets';
 import { getElements } from './util';
 
 const INITIALIZERS = [
@@ -21,7 +21,9 @@ const INITIALIZERS = [
   initColorSelect,
   initRackElevation,
   initClipboard,
-  // initGenerateKeyPair,
+  initGenerateKeyPair,
+  initLockUnlock,
+  initGetSessionKey,
 ] as (() => void)[];
 
 /**
@@ -35,7 +37,6 @@ function initBootstrap(): void {
       new Tooltip(tooltip, { container: 'body', boundary: 'window' });
     }
     for (const modal of getElements('[data-bs-toggle="modal"]')) {
-      // for (const modal of getElements('div.modal')) {
       new Modal(modal);
     }
     initMessageToasts();

+ 162 - 22
netbox/project-static/src/secrets.ts

@@ -1,47 +1,50 @@
-import { apiGetBase, getElements, isApiError } from './util';
+import { Modal } from 'bootstrap';
+import { apiGetBase, apiPostForm, getElements, isApiError, hasError } from './util';
+import { createToast } from './toast';
+
 /**
- * 
- * $('#generate_keypair').click(function() {
-        $('#new_keypair_modal').modal('show');
-        $.ajax({
-            url: netbox_api_path + 'secrets/generate-rsa-key-pair/',
-            type: 'GET',
-            dataType: 'json',
-            success: function (response, status) {
-                var public_key = response.public_key;
-                var private_key = response.private_key;
-                $('#new_pubkey').val(public_key);
-                $('#new_privkey').val(private_key);
-            },
-            error: function (xhr, ajaxOptions, thrownError) {
-                alert("There was an error generating a new key pair.");
-            }
-        });
-    });
+ * Initialize Generate Private Key Pair Elements.
  */
 export function initGenerateKeyPair() {
   const element = document.getElementById('new_keypair_modal') as HTMLDivElement;
   const accept = document.getElementById('use_new_pubkey') as HTMLButtonElement;
+  // If the elements are not loaded, stop.
+  if (element === null || accept === null) {
+    return;
+  }
   const publicElem = element.querySelector<HTMLTextAreaElement>('textarea#new_pubkey');
   const privateElem = element.querySelector<HTMLTextAreaElement>('textarea#new_privkey');
 
+  /**
+   * Handle Generate Private Key Pair Modal opening.
+   */
   function handleOpen() {
+    // When the modal opens, set the `readonly` attribute on the textarea elements.
     for (const elem of [publicElem, privateElem]) {
       if (elem !== null) {
         elem.setAttribute('readonly', '');
       }
     }
-
+    // Fetch the key pair from the API.
     apiGetBase<APIKeyPair>('/api/secrets/generate-rsa-key-pair').then(data => {
-      if (!isApiError(data)) {
+      if (!hasError(data)) {
+        // If key pair generation was successful, set the textarea elements' value to the generated
+        // values.
         const { private_key: priv, public_key: pub } = data;
         if (publicElem !== null && privateElem !== null) {
           publicElem.value = pub;
           privateElem.value = priv;
         }
+      } else {
+        // Otherwise, show an error.
+        const toast = createToast('danger', 'Error', data.error);
+        toast.show();
       }
     });
   }
+  /**
+   * Set the public key form field's value to the generated public key.
+   */
   function handleAccept() {
     const publicKeyField = document.getElementById('id_public_key') as HTMLTextAreaElement;
     if (publicElem !== null) {
@@ -53,10 +56,147 @@ export function initGenerateKeyPair() {
   accept.addEventListener('click', handleAccept);
 }
 
+/**
+ * Toggle copy/lock/unlock button visibility based on the action occurring.
+ * @param id Secret ID.
+ * @param action Lock or Unlock, so we know which buttons to display.
+ */
+function toggleSecretButtons(id: string, action: 'lock' | 'unlock') {
+  const unlockButton = document.querySelector(`button.unlock-secret[secret-id='${id}']`);
+  const lockButton = document.querySelector(`button.lock-secret[secret-id='${id}']`);
+  const copyButton = document.querySelector(`button.copy-secret[secret-id='${id}']`);
+
+  // If we're unlocking, hide the unlock button. Otherwise, show it.
+  if (unlockButton !== null) {
+    if (action === 'unlock') unlockButton.classList.add('d-none');
+    if (action === 'lock') unlockButton.classList.remove('d-none');
+  }
+  // If we're unlocking, show the lock button. Otherwise, hide it.
+  if (lockButton !== null) {
+    if (action === 'unlock') lockButton.classList.remove('d-none');
+    if (action === 'lock') lockButton.classList.add('d-none');
+  }
+  // If we're unlocking, show the copy button. Otherwise, hide it.
+  if (copyButton !== null) {
+    if (action === 'unlock') copyButton.classList.remove('d-none');
+    if (action === 'lock') copyButton.classList.add('d-none');
+  }
+}
+
+/**
+ * Initialize Lock & Unlock button event listeners & callbacks.
+ */
 export function initLockUnlock() {
+  const privateKeyModalElem = document.getElementById('privkey_modal');
+  if (privateKeyModalElem === null) {
+    return;
+  }
+  const privateKeyModal = new Modal(privateKeyModalElem);
+
+  /**
+   * Unlock a secret, or prompt the user for their private key, if a session key is not available.
+   *
+   * @param id Secret ID
+   */
+  function unlock(id: string | null) {
+    const target = document.getElementById(`secret_${id}`);
+    if (typeof id === 'string' && id !== '') {
+      apiGetBase<APISecret>(`/api/secrets/secrets/${id}`).then(data => {
+        if (!hasError(data)) {
+          const { plaintext } = data;
+          // `plaintext` is the plain text value of the secret. If it is null, it has not been
+          // decrypted, likely due to a mission session key.
+
+          if (target !== null && plaintext !== null) {
+            // If `plaintext` is not null, we have the decrypted value. Set the target element's
+            // inner text to the decrypted value and toggle copy/lock button visibility.
+            target.innerText = plaintext;
+            toggleSecretButtons(id, 'unlock');
+          } else {
+            // Otherwise, we do _not_ have the decrypted value and need to prompt the user for
+            // their private RSA key, in order to get a session key. The session key is then sent
+            // as a cookie in future requests.
+            privateKeyModal.show();
+          }
+        } else {
+          if (data.error.toLowerCase().includes('invalid session key')) {
+            // If, for some reason, a request was made but resulted in an API error that complains
+            // of a missing session key, prompt the user for their session key.
+            privateKeyModal.show();
+          } else {
+            // If we received an API error but it doesn't contain 'invalid session key', show the
+            // user an error message.
+            const toast = createToast('danger', 'Error', data.error);
+            toast.show();
+          }
+        }
+      });
+    }
+  }
+  /**
+   * Lock a secret and toggle visibility of the unlock button.
+   * @param id Secret ID
+   */
+  function lock(id: string | null) {
+    if (typeof id === 'string' && id !== '') {
+      const target = document.getElementById(`secret_${id}`);
+      if (target !== null) {
+        // Obscure the inner text of the secret element.
+        target.innerText = '********';
+      }
+      // Toggle visibility of the copy/lock/unlock buttons.
+      toggleSecretButtons(id, 'lock');
+    }
+  }
+
   for (const element of getElements<HTMLButtonElement>('button.unlock-secret')) {
+    element.addEventListener('click', () => unlock(element.getAttribute('secret-id')));
+  }
+  for (const element of getElements<HTMLButtonElement>('button.lock-secret')) {
+    element.addEventListener('click', () => lock(element.getAttribute('secret-id')));
+  }
+}
+
+/**
+ * Request a session key from the API.
+ * @param privateKey RSA Private Key (valid JSON string)
+ */
+function requestSessionKey(privateKey: string) {
+  apiPostForm('/api/secrets/get-session-key/', { private_key: privateKey }).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();
+    } else {
+      // Otherwise, show the user an error message.
+      let message = res.error;
+      if (isApiError(res)) {
+        // If the error received was a standard API error containing a Python exception message,
+        // append it to the error.
+        message += `\n${res.exception}`;
+      }
+      const toast = createToast('danger', 'Failed to Retrieve Session Key', message);
+      toast.show();
+    }
+  });
+}
+
+/**
+ * Initialize Request Session Key Elements.
+ */
+export function initGetSessionKey() {
+  for (const element of getElements<HTMLButtonElement>('#request_session_key')) {
+    /**
+     * Send the user's input private key to the API to get a session key, which will be stored as
+     * a cookie for future requests.
+     */
     function handleClick() {
-      const { secretId } = element.dataset;
+      for (const pk of getElements<HTMLTextAreaElement>('#user_privkey')) {
+        requestSessionKey(pk.value);
+        // Clear the private key form field value.
+        pk.value = '';
+      }
     }
+    element.addEventListener('click', handleClick);
   }
 }

+ 48 - 3
netbox/project-static/src/util.ts

@@ -1,6 +1,9 @@
 import Cookie from 'cookie';
-
 export function isApiError(data: Record<string, unknown>): data is APIError {
+  return 'error' in data && 'exception' in data;
+}
+
+export function hasError(data: Record<string, unknown>): data is ErrorBase {
   return 'error' in data;
 }
 
@@ -34,13 +37,55 @@ export function getCsrfToken(): string {
 
 export async function apiGetBase<T extends Record<string, unknown>>(
   url: string,
-): Promise<T | APIError> {
+): Promise<T | ErrorBase | APIError> {
   const token = getCsrfToken();
   const res = await fetch(url, {
     method: 'GET',
     headers: { 'X-CSRFToken': token },
+    credentials: 'same-origin',
   });
+  const contentType = res.headers.get('Content-Type');
+  if (typeof contentType === 'string' && contentType.includes('text')) {
+    const error = await res.text();
+    return { error } as ErrorBase;
+  }
+
   const json = (await res.json()) as T | APIError;
+  if (!res.ok && Array.isArray(json)) {
+    const error = json.join('\n');
+    return { error } as ErrorBase;
+  }
+  return json;
+}
+
+export async function apiPostForm<
+  T extends Record<string, unknown>,
+  R extends Record<string, unknown>
+>(url: string, data: T): Promise<R | ErrorBase | APIError> {
+  const token = getCsrfToken();
+  const body = new URLSearchParams();
+  for (const [k, v] of Object.entries(data)) {
+    body.append(k, String(v));
+  }
+  const res = await fetch(url, {
+    method: 'POST',
+    body,
+    headers: { 'X-CSRFToken': token },
+  });
+
+  const contentType = res.headers.get('Content-Type');
+  if (typeof contentType === 'string' && contentType.includes('text')) {
+    let error = await res.text();
+    if (contentType.includes('text/html')) {
+      error = res.statusText;
+    }
+    return { error } as ErrorBase;
+  }
+
+  const json = (await res.json()) as R | APIError;
+  if (!res.ok && 'detail' in json) {
+    return { error: json.detail as string } as ErrorBase;
+  }
   return json;
 }
 
@@ -50,7 +95,7 @@ export async function apiGetBase<T extends Record<string, unknown>>(
  */
 export async function getApiData<T extends APIObjectBase>(
   url: string,
-): Promise<APIAnswer<T> | APIError> {
+): Promise<APIAnswer<T> | ErrorBase | APIError> {
   return await apiGetBase<APIAnswer<T>>(url);
 }
 

+ 11 - 11
netbox/templates/secrets/inc/private_key_modal.html

@@ -2,26 +2,26 @@
     <div class="modal-dialog modal-md" role="document">
         <div class="modal-content">
             <div class="modal-header">
-                <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
-                <h4 class="modal-title" id="privkey_modal_title">
+                <h5 class="modal-title" id="privkey_modal_title">
                     <span class="mdi mdi-lock" aria-hidden="true"></span>
-                    Enter your private RSA key
-                </h4>
+                    Enter Private RSA Key
+                </h5>
+                <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
             </div>
             <div class="modal-body">
-                <p>
+                <p class="small text-muted">
                     You do not have an active session key. To request one, please provide your private RSA key below.
                     Once retrieved, your session key will be saved for future requests.
                 </p>
                 <div class="form-group">
-                    <textarea class="form-control" id="user_privkey" style="height: 300px;"></textarea>
-                </div>
-                <div class="form-group text-right noprint">
-                    <button id="request_session_key" class="btn btn-primary" data-dismiss="modal">
-                        Request session key
-                    </button>
+                    <textarea class="form-control font-monospace" id="user_privkey" style="height: 300px;"></textarea>
                 </div>
             </div>
+            <div class="modal-footer float-end">
+                <button id="request_session_key" class="btn btn-primary" data-bs-dismiss="modal">
+                    Request Session Key
+                </button>
+            </div>
         </div>
     </div>
 </div>

+ 3 - 3
netbox/templates/secrets/secret.html

@@ -53,15 +53,15 @@
                 </form>
                 <div class="row">
                     <div class="col-md-2">Secret</div>
-                    <div class="col-md-6" id="secret_{{ object.pk }}">********</div>
+                    <div class="col-md-6"><code id="secret_{{ object.pk }}">********</code></div>
                     <div class="col-md-4 text-right noprint">
                         <button class="btn btn-sm btn-success unlock-secret" secret-id="{{ object.pk }}">
                             <i class="mdi mdi-lock"></i> Unlock
                         </button>
-                        <button class="btn btn-sm btn-default copy-secret collapse" secret-id="{{ object.pk }}" data-clipboard-target="#secret_{{ object.pk }}">
+                        <button class="btn btn-sm btn-outline-dark copy-secret d-none" secret-id="{{ object.pk }}" data-clipboard-target="#secret_{{ object.pk }}">
                             <i class="mdi mdi-content-copy"></i> Copy
                         </button>
-                        <button class="btn btn-sm btn-danger lock-secret collapse" secret-id="{{ object.pk }}">
+                        <button class="btn btn-sm btn-danger lock-secret d-none" secret-id="{{ object.pk }}">
                             <i class="mdi mdi-lock-open"></i> Lock
                         </button>
                     </div>

+ 0 - 4
netbox/templates/secrets/secret_import.html

@@ -5,7 +5,3 @@
 {{ block.super }}
 {% include 'secrets/inc/private_key_modal.html' %}
 {% endblock %}
-
-{% block javascript %}
-<script src="{% static 'js/secrets.js' %}?v{{ settings.VERSION }}"></script>
-{% endblock %}

+ 33 - 29
netbox/templates/secrets/secretrole.html

@@ -3,33 +3,35 @@
 {% load plugins %}
 
 {% block breadcrumbs %}
-  <li><a href="{% url 'secrets:secretrole_list' %}">Secret Roles</a></li>
-  <li>{{ object }}</li>
+  <li class="breadcrumb-item"><a href="{% url 'secrets:secretrole_list' %}">Secret Roles</a></li>
+  <li class="breadcrumb-item">{{ object }}</li>
 {% endblock %}
 
 {% block content %}
-<div class="row">
+<div class="row mb-3">
 	<div class="col-md-6">
-    <div class="panel panel-default">
-      <div class="panel-heading">
-        <strong>Secret Role</strong>
+    <div class="card">
+      <h5 class="card-header">
+        Secret Role
+      </h5>
+      <div class="card-body">
+        <table class="table table-hover attr-table">
+          <tr>
+            <th scope="row">Name</th>
+            <td>{{ object.name }}</td>
+          </tr>
+          <tr>
+            <th scope="row">Description</th>
+            <td>{{ object.description|placeholder }}</td>
+          </tr>
+          <tr>
+            <th scope="row">Secrets</th>
+            <td>
+              <a href="{% url 'secrets:secret_list' %}?role_id={{ object.pk }}">{{ secrets_table.rows|length }}</a>
+            </td>
+          </tr>
+        </table>
       </div>
-      <table class="table table-hover panel-body attr-table">
-        <tr>
-          <td>Name</td>
-          <td>{{ object.name }}</td>
-        </tr>
-        <tr>
-          <td>Description</td>
-          <td>{{ object.description|placeholder }}</td>
-        </tr>
-        <tr>
-          <td>Secrets</td>
-          <td>
-            <a href="{% url 'secrets:secret_list' %}?role_id={{ object.pk }}">{{ secrets_table.rows|length }}</a>
-          </td>
-        </tr>
-      </table>
     </div>
     {% plugin_left_page object %}
 	</div>
@@ -40,15 +42,17 @@
 </div>
 <div class="row">
 	<div class="col-md-12">
-    <div class="panel panel-default">
-      <div class="panel-heading">
-        <strong>Secrets</strong>
+    <div class="card">
+      <h5 class="card-header">
+        Secrets
+      </h5>
+      <div class="card-body">
+        {% include 'inc/table.html' with table=secrets_table %}
       </div>
-      {% include 'inc/table.html' with table=secrets_table %}
       {% if perms.secrets.add_secret %}
-        <div class="panel-footer text-right noprint">
-          <a href="{% url 'secrets:secret_add' %}?role={{ object.pk }}" class="btn btn-xs btn-primary">
-            <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add secret
+        <div class="card-footer text-end noprint">
+          <a href="{% url 'secrets:secret_add' %}?role={{ object.pk }}" class="btn btn-sm btn-primary">
+            <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Secret
           </a>
         </div>
       {% endif %}

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio