Compare commits

...

2 Commits

Author SHA1 Message Date
bce372a1d1 Refactor frontend views
All checks were successful
Build and push image / build-containers (push) Successful in 10m14s
2025-06-14 21:58:21 +02:00
b3debd3ed2 Finalize secret tree page 2025-06-11 19:10:00 +02:00
35 changed files with 1294 additions and 570 deletions

View File

@ -1,4 +1,4 @@
<div> <div class="flowbite-init-target">
<div class="flex flex-col"> <div class="flex flex-col">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<div class="inline-block min-w-full align-middle"> <div class="inline-block min-w-full align-middle">

View File

@ -1,82 +1,46 @@
<tr {% extends "/dashboard/_base.html" %} {% block content %}
class="hover:bg-gray-100 dark:hover:bg-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">
id="client-{{ client.id }}" <div class="w-full mb-1">
> <div class="mb-4">
<td <nav class="flex mb-5" aria-label="Breadcrumb">
class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400" <ol class="inline-flex items-center space-x-1 text-sm font-medium md:space-x-2">
> <li class="inline-flex items-center">
{{-client.name -}} <a href="/" class="inline-flex items-center text-gray-700 hover:text-primary-600 dark:text-gray-300 dark:hover:text-white">
</td> <svg class="w-5 h-5 mr-2.5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"></path></svg>
<td Home
class="p-4 text-base font-medium text-gray-900 whitespace-nowrap dark:text-white" </a>
> </li>
{{- client.id -}} <li>
</td> <div class="flex items-center">
<td <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>
class="max-w-sm p-4 overflow-hidden text-base font-normal text-gray-500 truncate xl:max-w-xs dark:text-gray-400" <span class="ml-1 text-gray-400 md:ml-2 dark:text-gray-500" aria-current="page">Clients</span>
> </div>
{{- client.description -}} </li>
</td> <li>
<td <div class="flex items-center">
class="max-w-sm p-4 text-base font-normal text-gray-500 truncate xl:max-w-xs dark:text-gray-400" <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">{{ client.name }}</span>
{{- client.secrets|length -}} </div>
</td> </li>
<td </ol>
class="max-w-sm p-4 text-base font-normal text-gray-500 truncate xl:max-w-xs dark:text-gray-400" </nav>
>
{{- client.policies|join(', ') -}}
</td>
<td class="p-4 space-x-2 whitespace-nowrap">
<button </div>
type="button"
id="updateClientButton-{{ client.id }}" <div class="grid w-full grid-cols-1 gap-4 mt-4 xl:grid-cols-3">
data-drawer-target="drawer-update-client-{{ client.id }}" <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 h-full" id="client-tree">
data-drawer-show="drawer-update-client-{{ client.id }}" {% include '/clients/partials/tree.html.j2' %}
aria-controls="drawer-update-client-{{ client.id }}" </div>
data-drawer-placement="right" <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">
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" <div class="w-full" id="clientdetails">
> {% include '/clients/partials/client_details.html.j2' %}
<svg </div>
class="w-4 h-4 mr-2" </div>
fill="currentColor" </div>
viewBox="0 0 20 20" </div>
xmlns="http://www.w3.org/2000/svg"
> </div>
<path {% include '/clients/partials/drawer_create.html.j2' %}
d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"
></path> {% endblock %}
<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>
Update
</button>
<button
type="button"
id="deleteClientButton-{{ client.id }}"
data-drawer-target="drawer-delete-client-{{ client.id }}"
data-drawer-show="drawer-delete-client-{{ client.id }}"
aria-controls="drawer-delete-client-{{ client.id }}"
data-drawer-placement="right"
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"
>
<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

@ -1,38 +0,0 @@
<div
id="drawer-create-client-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 Client
</h5>
<button
type="button"
data-drawer-dismiss="drawer-create-client-default"
aria-controls="drawer-create-client-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="/clients/" hx-target="#clientContent">
{% include '/clients/drawer_client_create_inner.html.j2' %}
</form>
</div>

View File

@ -1,108 +0,0 @@
<div class="space-y-4">
<div>
<label
for="name"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Name</label
>
<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="Client name"
required=""
/>
</div>
<div>
<label
for="description"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Description</label
>
<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="Client description"
/>
</div>
<div>
<label
for="sources"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Allowed subnets or IPs</label
>
<p
id="helper-text-explanation"
class="mt-2 text-sm text-gray-500 dark:text-gray-400"
>
Separate multiple entries with comma.
</p>
<input
type="text"
name="sources"
id="sources"
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="0.0.0.0/0"
value="0.0.0.0/0"
hx-post="/clients/validate/source"
hx-target="#clientSourceValidation"
/>
<span id="clientSourceValidation"></span>
</div>
<div>
<label
for="public_key"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Public Key</label
>
<textarea
id="public_key"
name="public_key"
rows="4"
class="block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-primary-500 focus:border-primary-500 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="Enter RSA SSH Public Key here"
hx-post="/clients/validate/public_key"
hx-target="#clientPublicKeyValidation"
></textarea>
<span id="clientPublicKeyValidation"></span>
</div>
<div
class="bottom-0 left-0 flex justify-center w-full pb-4 space-x-4 md:px-4 md:absolute"
>
<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
</button>
<button
type="button"
data-drawer-dismiss="drawer-create-client-default"
aria-controls="drawer-create-client-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>

View File

@ -1,67 +0,0 @@
<div
id="drawer-delete-client-{{ client.id }}"
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 text-sm font-semibold text-gray-500 uppercase dark:text-gray-400"
>
Delete Client {{client.name}}
</h5>
<button
type="button"
data-drawer-dismiss="drawer-delete-client-{{ client.id }}"
aria-controls="drawer-delete-client-{{ client.id }}"
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>
<svg
class="w-10 h-10 mt-8 mb-4 text-red-600"
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="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<h3 class="mb-6 text-lg text-gray-500 dark:text-gray-400">
Are you sure you want to delete this client?
</h3>
<button
type="button"
class="text-white bg-red-600 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm inline-flex items-center px-3 py-2.5 text-center mr-2 dark:focus:ring-red-900"
hx-delete="/clients/{{ client.id }}"
hx-target="#clientContent"
>
Yes, delete the client
</button>
<a
href="#"
class="text-gray-900 bg-white hover:bg-gray-100 focus:ring-4 focus:ring-primary-300 border border-gray-200 font-medium inline-flex items-center rounded-lg text-sm px-3 py-2.5 text-center dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-gray-700"
data-drawer-hide="drawer-delete-client-{{ client.id }}"
>
No, cancel
</a>
</div>

View File

@ -1,3 +0,0 @@
<template>
{% include '/clients/inner.html.j2' %}
</template>

View File

@ -15,31 +15,27 @@
<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">Clients</span> <span class="ml-1 text-gray-400 md:ml-2 dark:text-gray-500" aria-current="page">Clients</span>
</svg>
</div> </div>
</li> </li>
</ol> </ol>
</nav> </nav>
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Clients</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 h-full" id="client-tree">
<label for="client-search" class="sr-only">Search</label> {% include '/clients/partials/tree.html.j2' %}
<div class="relative w-48 mt-1 sm:w-64 xl:w-96"> </div>
<input type="search" name="query" id="client-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 clients" hx-post="/clients/query" hx-trigger="keyup changed delay:500ms, query" hx-target="#clientContent"> <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">
<div class="w-full" id="clientdetails">
<h3 class="mb-4 text-sm italic text-gray-400 dark:text-white">Click an item to view details</h3>
</div> </div>
</div> </div>
<button id="createClientButton" 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-client-default" data-drawer-show="drawer-create-client-default" aria-controls="drawer-create-client-default" data-drawer-placement="right">
Add new client
</button>
</div> </div>
</div> </div>
</div>
<div id="clientContent">
{% include '/clients/inner.html.j2' %}
</div>
{% include '/clients/drawer_client_create.html.j2' %} </div>
{% include '/clients/partials/drawer_create.html.j2' %}
{% endblock %} {% endblock %}

View File

@ -0,0 +1,180 @@
<!-- menu -->
<div class="flowbite-init-target">
<div class="flex justify-end px-4">
<button id="client-menu-button" data-dropdown-toggle="client-edit-menu" class="inline-block text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:ring-4 focus:outline-none focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-1.5" type="button">
<span class="sr-only">Open dropdown</span>
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 3">
<path d="M2 0a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm6.041 0a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM14 0a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Z"/>
</svg>
</button>
<!-- Dropdown menu -->
<div id="client-edit-menu" class="z-10 hidden text-base list-none bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700">
<ul class="py-2" aria-labelledby="client-menu-button">
<li>
<a
href="#"
data-drawer-target="drawer-update-client-{{ client.id }}"
data-drawer-show="drawer-update-client-{{ client.id }}"
aria-controls="drawer-update-client-{{ client.id }}"
data-drawer-placement="right"
class="block px-4 py-2 text-sm text-gray-900 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white"
>
Edit
</a>
</li>
<li>
<a
href="#"
class="block px-4 py-2 text-sm text-red-600 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white"
hx-delete="/clients/{{client.id}}"
hx-target="this"
hx-indicator="#client-spinner"
hx-confirm="Really delete this client?"
>
Delete
</a>
</li>
</ul>
</div>
</div>
<sl-tab-group placement="end">
<sl-tab slot="nav" panel="client_data">Client Data</sl-tab>
<sl-tab slot="nav" panel="events">Events</sl-tab>
<sl-tab-panel name="client_data">
<div id="client_details">
<div class="w-full p-2">
<div class="px-4 sm:px-0">
<h3 class="text-base/7 font-semibold text-gray-900">{{client.name}}</h3>
{% if client.description %}
<p class="mt-1 max-w-2xl text-sm/6 text-gray-500">{{ client.description }}</p>
{% endif %}
</div>
<div class="mt-6 border-t border-gray-100">
<dl class="divide-y divide-gray-100">
<div class="px-4 py-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm/6 font-medium text-gray-900">Client ID</dt>
<dd class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0">{{client.id}}</dd>
</div>
<div class="px-4 py-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm/6 font-medium text-gray-900">Client Description</dt>
<dd class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0">{{client.description}}</dd>
</div>
<div class="px-4 py-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm/6 font-medium text-gray-900">Client Version</dt>
<dd class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0">{{client.version}}</dd>
</div>
<div class="px-4 py-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm/6 font-medium text-gray-900">Public Key</dt>
<dd class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0 truncate">{{client.public_key}}</dd>
</div>
<div class="px-4 py-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm/6 font-medium text-gray-900">Assigned Secrets</dt>
<dd class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0">{{client.secrets|length}}</dd>
</div>
<div class="px-4 py-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm/6 font-medium text-gray-900">Allowed sources</dt>
<dd class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0">{{client.policies|join(', ')}}</dd>
</div>
</dl>
</div>
</div>
</div>
</sl-tab-panel>
<sl-tab-panel name="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-tab-panel>
</sl-tab-group>
</div>
{% include '/clients/partials/drawer_edit.html.j2' %}

View File

@ -0,0 +1,164 @@
<div
id="drawer-create-client-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 text-sm font-semibold text-gray-500 uppercase dark:text-gray-400"
>
New Client
</h5>
<button
type="button"
data-drawer-dismiss="drawer-create-client-default"
aria-controls="drawer-create-client-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>
<div
class="htmx-indicator mb-6"
id="client-create-spinner">
<div role="status">
<svg aria-hidden="true" class="w-4 h-4 me-2 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>
<form
hx-post="/clients/"
>
<div class="space-y-4">
<div>
<label
for="name"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Name</label
>
<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="Client name"
required=""
/>
</div>
<div>
<label
for="description"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Description</label
>
<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="Client description"
/>
</div>
<div>
<label
for="sources"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Allowed subnets or IPs</label
>
<p
id="helper-text-explanation"
class="mt-2 text-sm text-gray-500 dark:text-gray-400"
>
Separate multiple entries with comma.
</p>
<input
type="text"
name="sources"
id="sources"
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="0.0.0.0/0, ::/0"
hx-post="/clients/validate/source"
hx-target="#clientSourceValidation"
/>
<span id="clientSourceValidation"></span>
</div>
<div>
<label
for="public_key"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Public Key</label
>
<textarea
id="public_key"
name="public_key"
rows="4"
class="block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-primary-500 focus:border-primary-500 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="Enter RSA SSH Public Key here"
hx-post="/clients/validate/public_key"
hx-target="#clientPublicKeyValidation"
></textarea>
<span id="clientPublicKeyValidation"></span>
</div>
<div
class="mt-2 text-sm text-red-600 dark:text-red-500"
id="client-create-error"
>
</div>
<div
class="bottom-0 left-0 flex justify-center w-full pb-4 space-x-4 md:px-4 md:absolute"
>
<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
</button>
<button
type="button"
data-drawer-dismiss="drawer-create-client-default"
aria-controls="drawer-create-client-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>
</form>
</div>

View File

@ -5,19 +5,15 @@
aria-labelledby="drawer-label-{{ client.id }}" aria-labelledby="drawer-label-{{ client.id }}"
aria-hidden="true" aria-hidden="true"
> >
<h5 <h5
id="drawer-label-{{ client.id }}" id="drawer-label-{{ client.id }}"
class="inline-flex items-center mb-6 text-sm font-semibold text-gray-500 uppercase dark:text-gray-400" class="inline-flex items-center text-sm font-semibold text-gray-500 uppercase dark:text-gray-400"
> >
<div role="status" class="mr-2 htmx-indicator" id="spinner-{{ client.id}}">
<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>
Update Client Update Client
</h5> </h5>
<button <button
type="button" type="button"
data-drawer-dismiss="drawer-update-client-{{ client.id }}" data-drawer-dismiss="drawer-update-client-{{ client.id }}"
@ -39,10 +35,19 @@
</svg> </svg>
<span class="sr-only">Close menu</span> <span class="sr-only">Close menu</span>
</button> </button>
<div
class="htmx-indicator mb-6"
id="client-update-spinner">
<div role="status">
<svg aria-hidden="true" class="w-4 h-4 me-2 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>
<form <form
hx-put="/clients/{{ client.id }}" hx-put="/clients/{{ client.id }}"
hx-target="#clientContent" hx-target="#clientdetails"
hx-indicator="spinner-{{ client.id }}" hx-indicator="#client-update-spinner"
> >
<input type="hidden" name="id" value="{{ client.id }}" /> <input type="hidden" name="id" value="{{ client.id }}" />
<div class="space-y-4"> <div class="space-y-4">
@ -94,7 +99,7 @@
type="text" type="text"
name="sources" name="sources"
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" 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="0.0.0.0/0" placeholder="0.0.0.0/0, ::/0"
id="sources-{{client.id}}" id="sources-{{client.id}}"
hx-post="/clients/validate/source" hx-post="/clients/validate/source"
hx-target="#clientSourceValidation-{{ client.id }}" hx-target="#clientSourceValidation-{{ client.id }}"
@ -114,8 +119,7 @@
id="helper-text-explanation-{{ client.id }}" id="helper-text-explanation-{{ client.id }}"
class="mt-2 text-sm text-gray-500 dark:text-gray-400" class="mt-2 text-sm text-gray-500 dark:text-gray-400"
> >
Note that updating the key will invalidate all secrets associated with Note that this will create a new version of the client, and any existing secrets will no longer be accessible.
this client.
</p> </p>
<textarea <textarea
@ -134,6 +138,11 @@
</div> </div>
</div> </div>
<div> <div>
<div
class="mt-2 text-sm text-red-600 dark:text-red-500"
id="client-update-error"
>
</div>
<div <div
class="bottom-0 left-0 flex justify-center w-full pb-4 mt-4 space-x-4 sm:absolute sm:px-4 sm:mt-0" class="bottom-0 left-0 flex justify-center w-full pb-4 mt-4 space-x-4 sm:absolute sm:px-4 sm:mt-0"
@ -148,10 +157,10 @@
type="button" type="button"
class="w-full justify-center text-red-600 inline-flex items-center hover:text-white border border-red-600 hover:bg-red-600 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:border-red-500 dark:text-red-500 dark:hover:text-white dark:hover:bg-red-600 dark:focus:ring-red-900" class="w-full justify-center text-red-600 inline-flex items-center hover:text-white border border-red-600 hover:bg-red-600 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:border-red-500 dark:text-red-500 dark:hover:text-white dark:hover:bg-red-600 dark:focus:ring-red-900"
hx-delete="/clients/{{ client.id }}" hx-delete="/clients/{{ client.id }}"
hx-indicator="#client-update-spinner"
hx-confirm="Are you sure?" hx-confirm="Are you sure?"
hx-target="#clientContent" hx-target="#client-update-error"
id="delete-button-{{ client.id }}" id="delete-button-{{ client.id }}"
> >
<svg <svg
aria-hidden="true" aria-hidden="true"

View File

@ -0,0 +1,73 @@
{% macro display_page(num) %}
<li>
<button
hx-get="/clients/page/{{num}}"
hx-target="#client-tree"
hx-push-url="true"
type="button"
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
{{ num }}
</button>
</li>
{% endmacro %}
{% macro display_current_page(num) %}
<li>
<button type="button" aria-current="page" class="z-10 flex items-center justify-center px-3 h-8 leading-tight text-blue-600 border border-blue-300 bg-blue-50 hover:bg-blue-100 hover:text-blue-700 dark:border-gray-700 dark:bg-gray-700 dark:text-white">
{{ num }}
</button>
</li>
{% endmacro %}
<div class="inline-flex mt-2 xs:mt-0">
<nav aria-label="Page navigation">
<ul class="flex items-center -space-x-px h-8 text-sm">
<li>
{% if pages.is_first %}
<button
type="button"
class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-100 bg-white border border-e-0 border-gray-300 rounded-s-lg hover:bg-gray-100 hover:text-gray-200 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
{% else %}
<button
type="button"
hx-get="/clients/page/{{pages.page - 1}}"
hx-target="#client-tree"
hx-push-url="true"
class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 bg-white border border-e-0 border-gray-300 rounded-s-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
{% endif %}
<span class="sr-only">Previous</span>
<svg class="w-2.5 h-2.5 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 1 1 5l4 4"/>
</svg>
</button>
</li>
{% for p in pages.pages %}
{% if p == pages.page %}
{{ display_current_page(p) }}
{% else %}
{{ display_page(p) }}
{% endif %}
{% endfor %}
<li>
{% if pages.is_last %}
<button
type="button"
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-100 bg-white border border-gray-300 rounded-e-lg hover:bg-gray-100 hover:text-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
{% else %}
<button
hx-get="/clients/page/{{pages.page + 1}}"
hx-target="#client-tree"
hx-push-url="true"
type="button"
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 rounded-e-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
{% endif %}
<span class="sr-only">Next</span>
<svg class="w-2.5 h-2.5 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 9 4-4-4-4"/>
</svg>
</button>
</li>
</ul>
</nav>
</div>

View File

@ -0,0 +1,80 @@
<div class="flex flex-1 flex-col justify-between h-full flowbite-init-target">
<div class="grid grid-cols-2 place-content-between mb-6">
<div>
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Clients</h1>
<div class="flex">
<div
class="htmx-indicator mt-2"
id="client-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>
</div>
<div
class="flex justify-end px-4"
>
<button id="client-tree-menu-button" data-dropdown-toggle="client-tree-menu" class="inline-block text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:ring-4 focus:outline-none focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-1.5" type="button">
<span class="sr-only">Open dropdown</span>
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 3">
<path d="M2 0a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm6.041 0a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM14 0a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Z"/>
</svg>
</button>
<!-- Dropdown menu -->
<div id="client-tree-menu" class="z-10 hidden text-base list-none bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700">
<ul class="py-2" aria-labelledby="client-menu-button">
<li>
<a
href="#"
data-drawer-target="drawer-create-client-default"
data-drawer-show="drawer-create-client-default"
aria-controls="drawer-create-client-default"
data-drawer-placement="right"
class="block px-4 py-2 text-sm text-gray-900 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white"
>
New client
</a>
</li>
</ul>
</div>
</div>
</div>
<div class="flex-1 overflow-auto">
<div class="relative w-full ">
<div class="border-b border-gray-200 py-2 mb-6">
<label for="default-search" class="mb-2 text-sm font-medium text-gray-900 sr-only dark:text-white">Search</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"/>
</svg>
</div>
<input
type="search"
id="client-search"
name="query"
class="block w-full p-2.5 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-gray-900 focus:border-gray-900 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-gray-900 dark:focus:border-gray-900"
placeholder="Search..."
required
hx-post="/clients/query"
hx-trigger="input changed delay:500ms, keyup[key=='Enter']"
hx-target="#client-tree-items"
hx-indicator="#client-spinner"
/>
</div>
</div>
</div>
<div id="client-tree-items">
{% include '/clients/partials/tree_items.html.j2' %}
</div>
</div>
</div>
<script>
{% include '/clients/partials/tree_event.js' %}
</script>

View File

@ -0,0 +1,34 @@
function addTreeListener() {
const tree = document.querySelector("sl-tree");
if (!tree) return;
tree.addEventListener("sl-selection-change", (event) => {
const selectedEl = event.detail.selection[0];
if (!selectedEl) return;
const type = selectedEl.dataset.nodeType;
const clientId = selectedEl.dataset.clientId;
const name = selectedEl.dataset.clientName;
//console.log(`Event on ${type} ${name} ${clientId}`);
if (!type || !clientId) return;
let url = `/clients/client/${encodeURIComponent(clientId)}`;
if (url) {
htmx.ajax("GET", url, {
target: "#clientdetails",
//swap: 'OuterHTML',
indicator: "#client-spinner",
});
}
});
}
document.addEventListener("DOMContentLoaded", () => {
addTreeListener();
});
document.addEventListener("htmx:afterSwap", () => {
addTreeListener();
});

View File

@ -0,0 +1,42 @@
<div class="flowbite-init-target">
{% if more_results %}
<span class="text-gray-400 text-xs italic mt-4">{{more_results}} more results. Narrow search to show them...</span>
{% endif %}
<sl-tree class="full-height-tree">
{% for item in clients %}
<sl-tree-item
id="client-{{ item.id }}"
data-node-type="client"
data-client-id="{{ item.id }}"
data-client-name="{{ item.name }}"
{% if client and client.id == item.id %}
selected
{% endif %}
>
<sl-icon name="person-fill-lock"> </sl-icon>
<span class="px-2">{{item.name}}</span>
{% for secret in item.secrets %}
<sl-tree-item
id="client-{{ item.name }}-secret-{{ secret }}"
data-node-type="secret"
data-secret-client-name="{{ item.name }}"
data-secret-name="{{ secret }}"
>
<sl-icon name="file-lock2"> </sl-icon>
<span class="px-2">{{ secret }}</span>
</sl-tree-item>
{% endfor %}
</sl-tree-item>
{% endfor %}
</sl-tree>
{% if pages %}
<div class="mt-4 text-center flex items-center flex-col border-t border-gray-100">
<span class="text-sm text-gray-700 dark:text-gray-400">
Showing <span class="font-semibold text-gray-900 dark:text-white">{{ pages.offset }}</span> to <span class="font-semibold text-gray-900 dark:text-white">{{ pages.total_pages }}</span> of <span class="font-semibold text-gray-900 dark:text-white">{{ results.total_results }}</span> Entries
</span>
{% include 'clients/partials/pagination.html.j2' %}
</div>
{% endif %}
</div>

View File

@ -12,13 +12,17 @@
{% include '/dashboard/sidebar.html' %} {% include '/dashboard/sidebar.html' %}
{% endif %} {% endif %}
<div id="main-content" class="relative w-full h-full overflow-y-auto bg-gray-50 lg:ml-64 dark:bg-gray-900"> <div id="main-content" class="relative w-full h-full overflow-y-auto bg-gray-50 lg:ml-64 dark:bg-gray-900 flex flex-col md:flex-row flex-grow">
<main> <main class="flex-grow p-4 order-2 md:order-1">
{% block content %} {% block content %}
{% endblock %} {% endblock %}
</main> </main>
{% block sidebar %}
{% endblock %}
</div> </div>
</div> </div>
{% include '/dashboard/_scripts.html' %} {% include '/dashboard/_scripts.html' %}
{% block scripts %}
{% endblock %}
</body> </body>
</html> </html>

View File

@ -7,9 +7,12 @@
<script type="text/javascript" src="{{ url_for('static', path="js/prism.js") }}"></script> <script type="text/javascript" src="{{ url_for('static', path="js/prism.js") }}"></script>
<script> <script>
document.body.addEventListener("htmx:afterSwap", () => { document.body.addEventListener("htmx:afterSwap", (e) => {
if (typeof window.initFlowbite === "function") { const swappedEl = e.target;
window.initFlowbite();
} const initTargets = swappedEl.querySelectorAll(".flowbite-init-target");
}); if (initTargets.length > 0 && typeof window.initFlowbite === "function") {
window.initFlowbite();
}
});
</script> </script>

View File

@ -27,6 +27,11 @@
{% if group.group_name in group_path_nodes %} {% if group.group_name in group_path_nodes %}
expanded="" expanded=""
{% endif %} {% endif %}
{% if selected_group | default(None) %}
{% if group.path == selected_group %}
selected=""
{% endif %}
{% endif %}
{% endif %} {% endif %}
> >
@ -77,7 +82,13 @@
id="secret-group-root-item" id="secret-group-root-item"
data-type="root" data-type="root"
data-name="root" data-name="root"
expanded=""
{% if "/" in group_path_nodes %}
expanded=""
{% endif %}
{% if selected_group == "/"%}
selected=""
{% endif %}
> >
<sl-icon name="folder"> </sl-icon> <sl-icon name="folder"> </sl-icon>
<span class="px-2">Ungrouped</span> <span class="px-2">Ungrouped</span>
@ -115,41 +126,6 @@
</div> </div>
</div> </div>
<script> <script>
document.addEventListener('DOMContentLoaded', () => { {% include '/secrets/partials/tree_event.js' %}
const tree = document.querySelector('sl-tree');
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;
const groupPath = selectedEl.dataset.groupPath;
console.log(`Event on ${type} ${name} path: ${groupPath}`);
if (!type || !name) return;
let url = '';
if (type === 'entry') {
url = `/secrets/secret/${encodeURIComponent(name)}`;
} else if (type === 'group') {
//url = `/secrets/partial/group/${encodeURIComponent(name)}`;
url = `/secrets/group/${encodeURIComponent(groupPath)}`;
} else if (type == 'root') {
url = `/secrets/group/`;
}
if (url) {
htmx.ajax('GET', url, {
target: '#secretdetails',
swap: 'OuterHTML',
indicator: '.secret-spinner'
});
}
});
});
</script> </script>
{% endblock %} {% endblock %}

View File

@ -1,14 +1,14 @@
<div class="w-full"> <div class="w-full">
<div class="mb-4"> <div class="mb-4">
<h3 class="text-xl font-semibold dark:text-white">Group {{name}}</h3> <h3 class="text-xl font-semibold dark:text-white">Group {{group.group_name}}</h3>
{% if description %} {% if description %}
<span class="text-sm text-gray-500 dark:text-gray-400">{{ description }}</span> <span class="text-sm text-gray-500 dark:text-gray-400">{{ group.description }}</span>
{% endif %} {% endif %}
</div> </div>
<sl-details summary="Create secret"> <sl-details summary="Create secret">
<form <form
hx-post="/secrets/create/group/{{ name }}" hx-post="/secrets/create/group/{{ group.group_name }}"
hx-target="#secretdetails" hx-target="#secretdetails"
hx-swap="OuterHTML" hx-swap="OuterHTML"
> >
@ -48,7 +48,7 @@
placeholder="Description" placeholder="Description"
/> />
</div> </div>
<input type="hidden" name="parent_group" value="{{ name }}" /> <input type="hidden" name="parent_group" value="{{ group.group_name }}" />
<button <button
type="submit" 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" 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"
@ -59,7 +59,7 @@
</sl-details> </sl-details>
<sl-details summary="Edit group"> <sl-details summary="Edit group">
<form <form
hx-put="/secrets/partial/group/{{name}}/description" hx-put="/secrets/partial/group/{{group.group_name}}/description"
hx-target="#secretdetails" hx-target="#secretdetails"
hx-swap="OuterHTML" hx-swap="OuterHTML"
> >
@ -77,7 +77,7 @@
name="description" name="description"
id="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" 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 }}" value="{{ group.description }}"
required="" required=""
/> />
</div> </div>
@ -91,7 +91,7 @@
<button <button
type="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" 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-delete="/secrets/group/{{ group.group_name }}"
hx-target="#secretdetails" hx-target="#secretdetails"
hx-swap="OuterHTML" hx-swap="OuterHTML"
hx-confirm="Deleting a group will move all its secrets to the Ungrouped category. Continue?" hx-confirm="Deleting a group will move all its secrets to the Ungrouped category. Continue?"

View File

@ -0,0 +1,7 @@
<div class="w-full" id="secretdetails">
<a
href="{{ destination }}"
class="font-medium text-blue-600 dark:text-blue-500 hover:underline">
Redirecting...
</a>
</div>

View File

@ -1,4 +1,4 @@
<div class="w-full" id="secretdetails"> <div class="w-full flowbite-init-target" id="secretdetails">
<!-- menu --> <!-- menu -->

View File

@ -0,0 +1,36 @@
document.addEventListener("DOMContentLoaded", () => {
const tree = document.querySelector("sl-tree");
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;
const groupPath = selectedEl.dataset.groupPath;
console.log(`Event on ${type} ${name} path: ${groupPath}`);
if (!type || !name) return;
let url = "";
if (type === "entry") {
url = `/secrets/secret/${encodeURIComponent(name)}`;
} else if (type === "group") {
//url = `/secrets/partial/group/${encodeURIComponent(name)}`;
url = `/secrets/group/${encodeURIComponent(groupPath)}`;
} else if (type == "root") {
url = `/secrets/group/`;
}
if (url) {
htmx.ajax("GET", url, {
target: "#secretdetails",
swap: "OuterHTML",
indicator: ".secret-spinner",
});
}
});
});

View File

@ -2,43 +2,20 @@
# pyright: reportUnusedFunction=false # pyright: reportUnusedFunction=false
import logging import logging
import math
from typing import Annotated, cast from typing import Annotated, cast
from fastapi import APIRouter, Depends, Request, Response from fastapi import APIRouter, Depends, Request, Response
from pydantic import BaseModel
from sshecret.backend import AuditFilter, Operation from sshecret.backend import AuditFilter, Operation
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 .common import PagingInfo
from ..dependencies import FrontendDependencies from ..dependencies import FrontendDependencies
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
class PagingInfo(BaseModel):
page: int
limit: int
total: int
offset: int = 0
@property
def first(self) -> int:
"""The first result number."""
return self.offset + 1
@property
def last(self) -> int:
"""Return the last result number."""
return self.offset + self.limit
@property
def total_pages(self) -> int:
"""Return total pages."""
return math.ceil(self.total / self.limit)
def create_router(dependencies: FrontendDependencies) -> APIRouter: def create_router(dependencies: FrontendDependencies) -> APIRouter:
"""Create clients router.""" """Create clients router."""

View File

@ -5,11 +5,12 @@ import ipaddress
import logging import logging
import uuid import uuid
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Form, Request, Response from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, Response
from pydantic import BaseModel, IPvAnyAddress, IPvAnyNetwork from pydantic import BaseModel, IPvAnyAddress, IPvAnyNetwork
from sshecret_admin.frontend.views.common import PagingInfo
from sshecret.backend import ClientFilter from sshecret.backend import ClientFilter
from sshecret.backend.models import FilterType from sshecret.backend.models import Client, ClientQueryResult, FilterType
from sshecret.crypto import validate_public_key from sshecret.crypto import validate_public_key
from sshecret_admin.auth import LocalUserInfo from sshecret_admin.auth import LocalUserInfo
from sshecret_admin.services import AdminBackend from sshecret_admin.services import AdminBackend
@ -18,6 +19,8 @@ from ..dependencies import FrontendDependencies
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
CLIENTS_PER_PAGE = 20
class ClientUpdate(BaseModel): class ClientUpdate(BaseModel):
id: uuid.UUID id: uuid.UUID
@ -34,6 +37,38 @@ class ClientCreate(BaseModel):
sources: str | None sources: str | None
class LocatedClient(BaseModel):
"""A located client."""
client: Client
results: ClientQueryResult
pages: PagingInfo
async def locate_client(admin: AdminBackend, client_id: str) -> LocatedClient | None:
"""Locate a client in a paginated dataset."""
offset = 0
page = 1
total_clients = await admin.get_client_count()
while offset < total_clients:
filter = ClientFilter(limit=CLIENTS_PER_PAGE, offset=offset)
results = await admin.query_clients(filter)
matches = [client for client in results.clients if str(client.id) == client_id]
if matches:
client = matches[0]
pages = PagingInfo(
page=page,
limit=CLIENTS_PER_PAGE,
total=results.total_results,
offset=offset,
)
return LocatedClient(client=client, results=results, pages=pages)
offset += CLIENTS_PER_PAGE
page += 1
return None
def create_router(dependencies: FrontendDependencies) -> APIRouter: def create_router(dependencies: FrontendDependencies) -> APIRouter:
"""Create clients router.""" """Create clients router."""
@ -41,45 +76,116 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
templates = dependencies.templates templates = dependencies.templates
@app.get("/clients") @app.get("/clients/")
async def get_clients( async def get_client_tree(
request: Request, request: Request,
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)], 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)],
) -> Response: ) -> Response:
"""Get clients.""" """Get client tree view."""
clients = await admin.get_clients() page = 1
LOG.info("Clients %r", clients) per_page = CLIENTS_PER_PAGE
offset = 0
client_filter = ClientFilter(offset=offset, limit=per_page)
results = await admin.query_clients(client_filter)
paginate = PagingInfo(
page=page, limit=per_page, total=results.total_results, offset=offset
)
LOG.info("Results %r", results)
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"clients/index.html.j2", "clients/index.html.j2",
{ {
"page_title": "Clients", "page_title": "Clients",
"clients": clients, "offset": offset,
"pages": paginate,
"clients": results.clients,
"user": current_user, "user": current_user,
"results": results,
}, },
) )
@app.post("/clients/query") @app.get("/clients/page/{page}")
async def query_clients( async def get_client_page(
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)],
query: Annotated[str, Form()], page: int,
) -> Response: ) -> Response:
"""Query for a client.""" """Get client tree view."""
query_filter: ClientFilter | None = None per_page = CLIENTS_PER_PAGE
if query: offset = 0
name = f"%{query}%" if page > 1:
query_filter = ClientFilter(name=name, filter_name=FilterType.LIKE) offset = (page - 1) * per_page
clients = await admin.get_clients(query_filter)
client_filter = ClientFilter(offset=offset, limit=per_page)
results = await admin.query_clients(client_filter)
paginate = PagingInfo(
page=page,
limit=per_page,
offset=offset,
total=results.total_results,
)
LOG.info("Results %r", results)
template = "clients/index.html.j2"
if request.headers.get("HX-Request"):
# This is a HTMX request.
template = "clients/partials/tree.html.j2"
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"clients/inner.html.j2", template,
{ {
"clients": clients, "page_title": "Clients",
"offset": offset,
"last_num": offset + per_page,
"pages": paginate,
"clients": results.clients,
"user": current_user,
"results": results,
}, },
) )
@app.get("/clients/client/{id}")
async def get_client(
request: Request,
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
id: str,
) -> Response:
"""Fetch a client."""
results = await locate_client(admin, id)
if not results:
raise HTTPException(status_code=404, detail="Client not found.")
events = await admin.get_audit_log_detailed(
limit=10, client_name=results.client.name
)
template = "clients/client.html.j2"
headers: dict[str, str] = {}
if request.headers.get("HX-Request"):
headers["HX-Push-Url"] = request.url.path
template = "clients/partials/client_details.html.j2"
return templates.TemplateResponse(
request,
template,
{
"page_title": f"Client {results.client.name}",
"pages": results.pages,
"clients": results.results.clients,
"client": results.client,
"user": current_user,
"results": results.results,
"events": events,
},
headers=headers,
)
@app.put("/clients/{id}") @app.put("/clients/{id}")
async def update_client( async def update_client(
request: Request, request: Request,
@ -111,36 +217,17 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
LOG.info("Fields: %r", client_fields) LOG.info("Fields: %r", client_fields)
updated_client = original_client.model_copy(update=client_fields) updated_client = original_client.model_copy(update=client_fields)
await admin.update_client(updated_client) final_client = await admin.update_client(updated_client)
events = await admin.get_audit_log_detailed(limit=10, client_name=client.name)
clients = await admin.get_clients()
headers = {"Hx-Refresh": "true"}
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"clients/inner.html.j2", "clients/partials/client_details.html.j2",
{ {
"clients": clients, "client": final_client,
"events": events,
}, },
headers=headers,
)
@app.delete("/clients/{id}")
async def delete_client(
request: Request,
id: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> Response:
"""Delete a client."""
await admin.delete_client(("id", id))
clients = await admin.get_clients()
headers = {"Hx-Refresh": "true"}
return templates.TemplateResponse(
request,
"clients/inner.html.j2",
{
"clients": clients,
},
headers=headers,
) )
@app.post("/clients/") @app.post("/clients/")
@ -153,20 +240,22 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
sources: list[str] | None = None sources: list[str] | None = None
if client.sources: if client.sources:
sources = [source.strip() for source in client.sources.split(",")] sources = [source.strip() for source in client.sources.split(",")]
await admin.create_client(
client.name, client.public_key.rstrip(), client.description, sources
)
clients = await admin.get_clients()
headers = {"Hx-Refresh": "true"} headers = {"Hx-Refresh": "true"}
return templates.TemplateResponse( return Response(
request,
"clients/inner.html.j2",
{
"clients": clients,
},
headers=headers, headers=headers,
) )
@app.delete("/clients/{id}")
async def delete_client(
id: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> Response:
"""Delete a client."""
await admin.delete_client(("id", id))
headers = {"Hx-Refresh": "true"}
return Response(headers=headers)
@app.post("/clients/validate/source") @app.post("/clients/validate/source")
async def validate_client_source( async def validate_client_source(
request: Request, request: Request,
@ -215,4 +304,38 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
{"explanation": "Invalid value. Not a valid SSH RSA Public Key."}, {"explanation": "Invalid value. Not a valid SSH RSA Public Key."},
) )
@app.post("/clients/query")
async def query_clients(
request: Request,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
query: Annotated[str, Form()],
) -> Response:
"""Query for a client."""
query_filter = ClientFilter(limit=CLIENTS_PER_PAGE)
if query:
name = f"%{query}%"
query_filter = ClientFilter(
name=name, filter_name=FilterType.LIKE, limit=CLIENTS_PER_PAGE
)
results = await admin.query_clients(query_filter)
pages: PagingInfo | None = None
if not query:
pages = PagingInfo(
page=1, limit=CLIENTS_PER_PAGE, offset=0, total=results.total_results
)
more_results: int | None = None
if query and results.total_results > CLIENTS_PER_PAGE:
more_results = results.total_results - CLIENTS_PER_PAGE
return templates.TemplateResponse(
request,
"clients/partials/tree_items.html.j2",
{
"clients": results.clients,
"pages": pages,
"results": results,
"more_results": more_results,
},
)
return app return app

View File

@ -0,0 +1,41 @@
"""Common utilities."""
import math
from pydantic import BaseModel
class PagingInfo(BaseModel):
page: int
limit: int
total: int
offset: int = 0
@property
def first(self) -> int:
"""The first result number."""
return self.offset + 1
@property
def last(self) -> int:
"""Return the last result number."""
return self.offset + self.limit
@property
def total_pages(self) -> int:
"""Return total pages."""
return math.ceil(self.total / self.limit)
@property
def pages(self) -> list[int]:
"""Return all page numbers."""
return [page for page in range(1, self.total_pages + 1)]
@property
def is_last(self) -> bool:
"""Is this the last page?"""
return self.page == self.total_pages
@property
def is_first(self) -> bool:
"""Is this the first page?"""
return self.page == 1

View File

@ -1,7 +1,7 @@
#!/usr/bin/env python3 """Secrets views."""
# pyright: reportUnusedFunction=false # pyright: reportUnusedFunction=false
import os
import logging import logging
import secrets as pysecrets import secrets as pysecrets
from typing import Annotated, Any from typing import Annotated, Any
@ -64,57 +64,17 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)], current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
): ):
groups = await admin.get_secret_groups() groups = await admin.get_secret_groups()
LOG.info("Groups: %s", groups.model_dump_json(indent=2))
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"secrets/index.html.j2", "secrets/index.html.j2",
{ {
"groups": groups, "groups": groups,
"user": current_user, "user": current_user,
"selected_group": None,
"group_path_nodes": ["/"],
}, },
) )
# @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",
# {
# "group_path_nodes": [],
# "clients": clients,
# },
# )
# @app.get("/secrets/partial/secret/{name}")
# async def get_secret_tree_detail_partial(
# 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(flat=True)
# 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/group/") @app.get("/secrets/group/")
async def show_root_group( async def show_root_group(
request: Request, request: Request,
@ -138,6 +98,7 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
context["user"] = current_user context["user"] = current_user
context["groups"] = groups context["groups"] = groups
context["group_path_nodes"] = ["/"] context["group_path_nodes"] = ["/"]
context["selected_group"] = "/"
return templates.TemplateResponse( return templates.TemplateResponse(
request, template_name, context, headers=headers request, template_name, context, headers=headers
@ -161,8 +122,7 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
headers: dict[str, str] = {} headers: dict[str, str] = {}
context: dict[str, Any] = { context: dict[str, Any] = {
"group_page": True, "group_page": True,
"name": group.group_name, "group": group,
"description": group.description,
"clients": clients, "clients": clients,
} }
if request.headers.get("HX-Request"): if request.headers.get("HX-Request"):
@ -176,6 +136,7 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
context["user"] = current_user context["user"] = current_user
context["groups"] = groups context["groups"] = groups
context["group_path_nodes"] = group.path.split("/") context["group_path_nodes"] = group.path.split("/")
context["selected_group"] = group.path
return templates.TemplateResponse( return templates.TemplateResponse(
request, template_name, context, headers=headers request, template_name, context, headers=headers
@ -190,7 +151,7 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
): ):
"""Get secret detail.""" """Get secret detail."""
secret = await admin.get_secret(name) secret = await admin.get_secret(name)
groups = await admin.get_secret_groups(flat=True) groups = await admin.get_secret_groups()
events = await admin.get_audit_log_detailed(limit=10, secret_name=name) events = await admin.get_audit_log_detailed(limit=10, secret_name=name)
if not secret: if not secret:
@ -222,35 +183,12 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
context["user"] = current_user context["user"] = current_user
context["groups"] = groups context["groups"] = groups
context["group_path_nodes"] = group_path context["group_path_nodes"] = group_path
context["selected_group"] = None
return templates.TemplateResponse( return templates.TemplateResponse(
request, template_name, context, headers=headers request, template_name, context, headers=headers
) )
@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}") @app.delete("/secrets/group/{name}")
async def delete_secret_group( async def delete_secret_group(
request: Request, request: Request,
@ -266,11 +204,15 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
await admin.delete_secret_group(name) await admin.delete_secret_group(name)
headers = {"Hx-Refresh": "true"} new_path = "/secrets/group/"
if group.parent_group:
new_path = os.path.join(new_path, group.parent_group.path)
headers = {"Hx-Redirect": new_path}
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"secrets/partials/default_detail.html.j2", "secrets/partials/redirect.html.j2",
{"destination": new_path},
headers=headers, headers=headers,
) )
@ -288,6 +230,7 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
description=group.description, description=group.description,
parent_group=group.parent_group, parent_group=group.parent_group,
) )
headers = {"Hx-Refresh": "true"} headers = {"Hx-Refresh": "true"}
return templates.TemplateResponse( return templates.TemplateResponse(
@ -360,8 +303,7 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
request, request,
"secrets/partials/group_detail.html.j2", "secrets/partials/group_detail.html.j2",
{ {
"name": group.group_name, "group": group,
"description": group.description,
"clients": clients, "clients": clients,
}, },
headers=headers, headers=headers,
@ -449,7 +391,6 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
secret: Annotated[CreateSecret, Form()], secret: Annotated[CreateSecret, Form()],
): ):
"""Create secret in group.""" """Create secret in group."""
LOG.info("secret: %s", secret.model_dump_json(indent=2))
if secret.value: if secret.value:
value = secret.value value = secret.value
else: else:
@ -457,24 +398,14 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
await admin.add_secret(secret.name, value, secret.clients, group=name) await admin.add_secret(secret.name, value, secret.clients, group=name)
headers = {"Hx-Refresh": "true"} new_path = f"/secrets/secret/{secret.name}"
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: headers = {"Hx-Redirect": new_path}
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found"
)
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"secrets/partials/tree_detail.html.j2", "secrets/partials/redirect.html.j2",
{ {"destination": new_path},
"secret": new_secret,
"groups": groups,
"events": events,
},
headers=headers, headers=headers,
) )
@ -493,23 +424,15 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
await admin.add_secret(secret.name, value, secret.clients, group=None) await admin.add_secret(secret.name, value, secret.clients, group=None)
headers = {"Hx-Refresh": "true"} new_path = f"/secrets/secret/{secret.name}"
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: headers = {"Hx-Redirect": new_path}
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found"
)
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"secrets/partials/tree_detail.html.j2", "secrets/partials/redirect.html.j2",
{ {
"secret": new_secret, "destination": new_path,
"groups": groups,
"events": events,
}, },
headers=headers, headers=headers,
) )
@ -598,12 +521,23 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
): ):
"""Delete a secret.""" """Delete a secret."""
secret = await admin.get_secret(name)
if not secret:
raise HTTPException(status_code=404, detail="Secret not found")
new_path = "/secrets/group/"
if secret.group:
secret_group = await admin.get_secret_group(secret.group)
if secret_group:
new_path = os.path.join("/secrets/group", secret_group.path)
await admin.delete_secret(name) await admin.delete_secret(name)
headers = {"Hx-Refresh": "true"} headers = {"Hx-Redirect": new_path}
# headers["HX-Push-Url"] = request.url.path
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"secrets/partials/default_detail.html.j2", "secrets/partials/redirect.html.j2",
{"destination": new_path},
headers=headers, headers=headers,
) )

View File

@ -16,7 +16,7 @@ from sshecret.backend import (
Operation, Operation,
SubSystem, SubSystem,
) )
from sshecret.backend.models import DetailedSecrets from sshecret.backend.models import ClientQueryResult, DetailedSecrets
from sshecret.backend.api import AuditAPI, KeySpec from sshecret.backend.api import AuditAPI, KeySpec
from sshecret.crypto import encrypt_string, load_public_key from sshecret.crypto import encrypt_string, load_public_key
@ -113,6 +113,17 @@ class AdminBackend:
except Exception as e: except Exception as e:
raise BackendUnavailableError() from e raise BackendUnavailableError() from e
async def query_clients(
self, filter: ClientFilter | None = None
) -> ClientQueryResult:
"""Query clients."""
try:
return await self.backend.query_clients(filter)
except ClientManagementError:
raise
except Exception as e:
raise BackendUnavailableError() from e
async def _get_client(self, idname: KeySpec) -> Client | None: async def _get_client(self, idname: KeySpec) -> Client | None:
"""Get a client from the backend.""" """Get a client from the backend."""
return await self.backend.get_client(idname) return await self.backend.get_client(idname)
@ -142,6 +153,10 @@ class AdminBackend:
except Exception as e: except Exception as e:
raise BackendUnavailableError() from e raise BackendUnavailableError() from e
async def get_client_count(self, filter: ClientFilter | None = None) -> int:
"""Count the clients, optionally with filter."""
return await self.backend.get_client_count(filter)
async def _create_client( async def _create_client(
self, self,
name: str, name: str,

View File

@ -40,6 +40,7 @@
--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-900: oklch(38.6% 0.063 188.416); --color-teal-900: oklch(38.6% 0.063 188.416);
--color-blue-50: oklch(97% 0.014 254.604);
--color-blue-100: oklch(93.2% 0.032 255.585); --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);
@ -381,6 +382,9 @@
.order-1 { .order-1 {
order: 1; order: 1;
} }
.order-2 {
order: 2;
}
.col-span-2 { .col-span-2 {
grid-column: span 2 / span 2; grid-column: span 2 / span 2;
} }
@ -444,12 +448,18 @@
.my-auto { .my-auto {
margin-block: auto; margin-block: auto;
} }
.ms-0 {
margin-inline-start: calc(var(--spacing) * 0);
}
.ms-2 { .ms-2 {
margin-inline-start: calc(var(--spacing) * 2); margin-inline-start: calc(var(--spacing) * 2);
} }
.ms-3 { .ms-3 {
margin-inline-start: calc(var(--spacing) * 3); margin-inline-start: calc(var(--spacing) * 3);
} }
.ms-auto {
margin-inline-start: auto;
}
.me-2 { .me-2 {
margin-inline-end: calc(var(--spacing) * 2); margin-inline-end: calc(var(--spacing) * 2);
} }
@ -916,6 +926,9 @@
.flex-wrap { .flex-wrap {
flex-wrap: wrap; flex-wrap: wrap;
} }
.place-content-between {
place-content: space-between;
}
.items-baseline { .items-baseline {
align-items: baseline; align-items: baseline;
} }
@ -1001,6 +1014,13 @@
margin-block-end: calc(calc(var(--spacing) * 12) * calc(1 - var(--tw-space-y-reverse))); margin-block-end: calc(calc(var(--spacing) * 12) * calc(1 - var(--tw-space-y-reverse)));
} }
} }
.-space-x-px {
:where(& > :not(:last-child)) {
--tw-space-x-reverse: 0;
margin-inline-start: calc(-1px * var(--tw-space-x-reverse));
margin-inline-end: calc(-1px * calc(1 - var(--tw-space-x-reverse)));
}
}
.space-x-1 { .space-x-1 {
:where(& > :not(:last-child)) { :where(& > :not(:last-child)) {
--tw-space-x-reverse: 0; --tw-space-x-reverse: 0;
@ -1078,6 +1098,9 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.overflow-auto {
overflow: auto;
}
.overflow-hidden { .overflow-hidden {
overflow: hidden; overflow: hidden;
} }
@ -1111,6 +1134,14 @@
.rounded-xs { .rounded-xs {
border-radius: var(--radius-xs); border-radius: var(--radius-xs);
} }
.rounded-s-lg {
border-start-start-radius: var(--radius-lg);
border-end-start-radius: var(--radius-lg);
}
.rounded-e-lg {
border-start-end-radius: var(--radius-lg);
border-end-end-radius: var(--radius-lg);
}
.rounded-t { .rounded-t {
border-top-left-radius: 0.25rem; border-top-left-radius: 0.25rem;
border-top-right-radius: 0.25rem; border-top-right-radius: 0.25rem;
@ -1161,6 +1192,10 @@
border-style: var(--tw-border-style); border-style: var(--tw-border-style);
border-width: 2px; border-width: 2px;
} }
.border-e-0 {
border-inline-end-style: var(--tw-border-style);
border-inline-end-width: 0px;
}
.border-t { .border-t {
border-top-style: var(--tw-border-style); border-top-style: var(--tw-border-style);
border-top-width: 1px; border-top-width: 1px;
@ -1201,6 +1236,12 @@
--tw-border-style: solid; --tw-border-style: solid;
border-style: solid; border-style: solid;
} }
.border-blue-300 {
border-color: var(--color-blue-300);
}
.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);
} }
@ -1264,6 +1305,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-50 {
background-color: var(--color-blue-50);
}
.bg-blue-100 { .bg-blue-100 {
background-color: var(--color-blue-100); background-color: var(--color-blue-100);
} }
@ -1282,6 +1326,9 @@
.bg-blue-600 { .bg-blue-600 {
background-color: var(--color-blue-600); background-color: var(--color-blue-600);
} }
.bg-blue-700 {
background-color: var(--color-blue-700);
}
.bg-emerald-500 { .bg-emerald-500 {
background-color: var(--color-emerald-500); background-color: var(--color-emerald-500);
} }
@ -1614,6 +1661,10 @@
font-size: var(--text-base); font-size: var(--text-base);
line-height: var(--tw-leading, var(--text-base--line-height)); line-height: var(--tw-leading, var(--text-base--line-height));
} }
.text-base\/7 {
font-size: var(--text-base);
line-height: calc(var(--spacing) * 7);
}
.text-lg { .text-lg {
font-size: var(--text-lg); font-size: var(--text-lg);
line-height: var(--tw-leading, var(--text-lg--line-height)); line-height: var(--tw-leading, var(--text-lg--line-height));
@ -1622,6 +1673,10 @@
font-size: var(--text-sm); font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height)); line-height: var(--tw-leading, var(--text-sm--line-height));
} }
.text-sm\/6 {
font-size: var(--text-sm);
line-height: calc(var(--spacing) * 6);
}
.text-xl { .text-xl {
font-size: var(--text-xl); font-size: var(--text-xl);
line-height: var(--tw-leading, var(--text-xl--line-height)); line-height: var(--tw-leading, var(--text-xl--line-height));
@ -1715,6 +1770,9 @@
.text-emerald-500 { .text-emerald-500 {
color: var(--color-emerald-500); color: var(--color-emerald-500);
} }
.text-gray-100 {
color: var(--color-gray-100);
}
.text-gray-200 { .text-gray-200 {
color: var(--color-gray-200); color: var(--color-gray-200);
} }
@ -2097,6 +2155,13 @@
} }
} }
} }
.hover\:bg-blue-100 {
&:hover {
@media (hover: hover) {
background-color: var(--color-blue-100);
}
}
}
.hover\:bg-blue-200 { .hover\:bg-blue-200 {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@ -2104,6 +2169,13 @@
} }
} }
} }
.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) {
@ -2209,6 +2281,13 @@
} }
} }
} }
.hover\:text-gray-200 {
&:hover {
@media (hover: hover) {
color: var(--color-gray-200);
}
}
}
.hover\:text-gray-500 { .hover\:text-gray-500 {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@ -2289,6 +2368,11 @@
border-color: var(--color-blue-500); border-color: var(--color-blue-500);
} }
} }
.focus\:border-gray-900 {
&:focus {
border-color: var(--color-gray-900);
}
}
.focus\:border-indigo-500 { .focus\:border-indigo-500 {
&:focus { &:focus {
border-color: var(--color-indigo-500); border-color: var(--color-indigo-500);
@ -2374,6 +2458,11 @@
--tw-ring-color: var(--color-gray-300); --tw-ring-color: var(--color-gray-300);
} }
} }
.focus\:ring-gray-900 {
&:focus {
--tw-ring-color: var(--color-gray-900);
}
}
.focus\:ring-indigo-500 { .focus\:ring-indigo-500 {
&:focus { &:focus {
--tw-ring-color: var(--color-indigo-500); --tw-ring-color: var(--color-indigo-500);
@ -2415,6 +2504,11 @@
position: absolute; position: absolute;
} }
} }
.sm\:col-span-2 {
@media (width >= 40rem) {
grid-column: span 2 / span 2;
}
}
.sm\:col-span-3 { .sm\:col-span-3 {
@media (width >= 40rem) { @media (width >= 40rem) {
grid-column: span 3 / span 3; grid-column: span 3 / span 3;
@ -2455,6 +2549,11 @@
display: flex; display: flex;
} }
} }
.sm\:grid {
@media (width >= 40rem) {
display: grid;
}
}
.sm\:hidden { .sm\:hidden {
@media (width >= 40rem) { @media (width >= 40rem) {
display: none; display: none;
@ -2501,6 +2600,11 @@
translate: var(--tw-translate-x) var(--tw-translate-y); translate: var(--tw-translate-x) var(--tw-translate-y);
} }
} }
.sm\:grid-cols-3 {
@media (width >= 40rem) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.sm\:justify-between { .sm\:justify-between {
@media (width >= 40rem) { @media (width >= 40rem) {
justify-content: space-between; justify-content: space-between;
@ -2516,6 +2620,11 @@
justify-content: flex-end; justify-content: flex-end;
} }
} }
.sm\:gap-4 {
@media (width >= 40rem) {
gap: calc(var(--spacing) * 4);
}
}
.sm\:space-x-3 { .sm\:space-x-3 {
@media (width >= 40rem) { @media (width >= 40rem) {
:where(& > :not(:last-child)) { :where(& > :not(:last-child)) {
@ -2566,6 +2675,11 @@
padding: calc(var(--spacing) * 8); padding: calc(var(--spacing) * 8);
} }
} }
.sm\:px-0 {
@media (width >= 40rem) {
padding-inline: calc(var(--spacing) * 0);
}
}
.sm\:px-4 { .sm\:px-4 {
@media (width >= 40rem) { @media (width >= 40rem) {
padding-inline: calc(var(--spacing) * 4); padding-inline: calc(var(--spacing) * 4);
@ -2654,6 +2768,11 @@
inset: calc(var(--spacing) * 0); inset: calc(var(--spacing) * 0);
} }
} }
.md\:order-1 {
@media (width >= 48rem) {
order: 1;
}
}
.md\:order-2 { .md\:order-2 {
@media (width >= 48rem) { @media (width >= 48rem) {
order: 2; order: 2;
@ -2754,6 +2873,11 @@
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
} }
.md\:flex-row {
@media (width >= 48rem) {
flex-direction: row;
}
}
.md\:items-center { .md\:items-center {
@media (width >= 48rem) { @media (width >= 48rem) {
align-items: center; align-items: center;
@ -3222,6 +3346,11 @@
padding-inline: calc(var(--spacing) * 0); padding-inline: calc(var(--spacing) * 0);
} }
} }
.rtl\:rotate-180 {
&:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) {
rotate: 180deg;
}
}
.rtl\:space-x-reverse { .rtl\:space-x-reverse {
&:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) { &:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) {
:where(& > :not(:last-child)) { :where(& > :not(:last-child)) {
@ -3324,6 +3453,11 @@
border-color: var(--color-red-800); border-color: var(--color-red-800);
} }
} }
.dark\:bg-blue-600 {
&:where(.dark, .dark *) {
background-color: var(--color-blue-600);
}
}
.dark\:bg-blue-900 { .dark\:bg-blue-900 {
&:where(.dark, .dark *) { &:where(.dark, .dark *) {
background-color: var(--color-blue-900); background-color: var(--color-blue-900);
@ -3578,6 +3712,15 @@
} }
} }
} }
.dark\:hover\:bg-blue-700 {
&:where(.dark, .dark *) {
&:hover {
@media (hover: hover) {
background-color: var(--color-blue-700);
}
}
}
}
.dark\:hover\:bg-blue-800 { .dark\:hover\:bg-blue-800 {
&:where(.dark, .dark *) { &:where(.dark, .dark *) {
&:hover { &:hover {
@ -3693,6 +3836,13 @@
} }
} }
} }
.dark\:focus\:border-gray-900 {
&:where(.dark, .dark *) {
&:focus {
border-color: var(--color-gray-900);
}
}
}
.dark\:focus\:border-green-500 { .dark\:focus\:border-green-500 {
&:where(.dark, .dark *) { &:where(.dark, .dark *) {
&:focus { &:focus {
@ -3756,6 +3906,13 @@
} }
} }
} }
.dark\:focus\:ring-gray-900 {
&:where(.dark, .dark *) {
&:focus {
--tw-ring-color: var(--color-gray-900);
}
}
}
.dark\:focus\:ring-green-500 { .dark\:focus\:ring-green-500 {
&:where(.dark, .dark *) { &:where(.dark, .dark *) {
&:focus { &:focus {

View File

@ -8,3 +8,14 @@ sl-avatar {
.tree-post-button { .tree-post-button {
} }
.full-height-tree::part(base) {
}
sl-details.small-details::part(header) {
padding: var(--sl-spacing-small);
}
sl-details.small-details::part(base) {
font-size: var(--sl-input-font-size-small);
}

View File

@ -4,6 +4,7 @@
import logging import logging
from typing import Any from typing import Any
import uuid
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field, TypeAdapter from pydantic import BaseModel, Field, TypeAdapter
from sqlalchemy import select, func, and_ from sqlalchemy import select, func, and_
@ -28,9 +29,9 @@ class AuditFilter(BaseModel):
limit: int = Field(100, le=100) limit: int = Field(100, le=100)
subsystem: SubSystem | None = None subsystem: SubSystem | None = None
operation: Operation | None = None operation: Operation | None = None
client_id: str | None = None client_id: uuid.UUID | None = None
client_name: str | None = None client_name: str | None = None
secret_id: str | None = None secret_id: uuid.UUID | None = None
secret_name: str | None = None secret_name: str | None = None
origin: str | None = None origin: str | None = None

View File

@ -247,6 +247,26 @@ class ClientOperations:
return ClientPolicyView.from_client(db_client) return ClientPolicyView.from_client(db_client)
def resolve_order(statement: Select[Any], order_by: str, reversed: bool) -> Select[Any]:
"""Resolve ordering."""
LOG.info("Resolve order called")
param_map = {
"name": Client.name,
"description": Client.description,
"created_at": Client.created_at,
"updated_at": Client.updated_at,
}
if column := param_map.get(order_by):
if reversed:
statement = statement.order_by(column.desc())
else:
statement = statement.order_by(column.asc())
#FIXME: Remove
LOG.info("Ordered by %s (%r)", order_by, reversed)
return statement
LOG.warning("Unsupported order field: %s", order_by)
return statement
def filter_client_statement( def filter_client_statement(
statement: Select[Any], params: ClientListParams, ignore_limits: bool = False statement: Select[Any], params: ClientListParams, ignore_limits: bool = False
) -> Select[Any]: ) -> Select[Any]:
@ -260,6 +280,8 @@ def filter_client_statement(
statement = statement.where(Client.name.like(params.name__like)) statement = statement.where(Client.name.like(params.name__like))
elif params.name__contains: elif params.name__contains:
statement = statement.where(Client.name.contains(params.name__contains)) statement = statement.where(Client.name.contains(params.name__contains))
statement = resolve_order(statement, params.order_by, params.order_reverse)
LOG.info("statement: %s", statement)
if ignore_limits: if ignore_limits:
return statement return statement

View File

@ -26,6 +26,7 @@ class ClientView(BaseModel):
description: str | None = None description: str | None = None
public_key: str public_key: str
policies: list[str] = ["0.0.0.0/0", "::/0"] policies: list[str] = ["0.0.0.0/0", "::/0"]
version: int
is_active: bool = True is_active: bool = True
is_deleted: bool = False is_deleted: bool = False
secrets: list[str] = Field(default_factory=list) secrets: list[str] = Field(default_factory=list)
@ -52,6 +53,7 @@ class ClientView(BaseModel):
created_at=client.created_at, created_at=client.created_at,
updated_at=client.updated_at or None, updated_at=client.updated_at or None,
deleted_at=client.deleted_at or None, deleted_at=client.deleted_at or None,
version=client.version,
is_active=client.is_active, is_active=client.is_active,
is_deleted=client.is_deleted, is_deleted=client.is_deleted,
) )
@ -86,6 +88,8 @@ class ClientListParams(BaseModel):
name: str | None = None name: str | None = None
name__like: str | None = None name__like: str | None = None
name__contains: str | None = None name__contains: str | None = None
order_by: str = "created_at"
order_reverse: bool = True
@model_validator(mode="after") @model_validator(mode="after")
def validate_expressions(self) -> Self: def validate_expressions(self) -> Self:

View File

@ -1,6 +1,7 @@
"""CLI and main entry point.""" """CLI and main entry point."""
import code import code
import logging
import os import os
from pathlib import Path from pathlib import Path
from typing import Literal, cast from typing import Literal, cast
@ -24,6 +25,17 @@ from .models import (
) )
from .settings import BackendSettings from .settings import BackendSettings
handler = logging.StreamHandler()
formatter = logging.Formatter(
"%(asctime)s [%(processName)s: %(process)d] [%(threadName)s: %(thread)d] [%(levelname)s] %(name)s: %(message)s"
)
handler.setFormatter(formatter)
LOG = logging.getLogger()
LOG.addHandler(handler)
LOG.setLevel(logging.INFO)
DEFAULT_LISTEN = "127.0.0.1" DEFAULT_LISTEN = "127.0.0.1"
DEFAULT_PORT = 8022 DEFAULT_PORT = 8022
@ -32,6 +44,7 @@ WORKDIR = Path(os.getcwd())
load_dotenv() load_dotenv()
def generate_token( def generate_token(
settings: BackendSettings, subsystem: Literal["admin", "sshd"] settings: BackendSettings, subsystem: Literal["admin", "sshd"]
) -> str: ) -> str:
@ -73,9 +86,12 @@ def add_system_tokens(settings: BackendSettings) -> None:
@click.group() @click.group()
@click.option("--database", help="Path to the sqlite database file.") @click.option("--database", help="Path to the sqlite database file.")
@click.option("--debug", is_flag=True)
@click.pass_context @click.pass_context
def cli(ctx: click.Context, database: str) -> None: def cli(ctx: click.Context, database: str, debug: bool) -> None:
"""CLI group.""" """CLI group."""
if debug:
LOG.setLevel(logging.DEBUG)
if database: if database:
settings = BackendSettings(database=str(Path(database).absolute())) settings = BackendSettings(database=str(Path(database).absolute()))
else: else:

View File

@ -54,15 +54,21 @@ class ClientQueryIterator:
if not filter_params: if not filter_params:
filter_params = ClientFilter() filter_params = ClientFilter()
self.filter_params: ClientFilter = filter_params self.filter_params: ClientFilter = filter_params
self._client_count: int = 0
self._result: ClientQueryResult | None = None self._result: ClientQueryResult | None = None
self._clients: list[Client] = [] self._clients: list[Client] = []
self.offset: int = 0 self.offset: int = filter_params.offset
async def _get_batch(self) -> None: async def _get_batch(self) -> None:
"""Get batch.""" """Get batch."""
exclude_limit = True
if self.filter_params.limit:
exclude_limit = False
params: dict[str, str | int] = { params: dict[str, str | int] = {
**self.filter_params.get_params(), **self.filter_params.get_params(
exclude_offset=True, exclude_limit=exclude_limit
),
"offset": self.offset, "offset": self.offset,
} }
try: try:
@ -97,7 +103,10 @@ class ClientQueryIterator:
async def __anext__(self) -> Client: async def __anext__(self) -> Client:
"""Get next client.""" """Get next client."""
if self.filter_params.limit and self._client_count == self.filter_params.limit:
raise StopAsyncIteration
if client := await self.get_next_client(): if client := await self.get_next_client():
self._client_count += 1
return client return client
raise StopAsyncIteration raise StopAsyncIteration
@ -324,6 +333,29 @@ class SshecretBackend(BaseBackend):
return clients return clients
async def get_client_count(self, filter: ClientFilter | None = None) -> int:
"""Get a count of the clients optionally filtered."""
query_results = await self.query_clients(filter)
return query_results.total_results
async def query_clients(
self, filter: ClientFilter | None = None
) -> ClientQueryResult:
"""Query clients."""
params: dict[str, str] = {}
if filter:
params = filter.get_params(exclude_offset=False, exclude_limit=False)
try:
results = await self._get("/api/v1/clients/", params=params)
except httpx.TransportError as e:
raise BackendConnectionError() from e
if results.status_code != 200:
output = results.text
raise BackendConnectionError(f"Error from backend:\n{output}")
query_results = ClientQueryResult.model_validate(results.json())
return query_results
async def get_client(self, id_or_name: KeySpec) -> Client | None: async def get_client(self, id_or_name: KeySpec) -> Client | None:
"""Lookup a client on username.""" """Lookup a client on username."""
key = _key(id_or_name) key = _key(id_or_name)

View File

@ -45,10 +45,15 @@ class Client(BaseModel):
name: str name: str
description: str | None description: str | None
public_key: Annotated[str, AfterValidator(public_key_validator)] public_key: Annotated[str, AfterValidator(public_key_validator)]
version: int
is_active: bool
is_deleted: bool
secrets: list[str] secrets: list[str]
policies: list[IPvAnyNetwork | IPvAnyAddress] policies: list[IPvAnyNetwork | IPvAnyAddress]
created_at: datetime created_at: datetime
updated_at: datetime | None updated_at: datetime | None
deleted_at: datetime | None
class ClientQueryResult(BaseModel): class ClientQueryResult(BaseModel):
@ -103,10 +108,20 @@ class ClientFilter(BaseModel):
id: str | None = None id: str | None = None
name: str | None = None name: str | None = None
filter_name: FilterType | None = None filter_name: FilterType | None = None
offset: int = 0
limit: int | None = None
order_by: str | None = None
order_reverse: bool = False
def get_params(self) -> dict[str, str]:
def get_params(self, exclude_offset: bool = True, exclude_limit: bool = True) -> dict[str, str]:
"""Render query parameters.""" """Render query parameters."""
params: dict[str, str] = {} params: dict[str, str] = {}
if not exclude_offset:
params["offset"] = str(self.offset)
if not exclude_limit and self.limit:
params["limit"] = str(self.limit)
if not self.id and not self.name: if not self.id and not self.name:
return params return params
if self.id: if self.id:
@ -118,6 +133,11 @@ class ClientFilter(BaseModel):
elif self.name and self.filter_name is FilterType.CONTAINS: elif self.name and self.filter_name is FilterType.CONTAINS:
params["name__contains"] = self.name params["name__contains"] = self.name
if self.order_by:
params["order_by"] = self.order_by
if self.order_reverse:
params["order_reverse"] = "1"
return params return params

View File

@ -7,6 +7,7 @@ rest of the tests.
import pytest import pytest
import httpx import httpx
from sshecret.backend import SshecretBackend from sshecret.backend import SshecretBackend
from sshecret.backend.models import ClientFilter
from .clients import create_test_client from .clients import create_test_client
@ -57,3 +58,21 @@ async def test_create_secret(backend_api: SshecretBackend) -> None:
assert secret is not None assert secret is not None
assert secret == "encrypted_secret" assert secret == "encrypted_secret"
@pytest.mark.parametrize("offset,limit", [(0, 10), (0, 20), (10, 1)])
@pytest.mark.asyncio
async def test_client_filtering(backend_api: SshecretBackend, offset: int, limit: int) -> None:
"""Test filtering on the backend API."""
# We need to create 100 test clients.
for x in range(20):
client_name = f"test-{x}"
test_client = create_test_client(client_name)
await backend_api.create_client(client_name, test_client.public_key)
client_filter = ClientFilter(offset=offset, limit=limit)
clients = await backend_api.get_clients(client_filter)
assert len(clients) == limit
first_client = clients[0]
expected_name = f"test-{offset}"
assert first_client.name == expected_name