Create views for organizing secrets in groups

This commit is contained in:
2025-06-01 15:06:07 +02:00
parent 773a1e2976
commit ba936ac645
28 changed files with 1152 additions and 396 deletions

View File

@ -10,6 +10,7 @@ from sshecret_admin.core.dependencies import AdminDependencies
from sshecret_admin.services import AdminBackend from sshecret_admin.services import AdminBackend
from sshecret_admin.services.models import ( from sshecret_admin.services.models import (
ClientSecretGroup, ClientSecretGroup,
ClientSecretGroupList,
SecretCreate, SecretCreate,
SecretGroupCreate, SecretGroupCreate,
SecretUpdate, SecretUpdate,
@ -78,7 +79,7 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
async def get_secret_groups( async def get_secret_groups(
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
filter_regex: Annotated[str | None, Query()] = None, filter_regex: Annotated[str | None, Query()] = None,
) -> list[ClientSecretGroup]: ) -> ClientSecretGroupList:
"""Get secret groups.""" """Get secret groups."""
return await admin.get_secret_groups(filter_regex) return await admin.get_secret_groups(filter_regex)

View File

@ -14,6 +14,11 @@
href="{{ url_for('static', path='css/prism.css') }}" href="{{ url_for('static', path='css/prism.css') }}"
type="text/css" type="text/css"
/> />
<link
rel="stylesheet"
href="{{ url_for('static', path='css/style.css') }}"
type="text/css"
/>
<link <link
href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css"

View File

@ -61,23 +61,7 @@
data-dropdown-toggle="dropdown-2" data-dropdown-toggle="dropdown-2"
> >
<span class="sr-only">Open user menu</span> <span class="sr-only">Open user menu</span>
<svg <sl-avatar label="User avatar"></sl-avatar>
class="w-6 h-6 text-gray-800 dark:text-white"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0 0a8.949 8.949 0 0 0 4.951-1.488A3.987 3.987 0 0 0 13 16h-2a3.987 3.987 0 0 0-3.951 3.512A8.948 8.948 0 0 0 12 21Zm3-11a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
</button> </button>
</div> </div>
<!-- Dropdown menu --> <!-- Dropdown menu -->

View File

@ -1,3 +0,0 @@
{% for client in clients %}
<option value="{{ client.id }}">{{ client.name }}</option>
{% endfor %}

View File

@ -1,38 +0,0 @@
<div
id="drawer-create-secret-default"
class="fixed top-0 right-0 z-40 w-full h-screen max-w-xs p-4 overflow-y-auto transition-transform translate-x-full bg-white dark:bg-gray-800"
tabindex="-1"
aria-labelledby="drawer-label"
aria-hidden="true"
>
<h5
id="drawer-label"
class="inline-flex items-center mb-6 text-sm font-semibold text-gray-500 uppercase dark:text-gray-400"
>
New Secret
</h5>
<button
type="button"
data-drawer-dismiss="drawer-create-secret-default"
aria-controls="drawer-create-secret-default"
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 absolute top-2.5 right-2.5 inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white"
>
<svg
aria-hidden="true"
class="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
></path>
</svg>
<span class="sr-only">Close menu</span>
</button>
<form hx-post="/secrets/" hx-target="#secretsContent">
{% include '/secrets/drawer_secret_create_inner.html.j2' %}
</form>
</div>

View File

@ -1,4 +1,36 @@
{% macro display_entry(entry) %}
<sl-tree-item
id="entry_{{ entry.name }}"
class="tree-entry-item"
data-type="entry"
data-name="{{ entry.name }}"
>
<sl-icon name="shield"> </sl-icon>
<span class="px-2">{{ entry.name }}</span>
</sl-tree-item>
{% endmacro %}
{% macro display_group(group) %}
<sl-tree-item
class="secret-group-list-item"
data-type="group"
data-name="{{ group.group_name }}"
>
<sl-icon name="folder"> </sl-icon>
<span class="px-2">{{ group.group_name }}</span>
{% for entry in group.entries %}
{{ display_entry(entry) }}
{% endfor %}
{% for child in group.children %}
{{ display_group(child) }}
{% endfor %}
</sl-tree-item>
{% endmacro %}
{% extends "/dashboard/_base.html" %} {% block content %} {% extends "/dashboard/_base.html" %} {% block content %}
<div class="p-4 bg-white block sm:flex items-center justify-between border-b border-gray-200 lg:mt-1.5 dark:bg-gray-800 dark:border-gray-700"> <div class="p-4 bg-white block sm:flex items-center justify-between border-b border-gray-200 lg:mt-1.5 dark:bg-gray-800 dark:border-gray-700">
<div class="w-full mb-1"> <div class="w-full mb-1">
<div class="mb-4"> <div class="mb-4">
@ -14,7 +46,6 @@
<div class="flex items-center"> <div class="flex items-center">
<svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg> <svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg>
<span class="ml-1 text-gray-400 md:ml-2 dark:text-gray-500" aria-current="page">Secrets</span> <span class="ml-1 text-gray-400 md:ml-2 dark:text-gray-500" aria-current="page">Secrets</span>
</svg>
</div> </div>
</li> </li>
</ol> </ol>
@ -22,24 +53,73 @@
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Secrets</h1> <h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Secrets</h1>
</div> </div>
<div class="items-center justify-between block sm:flex"> <div class="grid w-full grid-cols-1 gap-4 mt-4 xl:grid-cols-3">
<div class="flex items-center mb-4 sm:mb-0"> <div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm sm:flex dark:border-gray-700 sm:p-6 dark:bg-gray-800" id="secret-tree">
<label for="secret-search" class="sr-only">Search</label>
<div class="relative w-48 mt-1 sm:w-64 xl:w-96">
<input type="search" name="query" id="secret-search" class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500" placeholder="Search for secrets" hx-post="/secrets/query" hx-trigger="keyup changed delay:500ms, query" hx-target="#secretsContent"> <div class="flex flex-col">
<div class="h-full">
<sl-tree class="tree-with-icons">
<sl-tree-item
id="secret-group-root-item"
data-type="root"
data-name="root"
expanded=""
>
<sl-icon name="folder"> </sl-icon>
<span class="px-2">Ungrouped</span>
{% for entry in groups.ungrouped %}
{{ display_entry(entry) }}
{% endfor %}
</sl-tree-item>
{% for child in groups.groups %}
{{ display_group(child) }}
{% endfor %}
</sl-tree>
</div> </div>
</div> </div>
<button id="createSecretButton" class="text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-primary-600 dark:hover:bg-primary-700 focus:outline-none dark:focus:ring-primary-800" type="button" data-drawer-target="drawer-create-secret-default" data-drawer-show="drawer-create-secret-default" aria-controls="drawer-create-secret-default" data-drawer-placement="right">
Add new secret
</button>
</div>
</div> </div>
<div class="2xl:col-span-2 xl:col-span-2 p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
{% include '/secrets/partials/default_detail.html.j2' %}
</div> </div>
<div id="secretsContent">
{% include '/secrets/inner.html.j2' %}
</div> </div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const tree = document.querySelector('sl-tree');
{% include '/secrets/drawer_secret_create.html.j2' %} if (!tree) return;
tree.addEventListener('sl-selection-change', (event) => {
const selectedEl = event.detail.selection[0];
if (!selectedEl) return;
const type = selectedEl.dataset.type;
const name = selectedEl.dataset.name;
console.log(`Event on ${type} ${name}`);
if (!type || !name) return;
let url = '';
if (type === 'entry') {
url = `/secrets/partial/secret/${encodeURIComponent(name)}`;
} else if (type === 'group') {
url = `/secrets/partial/group/${encodeURIComponent(name)}`;
} else if (type == 'root') {
url = `/secrets/partial/root_group`;
}
if (url) {
htmx.ajax('GET', url, {
target: '#secretdetails',
swap: 'OuterHTML',
indicator: '.secret-spinner'
});
}
});
});
</script>
{% endblock %} {% endblock %}

View File

@ -1,36 +0,0 @@
<div>
<div class="flex flex-col">
<div class="overflow-x-auto">
<div class="inline-block min-w-full align-middle">
<div class="overflow-hidden shadow">
<table class="min-w-full divide-y divide-gray-200 table-fixed dark:divide-gray-600">
<thead class="bg-gray-100 dark:bg-gray-700">
<tr>
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
Name
</th>
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
Clients associated
</th>
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
Actions
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200 dark:bg-gray-800 dark:divide-gray-700">
{% for secret in secrets %}
{% include '/secrets/secret.html.j2'%}
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% for secret in secrets %}
{% include '/secrets/modal_client_secret.html.j2' %}
{% endfor %}

View File

@ -1,122 +0,0 @@
<div
id="client-secret-modal-{{ secret.name }}"
tabindex="-1"
aria-hidden="true"
class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full"
>
<div class="relative p-4 w-full max-w-md max-h-full">
<div class="relative bg-white rounded-lg shadow-sm dark:bg-gray-700">
<div
class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600 border-gray-200"
>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">
Edit Client Access
</h3>
<button
type="button"
class="end-2.5 text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"
data-modal-hide="client-secret-modal-{{ secret.name }}"
>
<svg
class="w-3 h-3"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 14 14"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"
/>
</svg>
<span class="sr-only">Close modal</span>
</button>
</div>
<div class="p-4 md:p-5">
{% if secret.clients %}
<div class="space-y-4">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">
Existing clients with access
</h3>
{% for client in secret.clients %}
<span
class="inline-flex items-center px-2 py-1 me-2 text-sm font-medium text-red-800 bg-red-100 rounded-sm dark:bg-red-900 dark:text-red-300 pill-client-secret"
id="client-secret-{{ secret.name }}-pill-{{ client.name }}"
>{{ client.name }}
<button
type="button"
class="inline-flex items-center p-1 ms-2 text-sm text-gray-400 bg-transparent rounded-xs hover:bg-gray-200 hover:text-gray-900 dark:hover:bg-gray-600 dark:hover:text-gray-300"
aria-label="Remove"
hx-delete="/secrets/{{ secret.name }}/clients/{{ client.id }}"
hx-target="#secretsContent"
hx-confirm="Remove client {{ client.name }} from secret {{secret.name}}?"
id="btn-remove-client-{{ client.name }}-secret-{{ secret.name }}"
>
<svg
class="w-2 h-2"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 14 14"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"
/>
</svg>
<span class="sr-only">Remove badge</span>
</button>
</span>
{% endfor %}
</div>
{% endif %}
<form
class="space-y-4"
hx-post="/secrets/{{ secret.name }}/clients/"
hx-target="#secretsContent"
>
<div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">
Give client access
</h3>
<label
for="client"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Client
</label>
<select
name="client"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
id="sel-add-client-secret-{{ secret.name }}"
>
<option selected="selected">
Select clients to assign the secret to
</option>
{% for client in clients %}
{% if client.id|string not in secret.clients|map(attribute='id')|list %}
<option value="{{ client.id }}">{{ client.name }}</option>
{% endif %}
{% endfor %}
</select>
</div>
<div>
<button
type="submit"
class="text-white w-full justify-center bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
>
Give Access
</button>
</div>
</form>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,22 @@
<form
hx-post="/secrets/{{secret.name}}/clients/"
hx-target="#secretclientdetails"
>
<div class="mb-6">
<label for="client" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client</label>
</div>
<div class="mb-6">
<sl-select label="Select client" name="client">
{% for client in clients %}
<sl-option value="{{ client.id }}">{{ client.name }}</sl-option>
{% endfor %}
</sl-select>
</div>
<div class="mb-6">
<button
type="submit"
class="text-white w-full justify-center bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
>
Add Client to Secret
</button>
</div>

View File

@ -0,0 +1,7 @@
<button
type="button"
class="text-gray-900 hover:text-blue-700 border border-gray-200 hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 mb-2 dark:border-blue-500 dark:text-blue-500 dark:hover:text-white dark:hover:bg-blue-500 dark:focus:ring-blue-800"
hx-get="/secrets/{{secret.name}}/clients/"
hx-target="#secretclientaction"
>Assign to new client
</button>

View File

@ -0,0 +1,19 @@
{% for client in secret.clients %}
<li class="w-full px-4 py-2">
<span class="inline-flex items-center px-2 py-1 me-2 text-sm font-medium text-blue-800 bg-blue-100 rounded-sm dark:bg-blue-900 dark:text-blue-300">
{{ client }}
<button
type="button"
class="inline-flex items-center p-1 ms-2 text-sm text-blue-400 bg-transparent rounded-xs hover:bg-blue-200 hover:text-blue-900 dark:hover:bg-blue-800 dark:hover:text-blue-300"
hx-delete="/secrets/{{ secret.name }}/clients/{{ client }}"
hx-target="#secretclientlist"
hx-confirm="Remove client {{ client }} from secret?"
aria-label="Remove">
<svg class="w-2 h-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
</svg>
<span class="sr-only">Remove client</span>
</button>
</span>
</li>
{% endfor %}

View File

@ -0,0 +1,8 @@
<div class="w-full my-2">
<ul class="w-48 text-sm font-medium text-gray-900 bg-white dark:bg-gray-700 dark:text-white" id="secretclientlist">
{% include '/secrets/partials/client_list_inner.html.j2' %}
</ul>
</div>
<div class="w-full my-2" id="secretclientaction">
{% include '/secrets/partials/client_assign_button.html.j2' %}
</div>

View File

@ -83,7 +83,6 @@
</div> </div>
<div <div
class="bottom-0 left-0 flex justify-center w-full pb-4 space-x-4 md:px-4 md:absolute"
> >
<button <button
type="submit" type="submit"
@ -91,28 +90,5 @@
> >
Add Secret Add Secret
</button> </button>
<button
type="button"
data-drawer-dismiss="drawer-create-secret-default"
aria-controls="drawer-create-secret-default"
class="inline-flex w-full justify-center text-gray-500 items-center bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-primary-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600"
>
<svg
aria-hidden="true"
class="w-5 h-5 -ml-1 sm:mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
Cancel
</button>
</div> </div>
</div> </div>

View File

@ -0,0 +1,6 @@
<div class="w-full" id="secretdetails">
<h3 class="mb-4 text-sm italic text-gray-400 dark:text-white">Click an item to view details</h3>
<div class="htmx-indicator secret-spinner">
{% include '/secrets/partials/skeleton.html.j2' %}
</div>
</div>

View File

@ -0,0 +1,53 @@
<div class="w-full">
<sl-details summary="Create secret">
<form
hx-post="/secrets/create/root"
hx-target="#secretdetails"
hx-swap="OuterHTML"
>
{% include '/secrets/partials/create_secret.html.j2' %}
</form>
</sl-details>
<sl-details summary="Create group">
<form
hx-post="/secrets/group/"
hx-target="#secretdetails"
hx-swap="OuterHTML"
hx-indicator=".secret-spinner"
>
<div class="mb-6">
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Name</label>
</div>
<div class="mb-6">
<input
type="text"
name="name"
id="name"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
placeholder="Group name"
required=""
/>
</div>
<div class="mb-6">
<label for="description" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Description</label>
</div>
<div class="mb-6">
<input
type="text"
name="description"
id="description"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
placeholder="Description"
/>
</div>
<button
type="submit"
class="text-white w-full justify-center bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
>
Add Group
</button>
</form>
</sl-details>
</div>

View File

@ -0,0 +1,127 @@
<div class="w-full">
<div class="mb-4">
<h3 class="text-xl font-semibold dark:text-white">Group {{name}}</h3>
<span class="text-sm text-gray-500 dark:text-gray-400">{{ description }}</span>
</div>
<sl-details summary="Create secret">
<form
hx-post="/secrets/create/group/{{ name }}"
hx-target="#secretdetails"
hx-swap="OuterHTML"
>
{% include '/secrets/partials/create_secret.html.j2' %}
</form>
</sl-details>
<sl-details summary="Create nested group">
<form
hx-post="/secrets/group/"
hx-target="#secretdetails"
hx-swap="OuterHTML"
hx-indicator=".secret-spinner"
>
<div class="mb-6">
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Name</label>
</div>
<div class="mb-6">
<input
type="text"
name="name"
id="name"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
placeholder="Group name"
required=""
/>
</div>
<div class="mb-6">
<label for="description" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Description</label>
</div>
<div class="mb-6">
<input
type="text"
name="description"
id="description"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
placeholder="Description"
/>
</div>
<input type="hidden" name="parent_group" value="{{ name }}" />
<button
type="submit"
class="text-white w-full justify-center bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
>
Add Group
</button>
</form>
</sl-details>
<sl-details summary="Edit group">
<form
hx-put="/secrets/partial/group/{{name}}/description"
hx-target="#secretdetails"
hx-swap="OuterHTML"
>
<div class="mb-6">
<label
for="description"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Description</label
>
</div>
<div class="flex w-full">
<div class="relative w-full">
<input
type="text"
name="description"
id="description"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
value="{{ description }}"
required=""
/>
</div>
<div class="px-2.5 mb-2">
<button type="Submit" class="text-gray-900 hover:text-blue-700 border border-gray-200 hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 mb-2 dark:border-blue-500 dark:text-blue-500 dark:hover:text-white dark:hover:bg-blue-500 dark:focus:ring-blue-800">Update</button>
</div>
</div>
</form>
<div class="mt-5">
<button
type="button"
class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-red-700 rounded-lg hover:bg-red-800 focus:ring-4 focus:ring-red-300 dark:focus:ring-red-900"
hx-delete="/secrets/group/{{ name }}"
hx-target="#secretdetails"
hx-swap="OuterHTML"
hx-confirm="Deleting a group will move all its secrets to the Ungrouped category. Continue?"
>
<svg
class="w-4 h-4 mr-2"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clip-rule="evenodd"
></path>
</svg>
Delete group
</button>
</div>
</sl-details>
<div class="htmx-indicator secret-spinner">
<div role="status">
<svg aria-hidden="true" class="inline w-6 h-6 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor"/>
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill"/>
</svg>
<span class="sr-only">Loading...</span>
</div>
</div>
</div>

View File

@ -0,0 +1,18 @@
<form hx-put="/secrets/partial/secret/{{ secret.name }}/value" hx-indicator="#secretupdatespinner">
<div class="mb-6">
<label for="secret_value" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Value</label>
</div>
<div class="flex w-full">
<div class="relative w-full">
<input type="text" name="secret_value" aria-label="secret-value" class="mb-6 bg-gray-100 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-400 dark:focus:ring-blue-500 dark:focus:border-blue-500" value="{{ secret.secret }}">
</div>
<div class="px-2.5 mb-2">
<button type="submit" class="bg-primary-700 text-white hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 mb-2 dark-bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">
Update
</button>
</div>
</div>
{% if updated %}
<p class="text-sm text-green-600 dark:text-green-500">Secret updated.</p>
{% endif %}
</form>

View File

@ -0,0 +1,38 @@
<div role="status" class="w-full p-4 space-y-4 border border-gray-200 divide-y divide-gray-200 rounded-sm shadow-sm animate-pulse dark:divide-gray-700 md:p-6 dark:border-gray-700">
<div class="flex items-center justify-between">
<div>
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5"></div>
<div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
</div>
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12"></div>
</div>
<div class="flex items-center justify-between pt-4">
<div>
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5"></div>
<div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
</div>
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12"></div>
</div>
<div class="flex items-center justify-between pt-4">
<div>
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5"></div>
<div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
</div>
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12"></div>
</div>
<div class="flex items-center justify-between pt-4">
<div>
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5"></div>
<div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
</div>
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12"></div>
</div>
<div class="flex items-center justify-between pt-4">
<div>
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5"></div>
<div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
</div>
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12"></div>
</div>
<span class="sr-only">Loading...</span>
</div>

View File

@ -0,0 +1,159 @@
<div class="w-full" id="secretdetails">
<h3 class="mb-4 text-xl font-semibold dark:text-white">{{secret.name}}</h3>
<div class="htmx-indicator secret-spinner">
<div role="status">
<svg aria-hidden="true" class="inline w-6 h-6 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor"/>
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill"/>
</svg>
<span class="sr-only">Loading...</span>
</div>
</div>
<sl-details summary="Clients" open>
<div id="secretclientdetails">
{% include '/secrets/partials/client_secret_details.html.j2' %}
</div>
</sl-details>
<sl-details summary="Read/Update Secret">
<div id="secretvalue">
<div class="mb-6">
<label for="secret-value" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Value</label>
</div>
<div class="flex w-full">
<div class="relative w-full">
<input type="text" id="disabled-input" aria-label="disabled input" class="mb-6 bg-gray-100 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 cursor-not-allowed dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-400 dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="••••••••" disabled>
</div>
<div class="px-2.5 mb-2">
<button
type="button"
class="text-gray-900 hover:text-blue-700 border border-gray-200 hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 mb-2 dark:border-blue-500 dark:text-blue-500 dark:hover:text-white dark:hover:bg-blue-500 dark:focus:ring-blue-800"
hx-get="/secrets/partial/{{ secret.name }}/viewsecret"
hx-target="#secretvalue"
hx-trigger="click"
hx-indicator="#secretupdatespinner"
>
View
</button>
</div>
</div>
</div>
<div class="htmx-indicator" id="secretupdatespinner">
<div role="status">
<svg aria-hidden="true" class="inline w-4 h-4 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor"/>
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill"/>
</svg>
<span class="sr-only">Loading...</span>
</div>
</div>
</sl-details>
{% if groups.groups %}
<sl-details summary="Group">
<form>
<div class="flex w-full">
<div class="relative w-full">
<select id="group" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="__ROOT">Ungrouped</option>
{% for group in groups.groups %}
<option value="{{ group.group_name }}" {% if group.name == secret.group -%}selected{% endif %}>{{ group.group_name }}</option>
{% endfor %}
</select>
</div>
<div class="px-2.5 mb-2">
<button type="Submit" class="text-gray-900 hover:text-blue-700 border border-gray-200 hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 mb-2 dark:border-blue-500 dark:text-blue-500 dark:hover:text-white dark:hover:bg-blue-500 dark:focus:ring-blue-800">Update</button>
</div>
</div>
</form>
</sl-details>
{% endif %}
<sl-details summary="Events">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-600" id="last-audit-events">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">Timestamp</th>
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">Subsystem</th>
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">Message</th>
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">Origin</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800">
{% for entry in events.results | list %}
<tr class="{{ loop.cycle('', 'bg-gray-50 dark:bg-gray-700 ') }}hover:bg-gray-100 dark:hover:bg-gray-700" id="login-entry-{{ entry.id }}">
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
<p>{{ entry.timestamp }}<button data-popover-target="popover-audit-entry-{{ entry.id }}" data-popover-placement="bottom-end" type="button"><svg class="w-4 h-4 ms-2 text-gray-400 hover:text-gray-500" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"></path>
</svg><span class="sr-only">Show information</span></button></p>
<div data-popover id="popover-audit-entry-{{entry.id}}" role="tooltip" class="absolute z-10 invisible inline-block text-sm text-gray-500 transition-opacity duration-300 bg-white border border-gray-200 rounded-lg shadow-xs opacity-0 w-80 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400">
<dl class="max-w-md text-gray-900 divide-y divide-gray-200 dark:text-white dark:divide-gray-700 px-2 py-2">
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">ID</dt>
<dd class="text-xs font-semibold">{{ entry.id }}</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Subsystem</dt>
<dd class="text-xs font-semibold">{{ entry.subsystem }}</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Timestamp</dt>
<dd class="text-xs font-semibold">{{ entry.timestamp }}</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Operation</dt>
<dd class="text-xs font-semibold">{{ entry.operation }}</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Client ID</dt>
<dd class="text-xs font-semibold">{{ entry.client_id }}</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Client Name</dt>
<dd class="text-xs font-semibold">{{ entry.client_name }}</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Secret ID</dt>
<dd class="text-xs font-semibold">{{ entry.secret_id }}</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Secret Name</dt>
<dd class="text-xs font-semibold">{{ entry.secret_name }}</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Message</dt>
<dd class="text-xs font-semibold">{{ entry.message }}</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Origin</dt>
<dd class="text-xs font-semibold">{{ entry.origin }}</dd>
</div>
{% if entry.data %}
{% for key, value in entry.data.items() %}
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">{{ key | capitalize }}</dt>
<dd class="text-xs font-semibold">{{ value }}</dd>
</div>
{% endfor %}
{% endif %}
</dl>
</div>
</td>
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
{{ entry.subsystem }}
</td>
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
{{ entry.message }}
</td>
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
{{ entry.origin }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</sl-details>
</div>

View File

@ -1,73 +0,0 @@
<tr
class="hover:bg-gray-100 dark:hover:bg-gray-700"
id="secret-{{ secret.id }}"
>
<td
class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400"
>{{- secret.name -}}</td>
<td
class="max-w-sm p-4 overflow-hidden text-base font-normal text-gray-500 truncate xl:max-w-xs dark:text-gray-400 secret-client-list"
id="secret-client-list-{{ secret.name }}"
>
{% if secret.clients %}
{% for client in secret.clients %}
<span class="bg-gray-100 text-gray-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-sm me-2 dark:bg-gray-700 dark:text-gray-400 border border-gray-500 ">
<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M12 4a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm-2 9a4 4 0 0 0-4 4v1a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-1a4 4 0 0 0-4-4h-4Z" clip-rule="evenodd"/>
</svg>
{{- client.name -}}
</span>
{% endfor %}
{% else %}
<p class="italic font-small">No clients</p>
{% endif %}
</td>
<td class="p-4 space-x-2 whitespace-nowrap">
<button
type="button"
data-modal-target="client-secret-modal-{{secret.name}}" data-modal-toggle="client-secret-modal-{{ secret.name }}"
class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white rounded-lg bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
id="manage-client-access-btn-{{ secret.name }}"
>
<svg
class="w-4 h-4 mr-2"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"
></path>
<path
fill-rule="evenodd"
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
clip-rule="evenodd"
></path>
</svg>
Manage Client Access
</button>
<button
type="button"
class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-red-700 rounded-lg hover:bg-red-800 focus:ring-4 focus:ring-red-300 dark:focus:ring-red-900"
hx-delete="/secrets/{{ secret.name }}"
hx-confirm="Are you sure you want to delete the secret {{ secret.name }}?"
hx-target="#secretsContent"
id="delete-secret-btn-{{ secret.name }}"
>
<svg
class="w-4 h-4 mr-2"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clip-rule="evenodd"
></path>
</svg>
Delete item
</button>
</td>
</tr>

View File

@ -5,11 +5,14 @@
import logging import logging
import secrets as pysecrets import secrets as pysecrets
from typing import Annotated, Any from typing import Annotated, Any
from fastapi import APIRouter, Depends, Form, Request from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from pydantic import BaseModel, BeforeValidator, Field from pydantic import BaseModel, BeforeValidator, Field
from sshecret_admin.auth import LocalUserInfo from sshecret_admin.auth import LocalUserInfo
from sshecret_admin.services import AdminBackend from sshecret_admin.services import AdminBackend
from sshecret_admin.services.models import SecretGroupCreate
from sshecret.backend.models import Operation
from ..dependencies import FrontendDependencies from ..dependencies import FrontendDependencies
@ -55,70 +58,356 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
templates = dependencies.templates templates = dependencies.templates
@app.get("/secrets/") @app.get("/secrets/")
async def get_secrets( async def get_secrets_tree(
request: Request, request: Request,
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
): ):
"""Get secrets index page.""" groups = await admin.get_secret_groups()
secrets = await admin.get_detailed_secrets() LOG.info("Groups: %r", groups)
clients = await admin.get_clients()
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"secrets/index.html.j2", "secrets/index.html.j2",
{ {
"page_title": "Secrets", "groups": groups,
"secrets": secrets,
"user": current_user, "user": current_user,
},
)
@app.get("/secrets/partial/root_group")
async def get_root_group(
request: Request,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
):
"""Get root group."""
clients = await admin.get_clients()
return templates.TemplateResponse(
request,
"secrets/partials/edit_root.html.j2",
{
"clients": clients, "clients": clients,
}, },
) )
@app.post("/secrets/") @app.get("/secrets/partial/secret/{name}")
async def add_secret( async def get_secret_tree_detail(
request: Request, request: Request,
name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
):
"""Get partial secret detail."""
secret = await admin.get_secret(name)
groups = await admin.get_secret_groups()
events = await admin.get_audit_log_detailed(limit=10, secret_name=name)
if not secret:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found"
)
return templates.TemplateResponse(
request,
"secrets/partials/tree_detail.html.j2",
{
"secret": secret,
"groups": groups,
"events": events,
},
)
@app.get("/secrets/partial/group/{name}")
async def get_group_details(
request: Request,
name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
):
"""Get group details partial."""
group = await admin.get_secret_group(name)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Group not found"
)
clients = await admin.get_clients()
return templates.TemplateResponse(
request,
"secrets/partials/group_detail.html.j2",
{
"name": group.group_name,
"description": group.description,
"clients": clients,
},
)
@app.delete("/secrets/group/{name}")
async def delete_secret_group(
request: Request,
name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
):
"""Delete a secret group."""
group = await admin.get_secret_group(name)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Group not found"
)
await admin.delete_secret_group(name)
headers = {"Hx-Refresh": "true"}
return templates.TemplateResponse(
request,
"secrets/partials/default_detail.html.j2",
headers=headers,
)
@app.post("/secrets/group/")
async def create_group(
request: Request,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
group: Annotated[SecretGroupCreate, Form()],
):
"""Create group."""
LOG.info("Creating secret group: %r", group)
await admin.add_secret_group(
group_name=group.name,
description=group.description,
parent_group=group.parent_group,
)
headers = {"Hx-Refresh": "true"}
return templates.TemplateResponse(
request,
"secrets/partials/default_detail.html.j2",
headers=headers,
)
@app.put("/secrets/partial/group/{name}/description")
async def update_group_description(
request: Request,
name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
description: Annotated[str, Form()],
):
"""Update group description."""
group = await admin.get_secret_group(name)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Group not found"
)
await admin.set_group_description(group_name=name, description=description)
clients = await admin.get_clients()
headers = {"Hx-Refresh": "true"}
return templates.TemplateResponse(
request,
"secrets/partials/group_detail.html.j2",
{
"name": group.group_name,
"description": group.description,
"clients": clients,
},
headers=headers,
)
@app.put("/secrets/partial/secret/{name}/value")
async def update_secret_value_inline(
request: Request,
name: str,
secret_value: Annotated[str, Form()],
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
):
"""Update secret value."""
secret = await admin.get_secret(name)
if not secret:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found"
)
origin = "UNKNOWN"
if request.client:
origin = request.client.host
await admin.write_audit_message(
operation=Operation.UPDATE,
message="Secret was updated via admin interface",
secret_name=name,
origin=origin,
username=current_user.display_name,
)
await admin.update_secret(name, secret_value)
secret = await admin.get_secret(name)
return templates.TemplateResponse(
request,
"secrets/partials/secret_value.html.j2",
{
"secret": secret,
"updated": True,
},
)
@app.get("/secrets/partial/{name}/viewsecret")
async def view_secret_in_tree(
request: Request,
name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
):
"""View secret inline partial."""
secret = await admin.get_secret(name)
if not secret:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found"
)
origin = "UNKNOWN"
if request.client:
origin = request.client.host
await admin.write_audit_message(
operation=Operation.READ,
message="Secret viewed",
secret_name=name,
origin=origin,
username=current_user.display_name,
)
return templates.TemplateResponse(
request,
"secrets/partials/secret_value.html.j2",
{
"secret": secret,
"updated": False,
},
)
@app.post("/secrets/create/group/{name}")
async def add_secret_in_group(
request: Request,
name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
secret: Annotated[CreateSecret, Form()], secret: Annotated[CreateSecret, Form()],
): ):
"""Add secret.""" """Create secret in group."""
LOG.info("secret: %s", secret.model_dump_json(indent=2)) LOG.info("secret: %s", secret.model_dump_json(indent=2))
clients = await admin.get_clients()
if secret.value: if secret.value:
value = secret.value value = secret.value
else: else:
value = pysecrets.token_urlsafe(32) value = pysecrets.token_urlsafe(32)
await admin.add_secret(secret.name, value, secret.clients) await admin.add_secret(secret.name, value, secret.clients, group=name)
secrets = await admin.get_detailed_secrets()
return templates.TemplateResponse( headers = {"Hx-Refresh": "true"}
request, new_secret = await admin.get_secret(secret.name)
"secrets/inner.html.j2", groups = await admin.get_secret_groups()
{ events = await admin.get_audit_log_detailed(limit=10, secret_name=secret.name)
"secrets": secrets,
"clients": clients, if not new_secret:
}, raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found"
) )
@app.delete("/secrets/{name}/clients/{id}") return templates.TemplateResponse(
request,
"secrets/partials/tree_detail.html.j2",
{
"secret": new_secret,
"groups": groups,
"events": events,
},
headers=headers,
)
@app.post("/secrets/create/root")
async def add_secret_in_root(
request: Request,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
secret: Annotated[CreateSecret, Form()],
):
"""Create secret in the root."""
LOG.info("secret: %s", secret.model_dump_json(indent=2))
if secret.value:
value = secret.value
else:
value = pysecrets.token_urlsafe(32)
await admin.add_secret(secret.name, value, secret.clients, group=None)
headers = {"Hx-Refresh": "true"}
new_secret = await admin.get_secret(secret.name)
groups = await admin.get_secret_groups()
events = await admin.get_audit_log_detailed(limit=10, secret_name=secret.name)
if not new_secret:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found"
)
return templates.TemplateResponse(
request,
"secrets/partials/tree_detail.html.j2",
{
"secret": new_secret,
"groups": groups,
"events": events,
},
headers=headers,
)
@app.delete("/secrets/{name}/clients/{client_name}")
async def remove_client_secret_access( async def remove_client_secret_access(
request: Request, request: Request,
name: str, name: str,
id: str, client_name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
): ):
"""Remove a client's access to a secret.""" """Remove a client's access to a secret."""
await admin.delete_client_secret(id, name) client = await admin.get_client(client_name)
if not client:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Client not found."
)
await admin.delete_client_secret(str(client.id), name)
clients = await admin.get_clients() clients = await admin.get_clients()
secrets = await admin.get_detailed_secrets() secret = await admin.get_secret(name)
headers = {"Hx-Refresh": "true"} if not secret:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found."
)
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"secrets/inner.html.j2", "secrets/partials/client_list_inner.html.j2",
{"clients": clients, "secret": secrets}, {"clients": clients, "secret": secret},
headers=headers, )
@app.get("/secrets/{name}/clients/")
async def show_secret_client_add(
request: Request,
name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
):
"""Show partial to add new client to a secret."""
clients = await admin.get_clients()
secret = await admin.get_secret(name)
if not secret:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found."
)
return templates.TemplateResponse(
request,
"secrets/partials/client_assign.html.j2",
{
"clients": clients,
"secret": secret,
},
) )
@app.post("/secrets/{name}/clients/") @app.post("/secrets/{name}/clients/")
@ -130,40 +419,42 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
): ):
"""Add a secret to a client.""" """Add a secret to a client."""
await admin.create_client_secret(client, name) await admin.create_client_secret(client, name)
secret = await admin.get_secret(name)
if not secret:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found."
)
clients = await admin.get_clients() clients = await admin.get_clients()
secrets = await admin.get_detailed_secrets()
headers = {"Hx-Refresh": "true"}
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"secrets/inner.html.j2", "secrets/partials/client_secret_details.html.j2",
{ {
"secret": secret,
"clients": clients, "clients": clients,
"secrets": secrets,
}, },
headers=headers,
) )
@app.delete("/secrets/{name}") # @app.delete("/secrets/{name}")
async def delete_secret( # async def delete_secret(
request: Request, # request: Request,
name: str, # name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], # admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
): # ):
"""Delete a secret.""" # """Delete a secret."""
await admin.delete_secret(name) # await admin.delete_secret(name)
clients = await admin.get_clients() # clients = await admin.get_clients()
secrets = await admin.get_detailed_secrets() # secrets = await admin.get_detailed_secrets()
headers = {"Hx-Refresh": "true"} # headers = {"Hx-Refresh": "true"}
return templates.TemplateResponse( # return templates.TemplateResponse(
request, # request,
"secrets/inner.html.j2", # "secrets/inner.html.j2",
{ # {
"clients": clients, # "clients": clients,
"secrets": secrets, # "secrets": secrets,
}, # },
headers=headers, # headers=headers,
) # )
return app return app

View File

@ -23,7 +23,13 @@ from sshecret.crypto import encrypt_string, load_public_key
from .keepass import PasswordContext, load_password_manager from .keepass import PasswordContext, load_password_manager
from sshecret_admin.core.settings import AdminServerSettings from sshecret_admin.core.settings import AdminServerSettings
from .models import ClientSecretGroup, SecretClientMapping, SecretGroup, SecretView from .models import (
ClientSecretGroup,
ClientSecretGroupList,
SecretClientMapping,
SecretGroup,
SecretView,
)
class ClientManagementError(Exception): class ClientManagementError(Exception):
@ -353,7 +359,7 @@ class AdminBackend:
self, self,
group_filter: str | None = None, group_filter: str | None = None,
regex: bool = True, regex: bool = True,
) -> list[ClientSecretGroup]: ) -> ClientSecretGroupList:
"""Get secret groups. """Get secret groups.
The starting group can be filtered with the group_name argument, which The starting group can be filtered with the group_name argument, which
@ -363,19 +369,33 @@ class AdminBackend:
secrets_mapping = {secret.name: secret for secret in all_secrets} secrets_mapping = {secret.name: secret for secret in all_secrets}
with self.password_manager() as password_manager: with self.password_manager() as password_manager:
all_groups = password_manager.get_secret_groups(group_filter, regex=regex) all_groups = password_manager.get_secret_groups(group_filter, regex=regex)
ungrouped = password_manager.get_ungrouped_secrets()
result: list[ClientSecretGroup] = [] group_result: list[ClientSecretGroup] = []
for group in all_groups: for group in all_groups:
# We have to do this recursively. # We have to do this recursively.
result.append(add_clients_to_secret_group(group, secrets_mapping)) group_result.append(add_clients_to_secret_group(group, secrets_mapping))
result = ClientSecretGroupList(groups=group_result)
if group_filter:
return result
ungrouped_clients: list[SecretClientMapping] = []
for name in ungrouped:
mapping = SecretClientMapping(name=name)
if client_mapping := secrets_mapping.get(name):
mapping.clients = client_mapping.clients
ungrouped_clients.append(mapping)
result.ungrouped = ungrouped_clients
return result return result
async def get_secret_group(self, name: str) -> ClientSecretGroup | None: async def get_secret_group(self, name: str) -> ClientSecretGroup | None:
"""Get a single secret group by name.""" """Get a single secret group by name."""
matches = await self.get_secret_groups(group_filter=name, regex=False) matches = await self.get_secret_groups(group_filter=name, regex=False)
if not matches: if matches.groups:
return matches.groups[0]
return None return None
return matches[0]
async def get_secret(self, name: str) -> SecretView | None: async def get_secret(self, name: str) -> SecretView | None:
"""Get secrets from backend.""" """Get secrets from backend."""
@ -390,10 +410,11 @@ class AdminBackend:
"""Get a secret, including the actual unencrypted value and clients.""" """Get a secret, including the actual unencrypted value and clients."""
with self.password_manager() as password_manager: with self.password_manager() as password_manager:
secret = password_manager.get_secret(name) secret = password_manager.get_secret(name)
secret_group = password_manager.get_entry_group(name)
if not secret: if not secret:
return None return None
secret_view = SecretView(name=name, secret=secret) secret_view = SecretView(name=name, secret=secret, group=secret_group)
secret_mapping = await self.backend.get_secret(name) secret_mapping = await self.backend.get_secret(name)
if secret_mapping: if secret_mapping:
secret_view.clients = secret_mapping.clients secret_view.clients = secret_mapping.clients

View File

@ -134,6 +134,16 @@ class PasswordContext:
raise RuntimeError(f"Cannot get password for entry {entry_name}") raise RuntimeError(f"Cannot get password for entry {entry_name}")
def get_entry_group(self, entry_name: str) -> str | None:
"""Get the group for an entry."""
entry = self._get_entry(entry_name)
if not entry:
return None
if entry.group.is_root_group:
return None
return str(entry.group.name)
def get_secret_groups(self, pattern: str | None = None, regex: bool = True) -> list[SecretGroup]: def get_secret_groups(self, pattern: str | None = None, regex: bool = True) -> list[SecretGroup]:
"""Get secret groups. """Get secret groups.
@ -145,13 +155,19 @@ class PasswordContext:
self.keepass.find_groups(name=pattern, regex=regex), self.keepass.find_groups(name=pattern, regex=regex),
) )
else: else:
all_groups = cast(list[pykeepass.group.Group], self.keepass.groups) groups = self._root_group.subgroups
# We skip the root group
groups = [group for group in all_groups if not group.is_root_group]
secret_groups = [_kp_group_to_secret_group(group) for group in groups] secret_groups = [_kp_group_to_secret_group(group) for group in groups]
return secret_groups return secret_groups
def get_ungrouped_secrets(self) -> list[str]:
"""Get secrets without groups."""
entries: list[str] = []
for entry in self._root_group.entries:
entries.append(str(entry.title))
return entries
def add_group( def add_group(
self, name: str, description: str | None = None, parent_group: str | None = None self, name: str, description: str | None = None, parent_group: str | None = None
) -> None: ) -> None:

View File

@ -33,6 +33,7 @@ class SecretView(BaseModel):
name: str name: str
secret: str secret: str
group: str | None = None
clients: list[str] = Field(default_factory=list) # Clients that have access to it. clients: list[str] = Field(default_factory=list) # Clients that have access to it.
@ -105,6 +106,7 @@ class SecretCreate(SecretUpdate):
{ {
"name": "MySecret", "name": "MySecret",
"clients": ["client-1", "client-2"], "clients": ["client-1", "client-2"],
"group": None,
"value": {"auto_generate": True, "length": 32}, "value": {"auto_generate": True, "length": 32},
}, },
{ {
@ -152,3 +154,10 @@ class SecretGroupCreate(BaseModel):
name: str name: str
description: str | None = None description: str | None = None
parent_group: str | None = None parent_group: str | None = None
class ClientSecretGroupList(BaseModel):
"""Secret group list."""
ungrouped: list[SecretClientMapping] = Field(default_factory=list)
groups: list[ClientSecretGroup] = Field(default_factory=list)

View File

@ -39,15 +39,16 @@
--color-teal-300: oklch(85.5% 0.138 181.071); --color-teal-300: oklch(85.5% 0.138 181.071);
--color-teal-500: oklch(70.4% 0.14 182.503); --color-teal-500: oklch(70.4% 0.14 182.503);
--color-teal-600: oklch(60% 0.118 184.704); --color-teal-600: oklch(60% 0.118 184.704);
--color-teal-700: oklch(51.1% 0.096 186.391);
--color-teal-900: oklch(38.6% 0.063 188.416); --color-teal-900: oklch(38.6% 0.063 188.416);
--color-blue-100: oklch(93.2% 0.032 255.585);
--color-blue-200: oklch(88.2% 0.059 254.128); --color-blue-200: oklch(88.2% 0.059 254.128);
--color-blue-300: oklch(80.9% 0.105 251.813); --color-blue-300: oklch(80.9% 0.105 251.813);
--color-blue-400: oklch(70.7% 0.165 254.624);
--color-blue-500: oklch(62.3% 0.214 259.815); --color-blue-500: oklch(62.3% 0.214 259.815);
--color-blue-600: oklch(54.6% 0.245 262.881); --color-blue-600: oklch(54.6% 0.245 262.881);
--color-blue-700: oklch(48.8% 0.243 264.376); --color-blue-700: oklch(48.8% 0.243 264.376);
--color-blue-800: oklch(42.4% 0.199 265.638); --color-blue-800: oklch(42.4% 0.199 265.638);
--color-indigo-200: oklch(87% 0.065 274.039); --color-blue-900: oklch(37.9% 0.146 265.522);
--color-indigo-500: oklch(58.5% 0.233 277.117); --color-indigo-500: oklch(58.5% 0.233 277.117);
--color-indigo-600: oklch(51.1% 0.262 276.966); --color-indigo-600: oklch(51.1% 0.262 276.966);
--color-indigo-700: oklch(45.7% 0.24 277.023); --color-indigo-700: oklch(45.7% 0.24 277.023);
@ -120,6 +121,7 @@
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--animate-spin: spin 1s linear infinite; --animate-spin: spin 1s linear infinite;
--animate-ping: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite; --animate-ping: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;
--animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
--default-transition-duration: 150ms; --default-transition-duration: 150ms;
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
--default-font-family: var(--font-sans); --default-font-family: var(--font-sans);
@ -415,12 +417,18 @@
.m-361 { .m-361 {
margin: calc(var(--spacing) * 361); margin: calc(var(--spacing) * 361);
} }
.mx-2\.5 {
margin-inline: calc(var(--spacing) * 2.5);
}
.mx-3 { .mx-3 {
margin-inline: calc(var(--spacing) * 3); margin-inline: calc(var(--spacing) * 3);
} }
.mx-4 { .mx-4 {
margin-inline: calc(var(--spacing) * 4); margin-inline: calc(var(--spacing) * 4);
} }
.mx-\[1rem\] {
margin-inline: 1rem;
}
.mx-auto { .mx-auto {
margin-inline: auto; margin-inline: auto;
} }
@ -478,6 +486,9 @@
.mt-2 { .mt-2 {
margin-top: calc(var(--spacing) * 2); margin-top: calc(var(--spacing) * 2);
} }
.mt-2\.5 {
margin-top: calc(var(--spacing) * 2.5);
}
.mt-3 { .mt-3 {
margin-top: calc(var(--spacing) * 3); margin-top: calc(var(--spacing) * 3);
} }
@ -541,6 +552,9 @@
.mb-2 { .mb-2 {
margin-bottom: calc(var(--spacing) * 2); margin-bottom: calc(var(--spacing) * 2);
} }
.mb-2\.5 {
margin-bottom: calc(var(--spacing) * 2.5);
}
.mb-3 { .mb-3 {
margin-bottom: calc(var(--spacing) * 3); margin-bottom: calc(var(--spacing) * 3);
} }
@ -583,6 +597,9 @@
.ml-6 { .ml-6 {
margin-left: calc(var(--spacing) * 6); margin-left: calc(var(--spacing) * 6);
} }
.ml-\[1rem\] {
margin-left: 1rem;
}
.ml-auto { .ml-auto {
margin-left: auto; margin-left: auto;
} }
@ -667,6 +684,9 @@
.h-32 { .h-32 {
height: calc(var(--spacing) * 32); height: calc(var(--spacing) * 32);
} }
.h-48 {
height: calc(var(--spacing) * 48);
}
.h-\[0\.125rem\] { .h-\[0\.125rem\] {
height: 0.125rem; height: 0.125rem;
} }
@ -736,12 +756,21 @@
.w-11 { .w-11 {
width: calc(var(--spacing) * 11); width: calc(var(--spacing) * 11);
} }
.w-12 {
width: calc(var(--spacing) * 12);
}
.w-16 { .w-16 {
width: calc(var(--spacing) * 16); width: calc(var(--spacing) * 16);
} }
.w-24 {
width: calc(var(--spacing) * 24);
}
.w-28 { .w-28 {
width: calc(var(--spacing) * 28); width: calc(var(--spacing) * 28);
} }
.w-32 {
width: calc(var(--spacing) * 32);
}
.w-36 { .w-36 {
width: calc(var(--spacing) * 36); width: calc(var(--spacing) * 36);
} }
@ -863,6 +892,9 @@
.animate-ping { .animate-ping {
animation: var(--animate-ping); animation: var(--animate-ping);
} }
.animate-pulse {
animation: var(--animate-pulse);
}
.animate-spin { .animate-spin {
animation: var(--animate-spin); animation: var(--animate-spin);
} }
@ -1196,6 +1228,9 @@
--tw-border-style: solid; --tw-border-style: solid;
border-style: solid; border-style: solid;
} }
.border-blue-700 {
border-color: var(--color-blue-700);
}
.border-gray-100 { .border-gray-100 {
border-color: var(--color-gray-100); border-color: var(--color-gray-100);
} }
@ -1208,6 +1243,9 @@
.border-gray-500 { .border-gray-500 {
border-color: var(--color-gray-500); border-color: var(--color-gray-500);
} }
.border-gray-900 {
border-color: var(--color-gray-900);
}
.border-green-100 { .border-green-100 {
border-color: var(--color-green-100); border-color: var(--color-green-100);
} }
@ -1262,6 +1300,9 @@
background-color: color-mix(in oklab, var(--color-black) 50%, transparent); background-color: color-mix(in oklab, var(--color-black) 50%, transparent);
} }
} }
.bg-blue-100 {
background-color: var(--color-blue-100);
}
.bg-blue-200 { .bg-blue-200 {
background-color: var(--color-blue-200); background-color: var(--color-blue-200);
} }
@ -1295,6 +1336,9 @@
.bg-gray-200 { .bg-gray-200 {
background-color: var(--color-gray-200); background-color: var(--color-gray-200);
} }
.bg-gray-300 {
background-color: var(--color-gray-300);
}
.bg-gray-800 { .bg-gray-800 {
background-color: var(--color-gray-800); background-color: var(--color-gray-800);
} }
@ -1692,12 +1736,21 @@
.text-\[\#f84525\] { .text-\[\#f84525\] {
color: #f84525; color: #f84525;
} }
.text-blue-400 {
color: var(--color-blue-400);
}
.text-blue-500 { .text-blue-500 {
color: var(--color-blue-500); color: var(--color-blue-500);
} }
.text-blue-600 { .text-blue-600 {
color: var(--color-blue-600); color: var(--color-blue-600);
} }
.text-blue-700 {
color: var(--color-blue-700);
}
.text-blue-800 {
color: var(--color-blue-800);
}
.text-emerald-500 { .text-emerald-500 {
color: var(--color-emerald-500); color: var(--color-emerald-500);
} }
@ -2083,6 +2136,27 @@
} }
} }
} }
.hover\:bg-blue-200 {
&:hover {
@media (hover: hover) {
background-color: var(--color-blue-200);
}
}
}
.hover\:bg-blue-700 {
&:hover {
@media (hover: hover) {
background-color: var(--color-blue-700);
}
}
}
.hover\:bg-blue-800 {
&:hover {
@media (hover: hover) {
background-color: var(--color-blue-800);
}
}
}
.hover\:bg-gray-50 { .hover\:bg-gray-50 {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@ -2174,6 +2248,13 @@
} }
} }
} }
.hover\:text-blue-900 {
&:hover {
@media (hover: hover) {
color: var(--color-blue-900);
}
}
}
.hover\:text-gray-100 { .hover\:text-gray-100 {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@ -2316,6 +2397,11 @@
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
} }
} }
.focus\:ring-blue-300 {
&:focus {
--tw-ring-color: var(--color-blue-300);
}
}
.focus\:ring-blue-500 { .focus\:ring-blue-500 {
&:focus { &:focus {
--tw-ring-color: var(--color-blue-500); --tw-ring-color: var(--color-blue-500);
@ -2538,6 +2624,11 @@
padding-inline: calc(var(--spacing) * 4); padding-inline: calc(var(--spacing) * 4);
} }
} }
.sm\:px-16 {
@media (width >= 40rem) {
padding-inline: calc(var(--spacing) * 16);
}
}
.sm\:py-2 { .sm\:py-2 {
@media (width >= 40rem) { @media (width >= 40rem) {
padding-block: calc(var(--spacing) * 2); padding-block: calc(var(--spacing) * 2);
@ -3013,6 +3104,12 @@
line-height: var(--tw-leading, var(--text-6xl--line-height)); line-height: var(--tw-leading, var(--text-6xl--line-height));
} }
} }
.lg\:text-xl {
@media (width >= 64rem) {
font-size: var(--text-xl);
line-height: var(--tw-leading, var(--text-xl--line-height));
}
}
.lg\:hover\:underline { .lg\:hover\:underline {
@media (width >= 64rem) { @media (width >= 64rem) {
&:hover { &:hover {
@ -3140,6 +3237,11 @@
padding-inline: calc(var(--spacing) * 0); padding-inline: calc(var(--spacing) * 0);
} }
} }
.xl\:px-48 {
@media (width >= 80rem) {
padding-inline: calc(var(--spacing) * 48);
}
}
.xl\:py-24 { .xl\:py-24 {
@media (width >= 80rem) { @media (width >= 80rem) {
padding-block: calc(var(--spacing) * 24); padding-block: calc(var(--spacing) * 24);
@ -3231,6 +3333,11 @@
} }
} }
} }
.dark\:border-blue-500 {
&:where(.dark, .dark *) {
border-color: var(--color-blue-500);
}
}
.dark\:border-gray-500 { .dark\:border-gray-500 {
&:where(.dark, .dark *) { &:where(.dark, .dark *) {
border-color: var(--color-gray-500); border-color: var(--color-gray-500);
@ -3291,6 +3398,11 @@
border-color: var(--color-red-800); border-color: var(--color-red-800);
} }
} }
.dark\:bg-blue-900 {
&:where(.dark, .dark *) {
background-color: var(--color-blue-900);
}
}
.dark\:bg-gray-600 { .dark\:bg-gray-600 {
&:where(.dark, .dark *) { &:where(.dark, .dark *) {
background-color: var(--color-gray-600); background-color: var(--color-gray-600);
@ -3359,6 +3471,11 @@
background-color: var(--color-teal-900); background-color: var(--color-teal-900);
} }
} }
.dark\:text-blue-300 {
&:where(.dark, .dark *) {
color: var(--color-blue-300);
}
}
.dark\:text-blue-500 { .dark\:text-blue-500 {
&:where(.dark, .dark *) { &:where(.dark, .dark *) {
color: var(--color-blue-500); color: var(--color-blue-500);
@ -3399,6 +3516,11 @@
color: var(--color-gray-600); color: var(--color-gray-600);
} }
} }
.dark\:text-gray-700 {
&:where(.dark, .dark *) {
color: var(--color-gray-700);
}
}
.dark\:text-green-400 { .dark\:text-green-400 {
&:where(.dark, .dark *) { &:where(.dark, .dark *) {
color: var(--color-green-400); color: var(--color-green-400);
@ -3536,6 +3658,24 @@
} }
} }
} }
.dark\:hover\:bg-blue-500 {
&:where(.dark, .dark *) {
&:hover {
@media (hover: hover) {
background-color: var(--color-blue-500);
}
}
}
}
.dark\:hover\:bg-blue-800 {
&:where(.dark, .dark *) {
&:hover {
@media (hover: hover) {
background-color: var(--color-blue-800);
}
}
}
}
.dark\:hover\:bg-gray-600 { .dark\:hover\:bg-gray-600 {
&:where(.dark, .dark *) { &:where(.dark, .dark *) {
&:hover { &:hover {
@ -3572,6 +3712,15 @@
} }
} }
} }
.dark\:hover\:text-blue-300 {
&:where(.dark, .dark *) {
&:hover {
@media (hover: hover) {
color: var(--color-blue-300);
}
}
}
}
.dark\:hover\:text-gray-200 { .dark\:hover\:text-gray-200 {
&:where(.dark, .dark *) { &:where(.dark, .dark *) {
&:hover { &:hover {
@ -3675,6 +3824,13 @@
} }
} }
} }
.dark\:focus\:ring-blue-800 {
&:where(.dark, .dark *) {
&:focus {
--tw-ring-color: var(--color-blue-800);
}
}
}
.dark\:focus\:ring-gray-600 { .dark\:focus\:ring-gray-600 {
&:where(.dark, .dark *) { &:where(.dark, .dark *) {
&:focus { &:focus {
@ -3976,6 +4132,11 @@
opacity: 0; opacity: 0;
} }
} }
@keyframes pulse {
50% {
opacity: 0.5;
}
}
@layer properties { @layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
*, ::before, ::after, ::backdrop { *, ::before, ::after, ::backdrop {

View File

@ -0,0 +1,10 @@
sl-avatar {
--size: 24pt;
}
.tree-with-lines {
--indent-guide-width: 1px;
}
.tree-post-button {
}

View File

@ -1,7 +1,7 @@
module.exports = { module.exports = {
content: [ content: [
"./src/sshecret_admin/templates/**/*.html", "./src/sshecret_admin/**/*.html",
"./src/sshecret_admin/templates/**/*.html.j2", "./src/sshecret_admin/**/*.html.j2",
"./src/sshecret_admin/static/**/*.js", "./src/sshecret_admin/static/**/*.js",
], ],
safelist: [ safelist: [

View File

@ -254,6 +254,7 @@ def test_delete_group(password_database: pykeepass.PyKeePass) -> None:
secrets = context.get_available_secrets() secrets = context.get_available_secrets()
assert len(secrets) == 10 assert len(secrets) == 10
def test_get_specific_group(password_database: pykeepass.PyKeePass) -> None: def test_get_specific_group(password_database: pykeepass.PyKeePass) -> None:
"""Test fetching a specific group.""" """Test fetching a specific group."""
context = PasswordContext(password_database) context = PasswordContext(password_database)
@ -266,3 +267,19 @@ def test_get_specific_group(password_database: pykeepass.PyKeePass) -> None:
assert len(results) == 1 assert len(results) == 1
# Check if the parent reference is available. # Check if the parent reference is available.
assert results[0].parent_group is not None assert results[0].parent_group is not None
def test_get_ungrouped_secrets(password_database: pykeepass.PyKeePass) -> None:
"""Test fetching secrets without groups."""
context = PasswordContext(password_database)
context.add_group("test_group", "A test group")
for n in range(7):
context.add_entry(f"grouped-{n}", "foo", group_name="test_group")
for n in range(5):
context.add_entry(f"ungrouped-{n}", "bar")
ungrouped = context.get_ungrouped_secrets()
assert len(ungrouped) == 5
for entry in ungrouped:
assert entry.startswith("ungrouped-")