Remove old frontend completely

This commit is contained in:
2025-07-17 07:54:37 +02:00
parent ef8b50e302
commit 8beefdf82f
82 changed files with 3 additions and 6075 deletions

View File

@ -4,25 +4,21 @@
# #
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
import logging import logging
import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request, Response, status from fastapi import FastAPI, Request, status
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, RedirectResponse from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sshecret_backend.db import DatabaseSessionManager from sshecret_backend.db import DatabaseSessionManager
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from sshecret_admin import api, frontend from sshecret_admin import api
from sshecret_admin.auth.models import Base from sshecret_admin.auth.models import Base
from sshecret_admin.core.db import setup_database from sshecret_admin.core.db import setup_database
from sshecret_admin.frontend.exceptions import RedirectException
from sshecret_admin.services.secret_manager import setup_private_key from sshecret_admin.services.secret_manager import setup_private_key
from sshecret.backend.exceptions import BackendError, BackendValidationError from sshecret.backend.exceptions import BackendError, BackendValidationError
@ -32,21 +28,9 @@ from .settings import AdminServerSettings
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
# dir_path = os.path.dirname(os.path.realpath(__file__))
def setup_frontend(app: FastAPI, dependencies: BaseDependencies) -> None:
"""Setup frontend."""
script_path = Path(os.path.dirname(os.path.realpath(__file__)))
static_path = script_path.parent / "static"
app.mount("/static", StaticFiles(directory=static_path), name="static")
app.include_router(frontend.create_frontend_router(dependencies))
def create_admin_app( def create_admin_app(
settings: AdminServerSettings, settings: AdminServerSettings,
with_frontend: bool = True,
create_db: bool = False, create_db: bool = False,
) -> FastAPI: ) -> FastAPI:
"""Create admin app.""" """Create admin app."""
@ -110,15 +94,6 @@ def create_admin_app(
content=jsonable_encoder({"detail": [str(exc)]}), content=jsonable_encoder({"detail": [str(exc)]}),
) )
@app.exception_handler(RedirectException)
async def redirect_handler(request: Request, exc: RedirectException) -> Response:
"""Handle redirect exceptions."""
if "hx-request" in request.headers:
response = Response()
response.headers["HX-Redirect"] = str(exc.to)
return response
return RedirectResponse(url=str(exc.to))
@app.get("/health") @app.get("/health")
async def get_health() -> JSONResponse: async def get_health() -> JSONResponse:
"""Provide simple health check.""" """Provide simple health check."""
@ -129,7 +104,5 @@ def create_admin_app(
dependencies = BaseDependencies(settings, get_db_session, get_async_session) dependencies = BaseDependencies(settings, get_db_session, get_async_session)
app.include_router(api.create_api_router(dependencies)) app.include_router(api.create_api_router(dependencies))
if with_frontend:
setup_frontend(app, dependencies)
return app return app

View File

@ -1,5 +0,0 @@
"""Frontend app."""
from .router import create_router as create_frontend_router
__all__ = ["create_frontend_router"]

View File

@ -1,7 +0,0 @@
"""Custom oauth2 class."""
from fastapi.security import OAuth2
class Oauth2TokenInCookies(OAuth2):
"""TODO: Create this."""

View File

@ -1,57 +0,0 @@
"""Frontend dependencies."""
from dataclasses import dataclass
from collections.abc import AsyncGenerator, Callable, Awaitable
from typing import Self
from sqlalchemy.orm import Session
from sqlalchemy.ext.asyncio import AsyncSession
from jinja2_fragments.fastapi import Jinja2Blocks
from fastapi import Request
from sshecret_admin.core.dependencies import AdminDep, BaseDependencies
from sshecret_admin.auth.models import IdentityClaims, LocalUserInfo, User
UserTokenDep = Callable[[Request, Session], Awaitable[User]]
LoginStatusDep = Callable[[Request], Awaitable[bool]]
AsyncSessionDep = Callable[[], AsyncGenerator[AsyncSession, None]]
UserInfoDep = Callable[[Request, AsyncSession], Awaitable[LocalUserInfo]]
RefreshTokenDep = Callable[[Request], IdentityClaims]
LoginGuardDep = Callable[[Request], Awaitable[None]]
@dataclass
class FrontendDependencies(BaseDependencies):
"""Frontend dependencies."""
get_admin_backend: AdminDep
templates: Jinja2Blocks
get_refresh_claims: RefreshTokenDep
get_login_status: LoginStatusDep
get_user_info: UserInfoDep
require_login: LoginGuardDep
@classmethod
def create(
cls,
deps: BaseDependencies,
get_admin_backend: AdminDep,
templates: Jinja2Blocks,
get_refresh_claims: RefreshTokenDep,
get_login_status: LoginStatusDep,
get_user_info: UserInfoDep,
require_login: LoginGuardDep,
) -> Self:
"""Create from base dependencies."""
return cls(
settings=deps.settings,
get_db_session=deps.get_db_session,
get_async_session=deps.get_async_session,
get_admin_backend=get_admin_backend,
templates=templates,
get_refresh_claims=get_refresh_claims,
get_login_status=get_login_status,
get_user_info=get_user_info,
require_login=require_login,
)

View File

@ -1,14 +0,0 @@
"""Frontend exceptions."""
from starlette.datastructures import URL
class RedirectException(Exception):
"""Exception that initiates a redirect flow."""
def __init__(self, to: str | URL) -> None: # pyright: ignore[reportMissingSuperCall]
"""Raise exception that redirects."""
if isinstance(to, str):
to = URL(to)
self.to: URL = to

View File

@ -1,179 +0,0 @@
"""Frontend router."""
# pyright: reportUnusedFunction=false
import logging
import os
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from jinja2_fragments.fastapi import Jinja2Blocks
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sshecret_admin.auth.authentication import generate_user_info
from sshecret_admin.auth.models import AuthProvider, IdentityClaims, LocalUserInfo
from starlette.datastructures import URL
from sshecret_admin.auth import User, decode_token
from sshecret_admin.auth.constants import LOCAL_ISSUER
from sshecret_admin.core.dependencies import BaseDependencies
from sshecret_admin.services.admin_backend import AdminBackend
from .dependencies import FrontendDependencies
from .exceptions import RedirectException
from .views import audit, auth, clients, index, secrets, oidc_auth
LOG = logging.getLogger(__name__)
access_token = "access_token"
refresh_token = "refresh_token"
def create_router(dependencies: BaseDependencies) -> APIRouter:
"""Create frontend router."""
app = APIRouter(include_in_schema=False)
script_path = Path(os.path.dirname(os.path.realpath(__file__)))
template_path = script_path / "templates"
templates = Jinja2Blocks(directory=template_path)
async def get_admin_backend(
request: Request,
):
"""Get admin backend API."""
username = get_optional_username(request)
origin = get_client_origin(request)
admin = AdminBackend(
dependencies.settings,
username=username,
origin=origin,
)
yield admin
def get_identity_claims(request: Request) -> IdentityClaims:
"""Get identity claim from session."""
token = request.cookies.get("access_token")
next = URL("/refresh").include_query_params(next=request.url.path)
credentials_error = RedirectException(to=next)
if not token:
raise credentials_error
claims = decode_token(dependencies.settings, token)
if not claims:
raise credentials_error
return claims
def refresh_identity_claims(request: Request) -> IdentityClaims:
"""Get identity claim from session for refreshing the token."""
token = request.cookies.get("refresh_token")
next = URL("/login").include_query_params(next=request.url.path)
credentials_error = RedirectException(to=next)
if not token:
raise credentials_error
claims = decode_token(dependencies.settings, token)
if not claims:
raise credentials_error
return claims
async def get_login_status(request: Request) -> bool:
"""Get login status."""
token = request.cookies.get("access_token")
if not token:
return False
claims = decode_token(dependencies.settings, token)
return claims is not None
async def require_login(request: Request) -> None:
"""Enforce login requirement."""
token = request.cookies.get("access_token")
LOG.info("User has no cookie")
if not token:
url = URL("/login").include_query_params(next=request.url.path)
raise RedirectException(to=url)
is_logged_in = await get_login_status(request)
if not is_logged_in:
next = URL("/refresh").include_query_params(next=request.url.path)
raise RedirectException(to=next)
async def get_user_info(
request: Request,
session: Annotated[AsyncSession, Depends(dependencies.get_async_session)],
) -> LocalUserInfo:
"""Get User information."""
claims = get_identity_claims(request)
if claims.provider == LOCAL_ISSUER:
LOG.info("Local user, finding username %s", claims.sub)
query = (
select(User)
.where(User.username == claims.sub)
.where(User.provider == AuthProvider.LOCAL)
)
else:
query = (
select(User)
.where(User.oidc_issuer == claims.provider)
.where(User.oidc_sub == claims.sub)
)
result = await session.scalars(query)
if user := result.first():
if user.disabled:
raise RedirectException(to=URL("/logout"))
return generate_user_info(user)
next = URL("/refresh").include_query_params(next=request.url.path)
raise RedirectException(to=next)
def get_optional_username(
request: Request,
) -> str | None:
"""Get username, if available.
This is purely used for auditing purposes.
"""
try:
claims = get_identity_claims(request)
except Exception:
return None
if claims.provider == LOCAL_ISSUER:
return claims.sub
return f"oidc:{claims.email}"
def get_client_origin(request: Request) -> str:
"""Get client origin."""
fallback_origin = "UNKNOWN"
if request.client:
return request.client.host
return fallback_origin
view_dependencies = FrontendDependencies.create(
dependencies,
get_admin_backend,
templates,
refresh_identity_claims,
get_login_status,
get_user_info,
require_login,
)
app.include_router(audit.create_router(view_dependencies))
app.include_router(auth.create_router(view_dependencies))
app.include_router(clients.create_router(view_dependencies))
app.include_router(index.create_router(view_dependencies))
app.include_router(secrets.create_router(view_dependencies))
if dependencies.settings.oidc:
app.include_router(oidc_auth.create_router(view_dependencies))
return app

View File

@ -1,48 +0,0 @@
<tr
class="hover:bg-gray-100 dark:hover:bg-gray-700"
id="entry-{{ entry.id }}"
>
<td
class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400"
>
{{ entry.timestamp }}
</td>
<td
class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400"
>
<span class="bg-gray-100 text-gray-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded-sm dark:bg-gray-700 dark:text-gray-300">{{ entry.subsystem }}</span>
<span class="bg-gray-100 text-gray-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded-sm dark:bg-gray-700 dark:text-gray-300">{{ entry.operation }}</span>
{% if entry.client_id %}
<span class="bg-gray-100 text-gray-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded-sm dark:bg-gray-700 dark:text-gray-300">
Client: <abbr title="{{ entry.client_id }}">{{ entry.client_name }}</abbr>
</span>
{% endif %}
{% if entry.secret_name %}
<span class="bg-gray-100 text-gray-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded-sm dark:bg-gray-700 dark:text-gray-300">
Secret:<abbr title="{{ entry.secret_id }}">{{ entry.secret_name }}</abbr>
</span>
{% endif %}
{% if entry.data %}
{% for key, value in entry.data.items() %}
<span class="bg-gray-100 text-gray-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded-sm dark:bg-gray-700 dark:text-gray-300">
{{ key }}:{{ value }}
</span>
{% endfor %}
{% endif %}
</td>
<td
class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400"
>
{{ entry.message }}
</td>
<td
class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400"
>
{{ entry.origin }}
</td>
</tr>

View File

@ -1,7 +0,0 @@
{% extends "/base/page.html.j2" %}
{% block title %}Audit{% endblock %}
{% block page_content %}
<div id="auditContent">
{% include 'audit/inner.html.j2' %}
</div>
{% endblock %}

View File

@ -1,183 +0,0 @@
<div class="flowbite-init-target">
<div class="flex flex-col">
<div class="overflow-x-auto">
<div class="inline-block min-w-full align-middle">
<div class="overflow-hidden shadow">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-600">
<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">
<a id="filterSubsystem" data-dropdown-toggle="filterSubsystemsDropdown" class="whitespace-nowrap inline-flex items-center font-medium text-gray-500 hover:underline">
Subsystem <svg class="w-[12px] h-[12px] text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M18.425 10.271C19.499 8.967 18.57 7 16.88 7H7.12c-1.69 0-2.618 1.967-1.544 3.271l4.881 5.927a2 2 0 0 0 3.088 0l4.88-5.927Z" clip-rule="evenodd"/>
</svg>
</a>
<div id="filterSubsystemsDropdown" class="z-10 hidden bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
<div class="py-2">
<a href="?" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">All</a>
</div>
<ul class="py-2 text-sm text-gray-700 dark:text-gray-200" aria-labelledby="filterSubsystem">
<li>
<a href="?subsystem=admin" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Admin</a>
</li>
<li>
<a href="?subsystem=sshd" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Ssh Server</a>
</li>
<li>
<a href="?subsystem=backend" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Backend</a>
</li>
</ul>
</div>
</th>
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
<a id="filterOperation" data-dropdown-toggle="filterOperationsDropdown" class="whitespace-nowrap inline-flex items-center font-medium text-gray-500 hover:underline">
Operation <svg class="w-[12px] h-[12px] text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M18.425 10.271C19.499 8.967 18.57 7 16.88 7H7.12c-1.69 0-2.618 1.967-1.544 3.271l4.881 5.927a2 2 0 0 0 3.088 0l4.88-5.927Z" clip-rule="evenodd"/>
</svg>
</a>
<div id="filterOperationsDropdown" class="z-10 hidden bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
<div class="py-2">
<a href="?" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">All</a>
</div>
<ul class="py-2 text-sm text-gray-700 dark:text-gray-200" aria-labelledby="filterSubsystem">
{% for operation in operations %}
<li>
<a href="?operation={{ operation }}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">{{ operation }}</a>
</li>
{% endfor %}
</ul>
</div>
</th>
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
Client
</th>
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
Secret
</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 entries | list %}
<tr
class="{{ loop.cycle('', 'bg-gray-50 dark:bg-gray-700 ') }}hover:bg-gray-100 dark:hover:bg-gray-700"
id="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.operation }}
</td>
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
{% if entry.client_name %}
<abbr title="{{ entry.client_id }}">{{ entry.client_name }}</abbr>
{% endif %}
</td>
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
{% if entry.secret_name %}
<abbr title="{{ entry.secret_id }}">{{ entry.secret_name }}</abbr>
{% endif %}
</td>
<td
class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400"
>
{{ entry.message }}
</td>
<td
class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400"
>
{{ entry.origin }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% include 'audit/pagination.html.j2' %}
</div>

View File

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

View File

@ -1,71 +0,0 @@
<div
class="sticky bottom-0 right-0 items-center w-full p-4 bg-white border-t border-gray-200 sm:flex sm:justify-between dark:bg-gray-800 dark:border-gray-700"
>
<div class="flex items-center mb-4 sm:mb-0">
<span class="text-sm font-normal text-gray-500 dark:text-gray-400"
>Showing
{% if page_info.total < page_info.last %}
<span class="font-semibold text-gray-900 dark:text-white">{{page_info.first }}-{{ page_info.total}}</span> of
{% else %}
<span class="font-semibold text-gray-900 dark:text-white">{{page_info.first }}-{{ page_info.last}}</span> of
{% endif %}
<span class="font-semibold text-gray-900 dark:text-white"
>{{ page_info.total }}</span
></span
>
</div>
<div class="flex items-center space-x-3">
<div class="flex space-x-1">
<button
{% if page_info.page == 1 %}
class="px-3 py-1 min-w-9 min-h-9 text-sm font-normal text-gray-900 bg-white border border-gray-300 rounded hover:bg-gray-100 focus:ring-4 focus:ring-primary-300 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 transition duration-200 ease"
disabled=""
{% else %}
class="px-3 py-1 min-w-9 min-h-9 text-sm font-normal text-white 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 transition duration-200 ease"
hx-get="/audit/page/{{ page_info.page - 1 }}"
hx-target="#auditContent"
hx-push-url="true"
{% endif %}
>
Prev
</button>
{% for n in range(page_info.total_pages) %}
{% set p = n + 1 %}
{% if p == page_info.page %}
<button
class="px-3 py-1 min-w-9 min-h-9 text-sm font-normal text-white 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 transition duration-200 ease">
{{ p }}
</button>
{% else %}
<button
class="px-3 py-1 min-w-9 min-h-9 text-sm font-normal text-gray-900 bg-white border border-gray-300 rounded hover:bg-gray-100 focus:ring-4 focus:ring-primary-300 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 transition duration-200 ease"
hx-get="/audit/page/{{ p }}"
hx-target="#auditContent"
hx-push-url="true"
>
{{ p }}
</button>
{% endif %}
{% endfor %}
<button
{% if page_info.page < page_info.total_pages %}
class="px-3 py-1 min-w-9 min-h-9 text-sm font-normal text-white 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 transition duration-200 ease"
hx-get="/audit/page/{{ page_info.page + 1 }}"
hx-target="#auditContent"
hx-push-url="true"
{% else %}
class="px-3 py-1 min-w-9 min-h-9 text-sm font-normal text-gray-900 bg-white border border-gray-300 rounded hover:bg-gray-100 focus:ring-4 focus:ring-primary-300 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 transition duration-200 ease"
disabled=""
{% endif %}
>
Next
</button>
</div>
</div>

View File

@ -1,23 +0,0 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}Sshecret Admin{% endblock %}</title>
{% block head %}
{% include 'base/partials/stylesheets.html.j2' %}
{% endblock %}
</head>
<body class="bg-gray-50 text-gray-900 min-h-screen flex flex-col">
<main id="content" class="flex-1 overflow-y-auto" hx-target="this" hx-swap="innerHTML">
<div class="" id="maincontent">
{% block content %}{% endblock %}
</div>
</main>
{% block scripts %}
{% include 'base/partials/scripts.html.j2' %}
{% endblock %}
{% block local_scripts %}
{% endblock %}
</body>
</html>

View File

@ -1,50 +0,0 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}Sshecret Admin{% endblock %}</title>
{% block head %}
{% include 'base/partials/stylesheets.html.j2' %}
{% endblock %}
</head>
<body class="bg-gray-50 text-gray-900 dark:bg-gray-900 min-h-screen flex flex-col">
<!-- Layout Container -->
<div class="flex flex-1 h-full overflow-hidden">
<!-- Sidebar -->
<aside class="hidden lg:flex lg:w-64 flex-col h-full min-h-screen bg-white border-r border-gray-300 dark:bg-gray-800 dark:border-gray-700" id="sidebar" aria-label="sidebar">
{% include "base/partials/sidebar.html.j2" %}
</aside>
<!-- Main Panel -->
<div class="flex-1 flex flex-col overflow-hidden">
<!-- Topbar -->
<header class="bg-white border-b px-4 py-3 border-gray-300 dark:bg-gray-800 dark:border-gray-700">
{% include "base/partials/navbar.html.j2" %}
</header>
<!-- Main Content Area -->
<main id="content" class="flex-1 overflow-y-auto" hx-target="this" hx-swap="innerHTML">
{% block breadcrumbs %}
{% endblock %}
<div class="" id="maincontent">
{% block content %}{% endblock %}
</div>
</main>
</div>
</div>
{% block scripts %}
{% include 'base/partials/scripts.html.j2' %}
{% endblock %}
{% block local_scripts %}
{% endblock %}
</body>
</html>

View File

@ -1,59 +0,0 @@
{% extends 'base/page.html.j2' %}
{% block page_content %}
<!-- Master-Detail Split View -->
<div class="flex h-[calc(100vh-5.5rem)] lg:h-[calc(100vh-3.5rem)] overflow-hidden">
<!-- Master Pane -->
<aside id="master-pane"
class="lg:w-80 w-full shrink-0 border-r overflow-y-auto bg-white lg:block border-gray-200 p-4 dark:bg-gray-800 dark:border-gray-700 {% if mobile_show_details|default(false) -%} hidden{% endif -%}">
{% block master %}
<p class="p-4 text-gray-500">Master view (e.g. list/tree)</p>
{% endblock %}
</aside>
<!-- Detail Pane -->
<section id="detail-pane"
class="flex-1 flex overflow-y-auto bg-white p-4 lg:block {% if not mobile_show_details|default(false) -%} hidden{%- endif -%} lg:block dark:bg-gray-800">
{% block detail %}
<p class="p-4 text-gray-500 dark:text-gray-200">Select an item to view details</p>
{% endblock %}
<div class="lg:hidden h-16 block">
&nbsp;
</div>
</section>
{% block master_detail_nav %}
{# mobile navigation for master-detail views #}
<div class="lg:hidden fixed bottom-0 left-0 z-10 w-full h-16 bg-white border-t border-gray-200 dark:bg-gray-700 dark:border-gray-600">
<section id="bottom-toolbar"
class="flex-1 flex grid grid-cols-2 blace-content-between">
<div class="flex w-full justify-start">
<sl-tooltip content="Back to list">
<sl-icon-button name="caret-left" label="back" id="showMasterBtn" style="font-size: 2.5rem;"></sl-icon-button>
</sl-tooltip>
</div>
<div class="flex w-full justify-end">
<sl-tooltip content="Show Details">
<sl-icon-button name="caret-right" label="back" id="showDetailsBtn" style="font-size: 2.5rem;"></sl-icon-button>
</sl-tooltip>
</div>
</section>
</div>
{% endblock %}
</div>
</div>
<script>
{% include '/base/partials/master-detail-nav.js' %}
</script>
{% endblock %}

View File

@ -1,26 +0,0 @@
{% extends "/base/navbar.html.j2" %}
{% block content %}
<div class="flex h-[calc(100vh-3.5rem)] overflow-hidden">
<aside id="master-pane"
class="flex flex-col overflow-hidden hidden lg:block lg:w-80 w-full shrink-0 border-r bg-white border-gray-200 p-4 dark:bg-gray-800 dark:border-gray-700">
{% block master %}
{% endblock master %}
</aside>
<section id="detail-pane"
class="flex-1 flex overflow-y-auto bg-white p-4 dark:bg-gray-800">
<div class="flex flex-col w-full">
{% block detail %}
{% include '/base/partials/breadcrumbs.html.j2' %}
<div>
<p class="p-4 text-gray-500 dark:text-gray-200">Select an item to view details</p>
</div>
{% endblock detail %}
</div>
</section>
</div>
{% endblock %}

View File

@ -1,18 +0,0 @@
{% extends "/base/base.html.j2" %}
{% block breadcrumbs %}
{% endblock %}
{% block content %}
<!-- Breadcrumbs -->
<!-- Page Content -->
<section class="bg-white dark:bg-gray-800">
{% block page_content %}
<p>This is a generic page.</p>
{% endblock %}
</section>
{% endblock %}

View File

@ -1,51 +0,0 @@
function toggleDetails() {
const masterPane = document.getElementById("master-pane");
const detailPane = document.getElementById("detail-pane");
masterPane.classList.toggle("hidden");
detailPane.classList.toggle("hidden");
}
function showDetails() {
const masterPane = document.getElementById("master-pane");
const detailPane = document.getElementById("detail-pane");
masterPane.classList.add("hidden");
detailPane.classList.remove("hidden");
}
function showMaster() {
const masterPane = document.getElementById("master-pane");
const detailPane = document.getElementById("detail-pane");
masterPane.classList.remove("hidden");
detailPane.classList.add("hidden");
}
function addBtnEvent() {
const showDetailsBtn = document.getElementById("showDetailsBtn");
const showMasterBtn = document.getElementById("showMasterBtn");
const masterPane = document.getElementById("master-pane");
const detailPane = document.getElementById("detail-pane");
if (!showDetailsBtn) {
console.log("Can't find the button!");
return;
}
showMasterBtn.addEventListener("click", () => {
showMaster();
});
showDetailsBtn.addEventListener("click", () => {
showDetails();
});
}
document.addEventListener("DOMContentLoaded", () => {
addBtnEvent();
});
document.addEventListener("htmx:afterSettle", () => {
addBtnEvent();
});

View File

@ -1,86 +0,0 @@
<header class="flex items-center justify-between">
<!-- Left: Sidebar toggle (for mobile) + Title -->
<div class="flex items-center space-x-4">
<!-- Mobile sidebar toggle -->
<button
id="sidebar-toggle"
aria-expanded="true"
aria-controls="mobile-sidebar"
class="lg:hidden text-gray-600 hover:text-gray-900 focus:outline-none"
aria-label="Toggle sidebar"
>
<sl-icon name="list" class="text-xl"></sl-icon>
</button>
<!-- Page title or logo -->
<nav class="text-sm text-gray-500" aria-label="Breadcrumb">
<sl-breadcrumb id="breadcrumbs">
<sl-breadcrumb-item>
<sl-icon slot="prefix" name="house"></sl-icon>
<a href="/">Home</a>
</sl-breadcrumb-item>
{% if breadcrumbs %}
{% for label, url in breadcrumbs %}
<sl-breadcrumb-item class="page-breadcrumb">
{% if url %}
<a href="{{url}}">{{label}}</a>
{% else %}
{{ label }}
{% endif %}
</sl-breadcrumb-item>
{% endfor %}
{% endif %}
</sl-breadcrumb>
</nav>
</div>
<!-- Right: User menu -->
<div class="relative">
<button
type="button"
class="flex text-sm bg-gray-800 rounded-full focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600"
id="user-menu-button-2"
aria-expanded="false"
data-dropdown-toggle="dropdown-2"
>
<span class="sr-only">Open user menu</span>
<sl-avatar label="User avatar"></sl-avatar>
</button>
<!-- Dropdown placeholder -->
<div
class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-gray-100 rounded shadow dark:bg-gray-700 dark:divide-gray-600"
id="dropdown-2"
>
<div class="px-4 py-3" role="none">
<p class="text-sm text-gray-900 dark:text-white" role="none">
{{ user.display_name }}
</p>
</div>
<ul class="py-1" role="none">
{% if user.local %}
<li>
<a
href="/password"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white"
role="menuitem"
>Change Password</a
>
</li>
{% endif %}
<li>
<a
href="/logout"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white"
role="menuitem"
>Logout</a
>
</li>
</ul>
</div>
<!-- You can later replace this with a Flowbite dropdown or Shoelace menu -->
</div>
</header>

View File

@ -1,26 +0,0 @@
{# <script src="{{ url_for('static', path='js/sidebar.js') }}"></script> #}
<script async defer src="https://buttons.github.io/buttons.js"></script>
<script src="https://cdn.jsdelivr.net/npm/simple-datatables@9.0.3"></script>
<script src="https://cdn.jsdelivr.net/npm/flowbite@3.1.2/dist/flowbite.min.js"></script>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.20.1/cdn/shoelace-autoloader.js"></script>
<script type="text/javascript" src="{{ url_for('static', path="js/prism.js") }}"></script>
<script>
const sidebarToggle = document.getElementById('sidebar-toggle');
const sidebarDrawer = document.getElementById('master-pane');
sidebarToggle?.addEventListener('click', () => {
sidebarDrawer.classList.toggle("hidden");
});
document.body.addEventListener("htmx:afterSwap", (e) => {
const swappedEl = e.target;
const initTargets = swappedEl.querySelectorAll(".flowbite-init-target");
if (initTargets.length > 0 && typeof window.initFlowbite === "function") {
window.initFlowbite();
}
});
</script>

View File

@ -1,44 +0,0 @@
<!-- Sidebar Container -->
<!-- Top: Brand -->
<div class="px-4 py-6">
<a href="/" class="text-xl font-semibold text-gray-800 dark:text-gray-100">
<sl-icon src="{{ url_for('static', path='logo.svg') }}"></sl-icon>
Sshecret
</a>
</div>
<nav class="flex-1 overflow-y-auto px-4" aria-label="navigation">
<ul class="space-y-">
<li>
<a href="/" class="flex items-center px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700">
<sl-icon name="house"></sl-icon>
Dashboard
</a>
</li>
<li>
<a href="/clients/" class="flex items-center px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700">
<sl-icon name="person-fill-lock"> </sl-icon>
Clients
</a>
</li>
<li>
<a href="/secrets" class="flex items-center px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700">
<sl-icon name="database-lock"></sl-icon>
Secrets
</a>
</li>
<li>
<a href="/audit" class="flex items-center px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700">
<sl-icon name="card-list"></sl-icon>
Audit Log
</a>
</li>
</ul>
</nav>

View File

@ -1,50 +0,0 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link
rel="stylesheet"
href="{{ url_for('static', path='css/main.css') }}"
type="text/css"
/>
<link
rel="stylesheet"
href="{{ url_for('static', path='css/prism.css') }}"
type="text/css"
/>
<link
rel="stylesheet"
href="{{ url_for('static', path='css/style.css') }}"
type="text/css"
/>
<link
href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css"
rel="stylesheet"
/>
<link
rel="stylesheet"
media="(prefers-color-scheme:light)"
href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.20.1/cdn/themes/light.css"
/>
<link
rel="stylesheet"
media="(prefers-color-scheme:dark)"
href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.20.1/cdn/themes/dark.css"
onload="document.documentElement.classList.add('sl-theme-dark');"
/>
<script>
// On page load or when changing themes, best to add inline in `head` to avoid FOUC
if (
localStorage.getItem("color-theme") === "dark" ||
(!("color-theme" in localStorage) &&
window.matchMedia("(prefers-color-scheme: dark)").matches)
) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
</script>

View File

@ -1,71 +0,0 @@
{% extends "/base/page.html.j2" %}
{% block title %}Change Password{% endblock %}
{% block page_content %}
<div class="h-[calc(100vh-3.5rem)] bg-gray-100 flex items-center justify-center p-4">
<div class="w-full max-w-xl p-6 space-y-8 bg-white rounded-lg shadow sm:p-8 dark:bg-gray-800">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
Change Password
</h2>
{% if errors | list %}
<div class="flex p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-gray-800 dark:text-red-400" role="alert">
<svg class="shrink-0 inline w-4 h-4 me-3 mt-[2px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z"/>
</svg>
<span class="sr-only">Danger</span>
<div>
<span class="font-medium">Error changing password:</span>
<ul class="mt-1.5 list-disc list-inside">
{% for error in errors %}
<li> {{ error }}</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
<form class="mt-8 space-y-6" action="/password" method="POST" id="password-change-form">
<input hidden type="text" name="username" value="{{ user.display_name }}" autocomplete="username">
<div>
<label for="current_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Current password</label>
<input type="password" name="current_password" id="current_password" placeholder="••••••••" 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" required autocomplete="current-password">
</div>
<div hx-target="this" hx-swap="outerHTML">
<div>
<label for="password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">New password</label>
<input
type="password"
name="password"
id="password"
placeholder="••••••••"
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"
required
autocomplete="new-password"
>
</div>
<div>
<label for="confirm_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Confirm New Password</label>
<input
type="password"
name="confirm_password"
id="confirm-password"
placeholder="••••••••"
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"
required
autocomplete="new-password"
hx-post="/password/validate-confirm"
hx-include="[name='password']"
>
</div>
<div class="mt-2">
<button type="submit" class="w-full px-5 py-3 text-base font-medium text-center text-white rounded-lg bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 sm:w-auto dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">Change password</button>
</div>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@ -1,37 +0,0 @@
<div hx-target="this" hx-swap="outerHTML">
<div>
<label for="password" class="block mb-2 text-sm font-medium text-red-900 dark:text-white">New password</label>
<input
type="password"
name="password"
id="password"
placeholder="••••••••"
value="{{ password }}"
class="bg-red-50 border border-red-500 text-red-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-red-700 dark:border-red-600 dark:placeholder-red-400 dark:text-white dark:focus:ring-red-500 dark:focus:border-red-500"
required
autocomplete="new-password"
>
</div>
<div>
<label for="confirm_password" class="block mb-2 text-sm font-medium text-red-900 dark:text-white">Confirm New Password</label>
<input
type="password"
name="confirm_password"
id="confirm-password"
placeholder="••••••••"
class="bg-red-50 border border-red-500 text-red-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-red-700 dark:border-red-600 dark:placeholder-red-400 dark:text-white dark:focus:ring-red-500 dark:focus:border-red-500"
required
value="{{ confirm_password }}"
autocomplete="new-password"
hx-post="/password/validate-confirm"
hx-include="[name='password']"
>
</div>
<p class="mt-2 text-sm text-red-600 dark:text-red-500"><span class="font-medium">Oops!</span> Passwords do not match!</p>
<div class="mt-2">
<button type="submit" class="w-full px-5 py-3 text-base font-medium text-center text-white rounded-lg bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 sm:w-auto dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800 cursor-not-allowed" disabled>Change password</button>
</div>
</div>

View File

@ -1,15 +0,0 @@
{% extends "/base/page.html.j2" %}
{% block page_content %}
<div class="h-[calc(100vh-3.5rem)] bg-gray-100 flex items-center justify-center p-4">
<div class="text-center xl:max-w-4xl">
<h1 class="mb-3 text-2xl font-bold leading-tight text-gray-900 sm:text-4xl lg:text-5xl dark:text-white">Password Changed</h1>
<p class="mb-5 text-base font-normal text-gray-500 md:text-lg dark:text-gray-400">Your password was changed sucessfully. Next time you log in, use your new password.</p>
<a href="/dashboard" 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 text-center inline-flex items-center mr-3 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">
<svg class="mr-2 -ml-1 w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"></path></svg>
Go back to the dashboard
</a>
</div>
</div>
{% endblock %}

View File

@ -1,33 +0,0 @@
<div hx-target="this" hx-swap="outerHTML">
<div>
<label for="password" class="block mb-2 text-sm font-medium text-green-900 dark:text-white">New password</label>
<input
type="password"
name="password"
id="password"
placeholder="••••••••"
value="{{ password }}"
class="bg-green-50 border border-green-500 text-green-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-green-700 dark:border-green-600 dark:placeholder-green-400 dark:text-white dark:focus:ring-green-500 dark:focus:border-green-500"
required
autocomplete="new-password"
>
</div>
<div>
<label for="confirm_password" class="block mb-2 text-sm font-medium text-green-900 dark:text-white">Confirm New Password</label>
<input
type="password"
name="confirm_password"
id="confirm-password"
placeholder="••••••••"
class="bg-green-50 border border-green-500 text-green-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-green-700 dark:border-green-600 dark:placeholder-green-400 dark:text-white dark:focus:ring-green-500 dark:focus:border-green-500"
required
value="{{ confirm_password }}"
autocomplete="new-password"
hx-post="/password/validate-confirm"
hx-include="[name='password']"
>
</div>
<div class="mt-2">
<button type="submit" class="w-full px-5 py-3 text-base font-medium text-center text-white rounded-lg bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 sm:w-auto dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">Change password</button>
</div>
</div>

View File

@ -1,38 +0,0 @@
{% extends "/base/master-detail.html.j2" %}
{% block master %}
<sl-tab-group id="sideTabs" class="flex flex-col flex-1 h-full overflow-hidden master-pane-tabs">
<sl-tab slot="nav" panel="clients">Clients</sl-tab>
<sl-tab slot="nav" panel="secrets">Secrets</sl-tab>
<sl-tab slot="nav" panel="audit">Audit</sl-tab>
<sl-tab-panel name="clients" >
<div id="client-tree" class="flex flex-col flex-1 w-full h-full">
</div>
</sl-tab-panel>
<sl-tab-panel name="secrets">
<div id="secrets-tree" class="flex flex-col flex-1 w-full h-full">
</div>
</sl-tab-panel>
<sl-tab-panel name="audit">
<div id="audit-tree" class="flex flex-col flex-1 w-full h-full">
</div>
</sl-tab-panel>
</sl-tab-group>
{% endblock master %}
{% block detail %}
<div id="clientdetails" class="w-full">
{% include '/clients/partials/client_details.html.j2' %}
</div>
{% endblock detail %}
{% block local_scripts %}
<script>
{% include '/admin/partials/master.js' %}
</script>
{% endblock local_scripts %}

View File

@ -1 +0,0 @@
<p class="mt-2 text-sm text-red-600 dark:text-red-500"><span class="font-medium">Invalid value. </span> {{explanation}}.</p>

View File

@ -1,33 +0,0 @@
{% extends "/base/master-detail.html.j2" %}
{% block master %}
<sl-tab-group id="sideTabs" class="flex flex-col flex-1 h-full overflow-hidden master-pane-tabs">
<sl-tab slot="nav" panel="clients">Clients</sl-tab>
<sl-tab slot="nav" panel="secrets">Secrets</sl-tab>
<sl-tab slot="nav" panel="audit">Audit</sl-tab>
<sl-tab-panel name="clients" >
<div id="client-tree" class="flex flex-col flex-1 w-full h-full">
{% include '/clients/partials/tree.html.j2' %}
</div>
</sl-tab-panel>
<sl-tab-panel name="secrets">
<div id="secrets-tree" class="flex flex-col flex-1 w-full h-full">
</div>
</sl-tab-panel>
<sl-tab-panel name="audit">
<div id="audit-tree" class="flex flex-col flex-1 w-full h-full">
</div>
</sl-tab-panel>
</sl-tab-group>
{% endblock master %}
{% block local_scripts %}
<script>
{% include '/admin/partials/master.js' %}
</script>
{% endblock local_scripts %}

View File

@ -1,48 +0,0 @@
<div>
<div class="flex flex-col">
<div class="overflow-x-auto">
<div class="inline-block min-w-full align-middle">
<div class="overflow-hidden shadow">
<table class="min-w-full divide-y divide-gray-200 table-fixed dark:divide-gray-600" id="clientListTable">
<thead class="bg-gray-100 dark:bg-gray-700">
<tr>
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
Name
</th>
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
ID
</th>
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
Description
</th>
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
Number of secrets allocated
</th>
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
Allowed Sources
</th>
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
Actions
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200 dark:bg-gray-800 dark:divide-gray-700">
{% for client in clients %}
{% include '/clients/client.html.j2'%}
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% for client in clients %}
{% include '/clients/drawer_client_update.html.j2' %}
{% include '/clients/drawer_client_delete.html.j2' %}
{% endfor %}
</div>

View File

@ -1,102 +0,0 @@
<!-- menu -->
<div class="flowbite-init-target">
<!-- start of client details inner -->
<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 >
<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 dark:text-gray-50">{{client.name}}</h3>
{% if client.description %}
<p class="mt-1 max-w-2xl text-sm/6 text-gray-500 dark:text-gray-100">{{ 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 dark:text-gray-200">Client ID</dt>
<dd class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0 dark:text-gray-300">{{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 dark:text-gray-200">Client Description</dt>
<dd class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0 dark:text-gray-300">{{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 dark:text-gray-200">Client Version</dt>
<dd class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0 dark:text-gray-300">{{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 dark:text-gray-200">Public Key</dt>
<dd class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0 dark:text-gray-300 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 dark:text-gray-200">Assigned Secrets</dt>
<dd class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0 dark:text-gray-300">{{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 dark:text-gray-200">Allowed sources</dt>
<dd class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0 dark:text-gray-300">{{client.policies|join(', ')}}</dd>
</div>
</dl>
</div>
</div>
</div>
</sl-tab-panel>
<sl-tab-panel name="events">
<div id="client-audit-events">
{% include '/clients/partials/client_events.html.j2' %}
</div>
</sl-tab-panel>
</sl-tab-group>
</div>
<!-- end of client details -->
{% include '/clients/partials/drawer_edit.html.j2' %}

View File

@ -1,158 +0,0 @@
<table class="min-w-full lg:table-fixed 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" id="last-audit-events-body">
{% 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>
<div
class="sticky bottom-0 right-0 items-center w-full p-4 bg-white border-t border-gray-200 sm:flex sm:justify-between dark:bg-gray-800 dark:border-gray-700"
>
<div class="flex items-center mb-4 sm:mb-0">
<span class="text-sm font-normal text-gray-500 dark:text-gray-400"
>Showing
{% if events_paging.total < events_paging.last %}
<span class="font-semibold text-gray-900 dark:text-white">{{events_paging.first }}-{{ events_paging.total}}</span> of
{% else %}
<span class="font-semibold text-gray-900 dark:text-white">{{events_paging.first }}-{{ events_paging.last}}</span> of
{% endif %}
<span class="font-semibold text-gray-900 dark:text-white">{{ events_paging.total }}</span>
</span>
</div>
<div class="flex items-center space-x-3">
<div class="flex space-x-1">
<button
{% if events_paging.page == 1 %}
class="px-3 py-1 min-w-9 min-h-9 text-sm font-normal text-gray-900 bg-white border border-gray-300 rounded hover:bg-gray-100 focus:ring-4 focus:ring-primary-300 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 transition duration-200 ease"
disabled=""
{% else %}
class="px-3 py-1 min-w-9 min-h-9 text-sm font-normal text-white 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 transition duration-200 ease"
hx-get="/clients/client/{{ client.id }}/events/{{ events_paging.page - 1 }}"
hx-target="#client-audit-events"
{% endif %}
>
Prev
</button>
{% for n in range(events_paging.total_pages) %}
{% set p = n + 1 %}
{% if p == events_paging.page %}
<button
class="px-3 py-1 min-w-9 min-h-9 text-sm font-normal text-white 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 transition duration-200 ease">
{{ p }}
</button>
{% else %}
<button
class="px-3 py-1 min-w-9 min-h-9 text-sm font-normal text-gray-900 bg-white border border-gray-300 rounded hover:bg-gray-100 focus:ring-4 focus:ring-primary-300 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 transition duration-200 ease"
hx-get="/clients/client/{{client.id}}/events/{{ p }}"
hx-target="#client-audit-events"
>
{{ p }}
</button>
{% endif %}
{% endfor %}
{% if events_paging.page < events_paging.total_pages %}
<button
class="px-3 py-1 min-w-9 min-h-9 text-sm font-normal text-white 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 transition duration-200 ease"
hx-get="/clients/client/{{ client.id }}/events/{{ events_paging.page + 1 }}"
hx-target="#client-audit-events">
Next
</button>
{% else %}
<button
class="px-3 py-1 min-w-9 min-h-9 text-sm font-normal text-gray-900 bg-white border border-gray-300 rounded hover:bg-gray-100 focus:ring-4 focus:ring-primary-300 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 transition duration-200 ease"
disabled="">
Next
</button>
{% endif %}
</div>
</div>
</div>

View File

@ -1,21 +0,0 @@
function addBtnEvent() {
const swapButton = document.getElementById("swapPanes");
const masterPane = document.getElementById("master-pane");
const detailPane = document.getElementById("detail-pane");
if (!swapButton) {
console.log("Can't find the button!");
return;
}
swapButton.addEventListener("click", () => {
console.log("Swap!");
masterPane.classList.remove("hidden");
detailPane.classList.add("hidden");
});
}
document.addEventListener("htmx:afterSettle", () => {
addBtnEvent();
});
document.addEventListener("DOMContentLoaded", () => {
addBtnEvent();
});

View File

@ -1,164 +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 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

@ -1,183 +0,0 @@
<div
id="drawer-update-client-{{ client.id }}"
class="fixed top-0 right-0 z-50 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-{{ client.id }}"
aria-hidden="true"
>
<h5
id="drawer-label-{{ client.id }}"
class="inline-flex items-center text-sm font-semibold text-gray-500 uppercase dark:text-gray-400"
>
Update Client
</h5>
<button
type="button"
data-drawer-dismiss="drawer-update-client-{{ client.id }}"
aria-controls="drawer-update-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>
<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
hx-put="/clients/{{ client.id }}"
hx-target="#clientdetails"
hx-indicator="#client-update-spinner"
>
<input type="hidden" name="id" value="{{ client.id }}" />
<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-{{ client.id }}"
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"
value="{{ 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-{{ client.id }}"
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"
value="{{ 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-{{ client.id }}"
class="mt-2 text-sm text-gray-500 dark:text-gray-400"
>
Separate multiple entries with comma.
</p>
<input
type="text"
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"
placeholder="0.0.0.0/0, ::/0"
id="sources-{{client.id}}"
hx-post="/clients/validate/source"
hx-target="#clientSourceValidation-{{ client.id }}"
value="{{ client.policies|join(", ") }}"
/>
<span id="clientSourceValidation-{{ client.id }}"></span>
</div>
<div>
<label
for="public_key"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Public Key</label
>
<p
id="helper-text-explanation-{{ client.id }}"
class="mt-2 text-sm text-gray-500 dark:text-gray-400"
>
Note that this will create a new version of the client, and any existing secrets will no longer be accessible.
</p>
<textarea
id="public_key-{{ client.id }}"
name="public_key"
rows="14"
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-indicator="spinner-{{ client.id }}"
hx-target="#clientPublicKeyValidation-{{ client.id }}"
>
{{- client.public_key -}}</textarea
>
<span id="clientPublicKeyValidation-{{ client.id }}"></span>
</div>
</div>
<div>
<div
class="mt-2 text-sm text-red-600 dark:text-red-500"
id="client-update-error"
>
</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"
>
<button
type="submit"
class="w-full justify-center text-white 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"
>
Update
</button>
<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"
hx-delete="/clients/{{ client.id }}"
hx-indicator="#client-update-spinner"
hx-confirm="Are you sure?"
hx-target="#client-update-error"
id="delete-button-{{ client.id }}"
>
<svg
aria-hidden="true"
class="w-5 h-5 mr-1 -ml-1"
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
</button>
</div>
</div>
</form>
</div>

View File

@ -1,73 +0,0 @@
{% 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

@ -1,63 +0,0 @@
{# This is the master block #}
<div class="flowbite-init-target flex flex-col h-full min-h-0">
<div class="tree-header mb-2 grid grid-cols-2 place-content-between">
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">Client List</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
class="flex w-full justify-end"
>
<sl-icon-button
name="plus-square"
label="Add Client"
data-drawer-target="drawer-create-client-default"
data-drawer-show="drawer-create-client-default"
aria-controls="drawer-create-client-default"
data-drawer-placement="right"
></sl-icon-button>
</div>
</div>
<div class="col-span-full"> <!-- was: col-span-full -->
<div class="relative">
<div class="border-b border-gray-200 py-2">
<label for="default-search" class="mb-2 text-xs 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-xs 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>
</div>
{% include '/clients/partials/tree_items.html.j2' %}
</div>
{% include '/clients/partials/drawer_create.html.j2' %}

View File

@ -1,55 +0,0 @@
function setBreadcrumb(name) {
// Set the current client name as the final breadcrumb
const breadcrumbs = document.getElementById("breadcrumbs");
const existingNode = document.getElementById("bc-dynamic-client");
if (existingNode) {
breadcrumbs.removeChild(existingNode);
}
const newCrumb = document.createElement("sl-breadcrumb-item");
newCrumb.setAttribute("id", "bc-dynamic-client");
const bcTitle = document.createTextNode(name);
newCrumb.appendChild(bcTitle);
breadcrumbs.appendChild(newCrumb);
}
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 masterPane = document.getElementById("master-pane");
const detailPane = document.getElementById("detail-pane");
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",
})
.then(() => {
masterPane.classList.add("hidden");
detailPane.classList.remove("hidden");
setBreadcrumb(name);
});
}
});
}
document.addEventListener("DOMContentLoaded", () => {
addTreeListener();
});
document.addEventListener("htmx:afterSwap", () => {
addTreeListener();
});

View File

@ -1,46 +0,0 @@
<div id="client-tree-items" class="flowbite-init-target flex flex-col h-full min-h-0">
{% if more_results %}
<span class="text-gray-400 text-xs italic mt-4">{{more_results}} more results. Narrow search to show them...</span>
{% endif %}
<div class="flex-1 overflow-y-auto">
<sl-tree class="w-full">
{% 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>
</div>
{% if pages %}
<div class="shrink-0 mt-4 pt-2 border-t border-gray-100 dark:border-gray-700 bg-white dark:bg-gray-800">
<div class="mt-4 text-center flex items-center flex-col">
<span class="text-sm text-gray-700 dark:text-gray-400">
Showing <span class="font-semibold text-gray-900 dark:text-white">{{ pages.offset + 1 }}</span> to <span class="font-semibold text-gray-900 dark:text-white">{{ pages.limit }}</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>
</div>
{% endif %}
</div>

View File

@ -1,488 +0,0 @@
{% extends "/base/base.html.j2" %} {% block content %}
<div class="px-4 pt-6">
<div class="py-8 px-4 mt-4 mx-auto max-w-screen-xl text-center lg:py-16">
<h1
class="mb-4 text-4xl font-extrabold leading-none tracking-tight text-gray-900 md:text-5xl lg:text-6xl dark:text-white"
>
Welcome to Sshecret
</h1>
</div>
<div
class="grid w-full grid-cols-1 gap-4 mt-4 xl:grid-cols-2 2xl:grid-cols-3"
>
<div
class="items-center justify-between p-4 bg-white border border-gray-200 rounded-lg shadow-sm sm:flex dark:border-gray-700 sm:p-6 dark:bg-gray-800"
id="dashboard-stats-panel"
>
<div class="w-full">
<h3 class="text-base text-gray-500 dark:text-gray-400">Stats</h3>
<dl
class="max-w-md text-gray-900 divide-y divide-gray-200 dark:text-white dark:divide-gray-700"
>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 text-xs dark:text-gray-400">
Clients
</dt>
<dd class="text-lg font-semibold" id="stats-client-count">
{{ stats.clients }}
</dd>
</div>
<div class="flex flex-col py-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">
Secrets
</dt>
<dd class="text-lg font-semibold" id="stats-secret-count">
{{ stats.secrets }}
</dd>
</div>
<div class="flex flex-col py-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">
Audit Events
</dt>
<dd class="text-lg font-semibold" id="stats-audit-count">
{{ stats.audit_events }}
</dd>
</div>
</dl>
</div>
</div>
<div
class="items-center 2xl: col-span-2 xl:col-span-2 justify-between 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"
>
<div class="w-full">
<h3 class="text-base font-normal text-gray-500 dark:text-gray-400">
Last Login Events
</h3>
{% if last_login_events.total > 0 %}
<table
class="min-w-full divide-y divide-gray-200 dark:divide-gray-600"
id="last-login-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"
>
Client/Username
</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 last_login_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-login-entry-{{ entry.id }}"
data-popover-placement="bottom-end"
type="button"
id="btn-popover-login-entry-{{ entry.id }}"
>
<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-login-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 popover-login-entry"
>
<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"
>
{% if entry.client_name %} {{ entry.client_name }} {% elif
entry.data.username %} {{ entry.data.username }} {% endif %}
</td>
<td
class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white"
>
{{ entry.origin }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-sm italic">No entries</p>
{% endif %}
</div>
</div>
<div
class="items-center 2xl:col-span-3 xl:col-span-3 justify-between 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"
>
<div class="w-full">
<h3 class="text-base font-normal text-gray-500 dark:text-gray-400">
Last Audit Events
</h3>
{% if last_audit_events.total > 0 %}
<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 last_audit_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>
{% else %}
<p class="text-sm italic">No entries</p>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -1,28 +0,0 @@
<!doctype html>
<html lang="en" class="dark">
<head>
{% include '/dashboard/_header.html' %}
</head>
<body class="bg-gray-50 dark:bg-gray-800">
{% if not hide_elements %}
{% include '/dashboard/navbar.html' %}
{% endif %}
<div class="flex pt-16 overflow-hidden bg-gray-50 dark:bg-gray-900">
{% if not hide_elements %}
{% include '/dashboard/sidebar.html' %}
{% endif %}
<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>
{% block content %}
{% endblock %}
</main>
{% block sidebar %}
{% endblock %}
</div>
</div>
{% include '/dashboard/_scripts.html' %}
{% block scripts %}
{% endblock %}
</body>
</html>

View File

@ -1,21 +0,0 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="{{ page_description }}" />
<title>{{page_title}}</title>
{% include '/dashboard/_stylesheet.html' %} {% include
'/dashboard/_favicons.html' %}
<script>
// On page load or when changing themes, best to add inline in `head` to avoid FOUC
if (
localStorage.getItem("color-theme") === "dark" ||
(!("color-theme" in localStorage) &&
window.matchMedia("(prefers-color-scheme: dark)").matches)
) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
</script>

View File

@ -1,18 +0,0 @@
<script src="{{ url_for('static', path='js/sidebar.js') }}"></script>
<script async defer src="https://buttons.github.io/buttons.js"></script>
<script src="https://cdn.jsdelivr.net/npm/simple-datatables@9.0.3"></script>
<script src="https://cdn.jsdelivr.net/npm/flowbite@3.1.2/dist/flowbite.min.js"></script>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.20.1/cdn/shoelace-autoloader.js"></script>
<script type="text/javascript" src="{{ url_for('static', path="js/prism.js") }}"></script>
<script>
document.body.addEventListener("htmx:afterSwap", (e) => {
const swappedEl = e.target;
const initTargets = swappedEl.querySelectorAll(".flowbite-init-target");
if (initTargets.length > 0 && typeof window.initFlowbite === "function") {
window.initFlowbite();
}
});
</script>

View File

@ -1,37 +0,0 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link
rel="stylesheet"
href="{{ url_for('static', path='css/main.css') }}"
type="text/css"
/>
<link
rel="stylesheet"
href="{{ url_for('static', path='css/prism.css') }}"
type="text/css"
/>
<link
rel="stylesheet"
href="{{ url_for('static', path='css/style.css') }}"
type="text/css"
/>
<link
href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css"
rel="stylesheet"
/>
<link
rel="stylesheet"
media="(prefers-color-scheme:light)"
href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.20.1/cdn/themes/light.css"
/>
<link
rel="stylesheet"
media="(prefers-color-scheme:dark)"
href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.20.1/cdn/themes/dark.css"
onload="document.documentElement.classList.add('sl-theme-dark');"
/>

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="none">
{% include '/clients/drawer_client_create_inner.html.j2' %}
</form>
</div>

View File

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

View File

@ -1,102 +0,0 @@
<nav
class="fixed z-30 w-full bg-white border-b border-gray-200 dark:bg-gray-800 dark:border-gray-700"
>
<div class="px-3 py-3 lg:px-5 lg:pl-3">
<div class="flex items-center justify-between">
<div class="flex items-center justify-start">
<button
id="toggleSidebarMobile"
aria-expanded="true"
aria-controls="sidebar"
class="p-2 text-gray-600 rounded cursor-pointer lg:hidden hover:text-gray-900 hover:bg-gray-100 focus:bg-gray-100 dark:focus:bg-gray-700 focus:ring-2 focus:ring-gray-100 dark:focus:ring-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
>
<svg
id="toggleSidebarMobileHamburger"
class="w-6 h-6"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h6a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
clip-rule="evenodd"
></path>
</svg>
<svg
id="toggleSidebarMobileClose"
class="hidden w-6 h-6"
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>
</button>
<a href="/" class="flex ml-2 md:mr-24">
<img
src="{{ url_for('static', path='logo.svg') }}"
class="h-11 mr-3"
alt="Sshecret Logo"
/>
<span
class="self-center text-xl font-semibold sm:text-2xl whitespace-nowrap dark:text-white"
>Sshecret</span
>
</a>
</div>
<div class="flex items-center">
<div class="flex items-center ml-3">
<div>
<button
type="button"
class="flex text-sm bg-gray-800 rounded-full focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600"
id="user-menu-button-2"
aria-expanded="false"
data-dropdown-toggle="dropdown-2"
>
<span class="sr-only">Open user menu</span>
<sl-avatar label="User avatar"></sl-avatar>
</button>
</div>
<!-- Dropdown menu -->
<div
class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-gray-100 rounded shadow dark:bg-gray-700 dark:divide-gray-600"
id="dropdown-2"
>
<div class="px-4 py-3" role="none">
<p class="text-sm text-gray-900 dark:text-white" role="none">
{{ user.display_name }}
</p>
</div>
<ul class="py-1" role="none">
{% if user.local %}
<li>
<a
href="/password"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white"
role="menuitem"
>Change Password</a
>
</li>
{% endif %}
<li>
<a
href="/logout"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white"
role="menuitem"
>Logout</a
>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</nav>

View File

@ -1,112 +0,0 @@
<aside
id="sidebar"
class="fixed top-0 left-0 z-20 flex flex-col flex-shrink-0 hidden w-64 h-full pt-16 font-normal duration-75 lg:flex transition-width"
aria-label="Sidebar"
>
<div
class="relative flex flex-col flex-1 min-h-0 pt-0 bg-white border-r border-gray-200 dark:bg-gray-800 dark:border-gray-700"
>
<div class="flex flex-col flex-1 pt-5 pb-4 overflow-y-auto">
<div
class="flex-1 px-3 space-y-1 bg-white divide-y divide-gray-200 dark:bg-gray-800 dark:divide-gray-700"
>
<ul class="pb-2 space-y-2">
<!-- This is the menu -->
<li>
<a
href="/"
class="flex items-center p-2 text-base text-gray-900 rounded-lg hover:bg-gray-100 group dark:text-gray-200 dark:hover:bg-gray-700"
>
<svg
class="w-6 h-6 text-gray-500 transition duration-75 group-hover:text-gray-900 dark:text-gray-400 dark:group-hover:text-white"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M2 10a8 8 0 018-8v8h8a8 8 0 11-16 0z"></path>
<path d="M12 2.252A8.014 8.014 0 0117.748 8H12V2.252z"></path>
</svg>
<span class="ml-3" sidebar-toggle-item>Dashboard</span>
</a>
</li>
<li>
<a
href="/clients"
class="flex items-center p-2 text-base text-gray-900 rounded-lg hover:bg-gray-100 group dark:text-gray-200 dark:hover:bg-gray-700"
>
<svg
class="w-6 h-6 text-gray-800 dark:text-white"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 6H5m2 3H5m2 3H5m2 3H5m2 3H5m11-1a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2M7 3h11a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Zm8 7a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"
/>
</svg>
<span class="ml-3" sidebar-toggle-item>Clients</span>
</a>
</li>
<li>
<a
href="/secrets"
class="flex items-center p-2 text-base text-gray-900 rounded-lg hover:bg-gray-100 group dark:text-gray-200 dark:hover:bg-gray-700"
>
<svg
class="w-6 h-6 text-gray-800 dark:text-white"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M18 9V4a1 1 0 0 0-1-1H8.914a1 1 0 0 0-.707.293L4.293 7.207A1 1 0 0 0 4 7.914V20a1 1 0 0 0 1 1h6M9 3v4a1 1 0 0 1-1 1H4m11 13a11.426 11.426 0 0 1-3.637-3.99A11.139 11.139 0 0 1 10 11.833L15 10l5 1.833a11.137 11.137 0 0 1-1.363 5.176A11.425 11.425 0 0 1 15.001 21Z"
/>
</svg>
<span class="ml-3" sidebar-toggle-item>Secrets</span>
</a>
</li>
<li>
<a
href="/audit"
class="flex items-center p-2 text-base text-gray-900 rounded-lg hover:bg-gray-100 group dark:text-gray-200 dark:hover:bg-gray-700"
>
<svg
class="w-6 h-6 text-gray-800 dark:text-white"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 8h6m-6 4h6m-6 4h6M6 3v18l2-2 2 2 2-2 2 2 2-2 2 2V3l-2 2-2-2-2 2-2-2-2 2-2-2Z"
/>
</svg>
<span class="ml-3" sidebar-toggle-item>Audit</span>
</a>
</li>
</ul>
</div>
</div>
</div>
</aside>

View File

@ -1,71 +0,0 @@
{% extends "/dashboard/_base.html" %} {% block content %}
<div class="relative overflow-x-auto shadow-md sm:rounded-lg">
<div class="pb-4 bg-white dark:bg-gray-900">
<label for="table-search" class="sr-only">Search</label>
<div class="relative mt-1">
<div
class="absolute inset-y-0 rtl:inset-r-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="text"
id="table-search"
class="block pt-2 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg w-80 bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Search for items"
/>
</div>
</div>
<table
class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400"
>
<thead
class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400"
>
<tr>
<th scope="col" class="px-6 py-3">Client Name</th>
<th scope="col" class="px-6 py-3">Description</th>
<th scope="col" class="px-6 py-3">Action</th>
</tr>
</thead>
<tbody>
{% for client in clients %}
<tr
class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 border-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600"
>
<th
scope="row"
class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white"
>
{{ client.name }}
</th>
<td class="px-6 py-4">{{ client.description }}</td>
<td class="px-6 py-4">
<a
href="#"
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
>Edit</a
>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -1,3 +0,0 @@
<p class="mt-2 text-sm text-green-600 dark:text-red-500">
<span class="font-medium">{{ message }}</span>
</p>

View File

@ -1,3 +0,0 @@
<p class="mt-2 text-sm text-green-600 dark:text-green-500">
<span class="font-medium">{{ message }}</span>
</p>

View File

@ -1,92 +0,0 @@
{% extends "/base/bare.html.j2" %} {% block content %} {% if login_error %}
<div class="flex bg-gray-100">
<div
class="flex w-full items-center p-4 mb-4 text-sm text-red-800 border border-red-300 rounded-lg bg-red-50 dark:bg-gray-800 dark:text-red-400 dark:border-red-800"
role="alert"
>
<svg
class="shrink-0 inline w-4 h-4 me-3"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z"
/>
</svg>
<span class="sr-only">Info</span>
<div>
<span class="font-medium">{{ login_error.title }}</span>
{{login_error.message}}
</div>
</div>
</div>
{% endif %}
<div class="min-h-screen bg-gray-100 flex items-center justify-center p-4">
<div class="max-w-md w-full bg-white rounded-xl shadow-lg p-8">
<h2 class="text-2xl font-bold text-gray-900 mb-6 text-center">Sign In</h2>
<form class="space-y-4" action="/login" method="POST">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1"
>Username</label
>
<input
type="text"
name="username"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all"
placeholder="Username"
autocomplete="username"
required=""
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1"
>Password</label
>
<input
type="password"
name="password"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all"
placeholder="••••••••"
autocomplete="current-password"
required=""
/>
</div>
<button
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2.5 rounded-lg transition-colors"
type="submit"
>
Sign In
</button>
</form>
{% if oidc.enabled %}
<div class="w-full items-center text-center my-4 flex">
<div
class="w-full h-[0.125rem] box-border bg-gray-200 dark:bg-gray-700"
></div>
<div
class="px-4 text-lg text-sm font-medium text-gray-500 dark:text-gray-400"
>
Or
</div>
<div
class="w-full h-[0.125rem] box-border bg-gray-200 dark:bg-gray-700"
></div>
</div>
<div class="w-full text-center my-4">
<a href="/oidc/login">
<button
class="w-full bg-white hover:bg-gray-100 text-gray-900 border border-gray-300 transition-colors font-medium py-2.5 rounded-lg dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
>
Sign in with {{ oidc.provider_name }}
</button>
</a>
</div>
</div>
{% endif %} {% endblock %}
</div>

View File

@ -1,111 +0,0 @@
{% macro display_entry(entry) %}
<sl-tree-item
id="entry_{{ entry.name }}"
class="tree-entry-item"
data-type="entry"
data-name="{{ entry.name }}"
data-group-path="/"
{% if secret | default(false) %}
{% if secret.name == entry.name %}
selected=""
{% endif %}
{% endif %}
>
<sl-icon name="shield"> </sl-icon>
<span class="px-2">{{ entry.name }}</span>
</sl-tree-item>
{% endmacro %}
{% macro display_group(group) %}
<sl-tree-item
class="secret-group-list-item"
data-type="group"
data-name="{{ group.group_name }}"
data-group-path="{{ group.path }}"
{% if group_path_nodes | default(false) %}
{% if group.group_name in group_path_nodes %}
expanded=""
{% endif %}
{% if selected_group | default(None) %}
{% if group.path == selected_group %}
selected=""
{% endif %}
{% endif %}
{% endif %}
>
<sl-icon name="folder"> </sl-icon>
<span class="px-2">{{ group.group_name }}</span>
{% for entry in group.entries %}
{{ display_entry(entry) }}
{% endfor %}
{% for child in group.children %}
{{ display_group(child) }}
{% endfor %}
</sl-tree-item>
{% endmacro %}
{% extends 'base/master-detail-email.html.j2' %}
{% block title %}Secrets{% endblock %}
{% block master %}
<div class="flowbite-init-target">
<div id="secret-tree">
<sl-tree class="tree-with-icons">
<sl-tree-item
id="secret-group-root-item"
data-type="root"
data-name="root"
{% if "/" in group_path_nodes %}
expanded=""
{% endif %}
{% if selected_group == "/"%}
selected=""
{% endif %}
>
<sl-icon name="folder"> </sl-icon>
<span class="px-2">Ungrouped</span>
{% for entry in groups.ungrouped %}
{{ display_entry(entry) }}
{% endfor %}
</sl-tree-item>
{% for child in groups.groups %}
{{ display_group(child) }}
{% endfor %}
</sl-tree>
</div>
</div>
{% endblock %}
{% block detail %}
{% if group_page | default(false) %}
<div class="w-full" id="secretdetails">
{% include '/secrets/partials/group_detail.html.j2' %}
</div>
{% elif root_group_page | default(false) %}
<div class="w-full" id="secretdetails">
{% include '/secrets/partials/edit_root.html.j2' %}
</div>
{% elif secret_page | default(false) %}
<div class="w-full" id="secretdetails">
{% include '/secrets/partials/tree_detail.html.j2' %}
</div>
{% else %}
{% include '/secrets/partials/default_detail.html.j2' %}
{% endif %}
{% endblock %}
{% block local_scripts %}
<script>
{% include '/secrets/partials/tree_event.js' %}
</script>
{% endblock %}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,94 +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="Secret name"
required=""
/>
</div>
<div>
<label
for="value"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Secret Value</label
>
<p
id="helper-text-explanation"
class="mt-2 text-sm text-gray-500 dark:text-gray-400"
>
Enter the secret string here.
</p>
<input
type="text"
name="value"
id="secretValueInput"
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="Your secret string here"
/>
</div>
<div>
<label
for="auto_generate"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>
<label class="inline-flex items-center cursor-pointer" id="autoGenerateCheckboxLabel">
<input
type="checkbox"
name="auto_generate"
id="autoGenerateCheckbox"
class="sr-only peer"
hx-on:change="document.getElementById('secretValueInput').disabled = this.checked;
if (this.checked) { document.getElementById('secretValueInput').value = '' }"
/>
<div
class="relative w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600 dark:peer-checked:bg-blue-600"
></div>
<span
class="ms-3 text-sm font-medium text-gray-900 dark:text-gray-300"
>Auto-generate secret</span
>
</label>
</label>
</div>
<div>
<label
for="clients"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Clients</label
>
<select
multiple="multiple"
id="clients"
name="clients"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
>
<option selected="selected">Select clients to assign the secret to</option>
{% for client in clients %}
<option value="{{ client.id }}">{{ client.name }}</option>
{% endfor %}
</select>
</div>
<div
>
<button
type="submit"
class="text-white w-full justify-center bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
>
Add Secret
</button>
</div>
</div>

View File

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

View File

@ -1,27 +0,0 @@
function addBtnEvent() {
const swapButton = document.getElementById("swapPanes");
const masterPane = document.getElementById("master-pane");
const detailPane = document.getElementById("detail-pane");
if (!swapButton) {
console.log("Can't find the button!");
return;
}
swapButton.addEventListener("click", () => {
console.log("Swap!");
masterPane.classList.remove("hidden");
detailPane.classList.add("hidden");
});
}
document.addEventListener("DOMContentLoaded", () => {
const masterPane = document.getElementById("master-pane");
const detailPane = document.getElementById("detail-pane");
masterPane.classList.add("hidden");
detailPane.classList.remove("hidden");
addBtnEvent();
});
document.addEventListener("htmx:afterSettle", () => {
addBtnEvent();
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,116 +0,0 @@
function createCrumb(name, url = null) {
// Create a breadcrumb
const crumb = document.createElement("sl-breadcrumb-item");
crumb.classList.add("page-breadcrumb");
if (url) {
var crumbChild = document.createElement("a");
crumbChild.setAttribute("href", url);
const crumbChildText = document.createTextNode(name);
crumbChild.appendChild(crumbChildText);
} else {
var crumbChild = document.createTextNode(name);
}
crumb.appendChild(crumbChild);
return crumb;
}
function setGroupBreadcrumbs(name, path, secret = null) {
// Set breadcrumbs for a whole group.
const breadcrumbs = document.getElementById("breadcrumbs");
// First, remove all existing page breadcrumbs
console.log(`setGroupBreadcrumbs: ${name} ${path}`);
let pageCrumbs = document.getElementsByClassName("page-breadcrumb");
for (let i = 0; i < pageCrumbs.length; i++) {
breadcrumbs.removeChild(pageCrumbs[i]);
}
// Re-create the breadcrumbs
const newcrumbs = [
["Secrets", "/secrets/"],
["Groups", "/secrets/groups/"],
];
if (path) {
const pathnodes = path.split("/");
for (let i = 0; i < pathnodes.length; i++) {
let pathnode = pathnodes[i];
let nextnode = i + 1;
let groupPathNodes = pathnodes.slice(0, nextnode);
let groupPath = groupPathNodes.join("/");
newcrumbs.push([pathnode, `/secrets/groups/${groupPath}`]);
}
} else {
newcrumbs.push(["Ungrouped", "/secrets/groups/"]);
}
if (secret) {
newcrumbs.push([secret, `/secrets/secret/${secret}`]);
}
for (let i = 0; i < newcrumbs.length; i++) {
let crumbParam = newcrumbs[i];
let newcrumb = createCrumb(crumbParam[0], crumbParam[1]);
breadcrumbs.appendChild(newcrumb);
}
}
function toggleDetails() {
const masterPane = document.getElementById("master-pane");
const detailPane = document.getElementById("detail-pane");
masterPane.classList.toggle("hidden");
detailPane.classList.toggle("hidden");
}
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.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",
})
.then(() => {
toggleDetails();
selectedEl.addEventListener("click", () => {
toggleDetails();
});
});
}
});
}
document.addEventListener("DOMContentLoaded", () => {
addTreeListener();
});
document.addEventListener("htmx:afterSwap", () => {
addTreeListener();
});

View File

@ -1,25 +0,0 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<title>{{ page_title }}</title>
<meta name="description" content="{{ page_description }}" />
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<link
rel="stylesheet"
href="{{ url_for('static', path='css/main.css') }}"
type="text/css"
/>
<link
href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css"
rel="stylesheet"
/>
</head>
<body>
{% block content %}{% endblock %}
</body>
</html>

View File

@ -1,95 +0,0 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<title>{{ page_title }}</title>
<meta name="description" content="{{ page_description }}" />
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<link
rel="stylesheet"
href="{{ url_for('static', path='css/main.css') }}"
type="text/css"
/>
<link
href="https://cdn.jsdelivr.net/npm/flowbite@3.1.2/dist/flowbite.min.css"
rel="stylesheet"
/>
<link
href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css"
rel="stylesheet"
/>
</head>
<body>
<nav class="bg-white border-gray-200 dark:bg-gray-900">
<div class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4">
<div class="flex items-center md:order-2 space-x-3 md:space-x-0 rtl:space-x-reverse">
<button type="button" class="flex text-sm bg-gray-800 rounded-full md:me-0 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600" id="user-menu-button" aria-expanded="false" data-dropdown-toggle="user-dropdown" data-dropdown-placement="bottom">
<span class="sr-only">Open user menu</span>
<svg class="w-8 h-8" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" r="50" />
</svg>
</button>
<div class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-gray-100 rounded-lg shadow-sm dark:bg-gray-700 dark:divide-gray-600" id="user-dropdown">
<div class="px-4 py-3">
<span class="block text-sm text-gray-900 dark:text-white">{{ user }}</span>
</div>
<ul class="py-2" aria-labelledby="user-menu-button">
<li>
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Change Password</a>
</li>
<li>
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Log out</a>
</li>
</ul>
</div>
</div>
</div>
</nav>
<button data-drawer-target="default-sidebar" data-drawer-toggle="default-sidebar" aria-controls="default-sidebar" type="button" class="inline-flex items-center p-2 mt-2 ms-3 text-sm text-gray-500 rounded-lg sm:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600">
<span class="sr-only">Open sidebar</span>
<svg class="w-6 h-6" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path clip-rule="evenodd" fill-rule="evenodd" d="M2 4.75A.75.75 0 012.75 4h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 4.75zm0 10.5a.75.75 0 01.75-.75h7.5a.75.75 0 010 1.5h-7.5a.75.75 0 01-.75-.75zM2 10a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 10z"></path>
</svg>
</button>
<aside id="default-sidebar" class="fixed top-0 left-0 z-40 w-64 h-screen transition-transform -translate-x-full sm:translate-x-0" aria-label="Sidebar">
<div class="h-full px-3 py-4 overflow-y-auto bg-gray-50 dark:bg-gray-800">
<a href="/" class="flex items-center ps-2.5 mb-5">
<img src="{{ url_for('static', path='logo.svg') }}" class="h-6 me-3 sm:h-7" alt="Sshecret Logo" />
<span class="self-center text-xl font-semibold whitespace-nowrap dark:text-white">Sshecret</span>
</a>
<ul class="space-y-2 font-medium">
<li>
<a href="#" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<svg class="shrink-0 w-5 h-5 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12a1 1 0 0 0-1 1v4a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-4a1 1 0 0 0-1-1M5 12h14M5 12a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1m-2 3h.01M14 15h.01M17 9h.01M14 9h.01"/>
</svg>
<span class="ms-3">Clients</span>
</a>
</li>
<li>
<a href="#" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9V4a1 1 0 0 0-1-1H8.914a1 1 0 0 0-.707.293L4.293 7.207A1 1 0 0 0 4 7.914V20a1 1 0 0 0 1 1h6M9 3v4a1 1 0 0 1-1 1H4m11 13a11.426 11.426 0 0 1-3.637-3.99A11.139 11.139 0 0 1 10 11.833L15 10l5 1.833a11.137 11.137 0 0 1-1.363 5.176A11.425 11.425 0 0 1 15.001 21Z"/>
</svg>
<span class="ms-3">Secrets</span>
</a>
</li>
</ul>
</div>
</aside>
<div class="p-4 sm:ml-64">
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/flowbite@3.1.2/dist/flowbite.min.js"></script>
</body>
</html>

View File

@ -1,128 +0,0 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<title>{{ page_title }}</title>
<meta name="description" content="{{ page_description }}" />
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<link
rel="stylesheet"
href="{{ url_for('static', path='css/main.css') }}"
type="text/css"
/>
<link
href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css"
rel="stylesheet"
/>
</head>
<body>
<div
class="fixed left-0 top-0 w-64 h-full bg-[#f8f4f3] p-4 z-50 sidebar-menu transition-transform"
>
<a href="#" class="flex items-center pb-4 border-b border-b-gray-800">
<h2 class="font-bold text-2xl">
SSHecret
<span class="bg-[#f84525] text-white px-2 rounded-md">Admin</span>
</h2>
</a>
<!-- MENU -->
<ul class="mt4">
<span class="text-gray-400 font-bold">Admin</span>
<li class="mb-1 group">
<a
href="/clients"
class="flex font-semibold items-center py-2 px-4 text-gray-900 hover:bg-gray-950 hover:text-gray-100 rounded-md group-[.active]:bg-gray-800 group-[.active]:text-white group-[.selected]:bg-gray-950 group-[.selected]:text-gray-100"
>
<i class="ri-server-line mr-3 text-lg"></i>
<span class="text-sm">Clients</span>
</a>
</li>
<li class="mb-1 group">
<a
href="/secrets"
class="flex font-semibold items-center py-2 px-4 text-gray-900 hover:bg-gray-950 hover:text-gray-100 rounded-md group-[.active]:bg-gray-800 group-[.active]:text-white group-[.selected]:bg-gray-950 group-[.selected]:text-gray-100"
>
<i class="ri-safe-2-line mr-3 text-lg"></i>
<span class="text-sm">Secrets</span>
</a>
</li>
</ul>
</div>
<div
class="fixed top-0 left-0 w-full h-full bg-black/50 z-40 md:hidden sidebar-overlay"
></div>
<main
class="w-full md:w-[calc(100%-256px)] md:ml-64 bg-gray-200 min-h-screen transition-all main"
>
<!-- navbar -->
<div
class="py-2 px-6 bg-[#f8f4f3] flex items-center shadow-md shadow-black/5 sticky top-0 left-0 z-30"
>
<button
type="button"
class="text-lg text-gray-900 font-semibold sidebar-toggle"
>
<i class="ri-menu-line"></i>
</button>
<ul class="ml-auto flex items-center">
<li class="dropdown ml-3">
<button type="button" class="dropdown-toggle flex items-center">
<div class="flex-shrink-0 w-10 h-10 relative">
<div
class="p-1 bg-white rounded-full focus:outline-none focus:ring"
>
<div
class="top-0 left-7 absolute w-3 h-3 bg-lime-400 border-2 border-white rounded-full animate-ping"
></div>
<div
class="top-0 left-7 absolute w-3 h-3 bg-lime-500 border-2 border-white rounded-full"
></div>
</div>
</div>
<div class="p-2 md:block text-left">
<h2 class="text-sm font-semibold text-gray-800">
{{ user.username }}
</h2>
</div>
</button>
<ul
class="dropdown-menu shadow-md shadow-black/5 z-30 hidden py-1.5 rounded-md bg-white border border-gray-100 w-full max-w-[140px]"
>
<li>
<a
href="#"
class="flex items-center text-[13px] py-1.5 px-4 text-gray-600 hover:text-[#f84525] hover:bg-gray-50"
>Profile</a
>
</li>
<li>
<a
href="#"
class="flex items-center text-[13px] py-1.5 px-4 text-gray-600 hover:text-[#f84525] hover:bg-gray-50"
>Settings</a
>
</li>
<li>
<form method="POST" action="">
<a
role="menuitem"
class="flex items-center text-[13px] py-1.5 px-4 text-gray-600 hover:text-[#f84525] hover:bg-gray-50 cursor-pointer"
onclick="event.preventDefault();
this.closest('form').submit();"
>
Log Out
</a>
</form>
</li>
</ul>
</li>
</ul>
</div>
<div class="p-6">{% block content %}{% endblock %}</div>
</main>
</body>
</html>

View File

@ -1,6 +0,0 @@
{% extends "/shared/_base.html" %} {% block content %}
<h1>Hooray!</h1>
<p>It worked!</p>
<p>Welcome, {{ user.username }}</p>
{% endblock %}

View File

@ -1 +0,0 @@
"""Frontend views."""

View File

@ -1,91 +0,0 @@
"""Audit view factory."""
# pyright: reportUnusedFunction=false
import logging
from typing import Annotated, cast
from fastapi import APIRouter, Depends, Request, Response
from sshecret.backend import AuditFilter, Operation
from sshecret_admin.auth import LocalUserInfo
from sshecret_admin.services import AdminBackend
from .common import PagingInfo
from ..dependencies import FrontendDependencies
LOG = logging.getLogger(__name__)
def create_router(dependencies: FrontendDependencies) -> APIRouter:
"""Create clients router."""
app = APIRouter()
templates = dependencies.templates
async def resolve_audit_entries(
request: Request,
current_user: LocalUserInfo,
admin: AdminBackend,
page: int,
filters: AuditFilter,
) -> Response:
"""Resolve audit entries."""
LOG.info("Page: %r", page)
per_page = 20
offset = 0
if page > 1:
offset = (page - 1) * per_page
filter_args = cast(dict[str, str], filters.model_dump(exclude_none=True))
audit_log = await admin.get_audit_log_detailed(offset, per_page, **filter_args)
page_info = PagingInfo(
page=page, limit=per_page, total=audit_log.total, offset=offset
)
operations = list(Operation)
breadcrumbs = [("Audit", "/audit/")]
if request.headers.get("HX-Request"):
return templates.TemplateResponse(
request,
"audit/inner.html.j2",
{
"entries": audit_log.results,
"page_info": page_info,
"operations": operations,
},
)
return templates.TemplateResponse(
request,
"audit/index.html.j2",
{
"page_title": "Audit Log",
"breadcrumbs": breadcrumbs,
"entries": audit_log.results,
"user": current_user,
"page_info": page_info,
"operations": operations,
},
)
@app.get("/audit/")
async def get_audit_entries(
request: Request,
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
filters: Annotated[AuditFilter, Depends()],
) -> Response:
"""Get audit entries."""
return await resolve_audit_entries(request, current_user, admin, 1, filters)
@app.get("/audit/page/{page}")
async def get_audit_entries_page(
request: Request,
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
filters: Annotated[AuditFilter, Depends()],
page: int,
) -> Response:
"""Get audit entries."""
LOG.info("Get audit entries page: %r", page)
return await resolve_audit_entries(request, current_user, admin, page, filters)
return app

View File

@ -1,218 +0,0 @@
"""Authentication related views factory."""
# pyright: reportUnusedFunction=false
import logging
from pydantic import BaseModel
from typing import Annotated
from fastapi import APIRouter, Depends, Query, Request, Response, status
from fastapi.responses import RedirectResponse
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sshecret_admin.services import AdminBackend
from starlette.datastructures import URL
from sshecret_admin.auth import (
IdentityClaims,
authenticate_user_async,
create_access_token,
create_refresh_token,
)
from sshecret.backend.models import Operation
from ..dependencies import FrontendDependencies
from ..exceptions import RedirectException
LOG = logging.getLogger(__name__)
class LoginError(BaseModel):
"""Login error."""
title: str
message: str
class OidcLogin(BaseModel):
"""Small container to hold OIDC info for the login box."""
enabled: bool = False
provider_name: str | None = None
async def audit_login_failure(
admin: AdminBackend, username: str, request: Request
) -> None:
"""Write login failure to audit log."""
origin: str | None = None
if request.client:
origin = request.client.host
await admin.write_audit_message(
operation=Operation.DENY,
message="Login failed",
origin=origin or "UNKNOWN",
username=username,
)
def create_router(dependencies: FrontendDependencies) -> APIRouter:
"""Create auth router."""
app = APIRouter()
templates = dependencies.templates
@app.get("/login")
async def get_login(
request: Request,
login_status: Annotated[bool, Depends(dependencies.get_login_status)],
error_title: str | None = None,
error_message: str | None = None,
):
"""Get index."""
if login_status:
return RedirectResponse("/dashboard")
login_error: LoginError | None = None
if error_title and error_message:
LOG.info("Got an error here: %s %s", error_title, error_message)
login_error = LoginError(title=error_title, message=error_message)
else:
LOG.info("Got no errors")
oidc_login = OidcLogin()
if dependencies.settings.oidc:
oidc_login.enabled = True
oidc_login.provider_name = dependencies.settings.oidc.name
return templates.TemplateResponse(
request,
"login.html",
{
"page_title": "Login",
"page_description": "Login page.",
"login_error": login_error,
"oidc": oidc_login,
},
)
@app.post("/login")
async def login_user(
request: Request,
response: Response,
session: Annotated[AsyncSession, Depends(dependencies.get_async_session)],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
next: Annotated[str, Query()] = "/dashboard",
error_title: str | None = None,
error_message: str | None = None,
):
"""Log in user."""
if error_title and error_message:
login_error = LoginError(title=error_title, message=error_message)
return templates.TemplateResponse(
request,
"login.html",
{
"page_title": "Login",
"page_description": "Login page.",
"login_error": login_error,
},
)
user = await authenticate_user_async(
session, form_data.username, form_data.password
)
login_failed = RedirectException(
to=URL("/login").include_query_params(
error_title="Login Error", error_message="Invalid username or password"
)
)
if not user:
await audit_login_failure(admin, form_data.username, request)
raise login_failed
token_data: dict[str, str] = {"sub": user.username}
access_token = create_access_token(dependencies.settings, data=token_data)
refresh_token = create_refresh_token(dependencies.settings, data=token_data)
if next == "/refresh":
# Don't redirect from login to refresh. Send to dashboard instead.
next = "/"
response = RedirectResponse(url=next, status_code=status.HTTP_302_FOUND)
response.set_cookie(
"access_token",
value=access_token,
httponly=True,
secure=False,
samesite="strict",
)
response.set_cookie(
"refresh_token",
value=refresh_token,
httponly=True,
secure=False,
samesite="strict",
)
origin = "UNKNOWN"
if request.client:
origin = request.client.host
await admin.write_audit_message(
operation=Operation.LOGIN,
message="Logged in to admin frontend",
origin=origin,
username=form_data.username,
)
return response
@app.get("/refresh")
async def get_refresh_token(
response: Response,
refresh_claims: Annotated[
IdentityClaims, Depends(dependencies.get_refresh_claims)
],
next: Annotated[str, Query()],
):
"""Refresh tokens.
We might as well refresh the long-lived one here.
"""
token_data: dict[str, str] = {"sub": refresh_claims.sub}
access_token = create_access_token(
dependencies.settings, data=token_data, provider=refresh_claims.provider
)
refresh_token = create_refresh_token(
dependencies.settings, data=token_data, provider=refresh_claims.provider
)
response = RedirectResponse(url=next, status_code=status.HTTP_302_FOUND)
response.set_cookie(
"access_token",
value=access_token,
httponly=True,
secure=False,
samesite="strict",
)
response.set_cookie(
"refresh_token",
value=refresh_token,
httponly=True,
secure=False,
samesite="strict",
)
return response
@app.get("/logout")
async def logout(
response: Response,
):
"""Log out user."""
response = RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
response.delete_cookie(
"refresh_token", httponly=True, secure=False, samesite="strict"
)
response.delete_cookie(
"access_token", httponly=True, secure=False, samesite="strict"
)
return response
return app

View File

@ -1,424 +0,0 @@
"""clients view factory."""
# pyright: reportUnusedFunction=false
import ipaddress
import logging
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response
from fastapi.responses import RedirectResponse
from pydantic import BaseModel, IPvAnyAddress, IPvAnyNetwork
from sshecret_admin.frontend.views.common import PagingInfo
from sshecret.backend import ClientFilter
from sshecret.backend.models import Client, ClientQueryResult, FilterType
from sshecret.crypto import validate_public_key
from sshecret_admin.auth import LocalUserInfo
from sshecret_admin.services import AdminBackend
from ..dependencies import FrontendDependencies
LOG = logging.getLogger(__name__)
CLIENTS_PER_PAGE = 20
EVENTS_PER_PAGE = 20
class ClientUpdate(BaseModel):
id: uuid.UUID
name: str
description: str
public_key: str
sources: str | None = None
class ClientCreate(BaseModel):
name: str
public_key: str
description: 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:
"""Create clients router."""
app = APIRouter(dependencies=[Depends(dependencies.require_login)])
templates = dependencies.templates
@app.get("/clients/")
async def get_test_page(
request: Request,
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> Response:
"""Get test page."""
breadcrumbs = [("clients", "/clients/")]
return templates.TemplateResponse(
request,
"admin/index.html.j2",
{
"breadcrumbs": breadcrumbs,
"page_title": "Clients",
"user": current_user,
}
)
# @app.get("/clients/")
# async def get_client_tree(
# request: Request,
# current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
# admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
# ) -> Response:
# """Get client tree view."""
# page = 1
# 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
# )
# breadcrumbs = [("clients", "/clients/")]
# LOG.info("Results %r", results)
# return templates.TemplateResponse(
# request,
# "clients/index_new.html.j2",
# {
# "breadcrumbs": breadcrumbs,
# "page_title": "Clients",
# "offset": offset,
# "pages": paginate,
# "clients": results.clients,
# "user": current_user,
# "results": results,
# },
# )
@app.get("/clients/page/{page}")
async def get_client_page(
request: Request,
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
page: int,
) -> Response:
"""Get client tree view."""
per_page = CLIENTS_PER_PAGE
offset = 0
if page > 1:
offset = (page - 1) * per_page
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(
request,
template,
{
"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=EVENTS_PER_PAGE, client_name=results.client.name
)
template = "clients/client.html.j2"
breadcrumbs = [
("clients", "/clients/"),
(results.client.name, request.url.path),
]
headers: dict[str, str] = {}
if request.headers.get("HX-Request"):
headers["HX-Push-Url"] = request.url.path
template = "clients/partials/client_details.html.j2"
events_paging = PagingInfo(
page=1, limit=EVENTS_PER_PAGE, total=events.total, offset=0
)
return templates.TemplateResponse(
request,
template,
{
"page_title": f"Client {results.client.name}",
"breadcrumbs": breadcrumbs,
"pages": results.pages,
"clients": results.results.clients,
"client": results.client,
"user": current_user,
"results": results.results,
"events": events,
"events_paging": events_paging,
},
headers=headers,
)
@app.get("/clients/client/{id}/events/{page}")
async def get_client_events(
request: Request,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
id: str,
page: int,
) -> Response:
"""Get more events for a client."""
if "HX-Request" not in request.headers:
return RedirectResponse(url=f"/clients/client/{id}")
client = await admin.get_client(("id", id))
if not client:
raise HTTPException(status_code=404, detail="Client not found.")
offset = 0
if page > 1:
offset = (page - 1) * EVENTS_PER_PAGE
events = await admin.get_audit_log_detailed(
limit=EVENTS_PER_PAGE, client_name=client.name, offset=offset
)
events_paging = PagingInfo(
page=page, limit=EVENTS_PER_PAGE, total=events.total, offset=offset
)
return templates.TemplateResponse(
request,
"clients/partials/client_events.html.j2",
{
"events": events,
"client": client,
"events_paging": events_paging,
},
)
@app.put("/clients/{id}")
async def update_client(
request: Request,
id: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
client: Annotated[ClientUpdate, Form()],
):
"""Update a client."""
original_client = await admin.get_client(("id", id))
if not original_client:
return templates.TemplateResponse(
request, "fragments/error.html", {"message": "Client not found"}
)
sources: list[IPvAnyAddress | IPvAnyNetwork] = []
if client.sources:
source_str = client.sources.split(",")
for source in source_str:
if "/" in source:
sources.append(ipaddress.ip_network(source.strip()))
else:
sources.append(ipaddress.ip_address(source.strip()))
client_fields = client.model_dump(exclude_unset=True)
del client_fields["sources"]
if sources:
client_fields["policies"] = sources
LOG.info("Fields: %r", client_fields)
updated_client = original_client.model_copy(update=client_fields)
final_client = await admin.update_client(updated_client)
events = await admin.get_audit_log_detailed(
limit=EVENTS_PER_PAGE, client_name=client.name
)
events_paging = PagingInfo(
page=1, limit=EVENTS_PER_PAGE, total=events.total, offset=0
)
return templates.TemplateResponse(
request,
"clients/partials/client_details.html.j2",
{
"client": final_client,
"events": events,
"events_paging": events_paging,
},
)
@app.post("/clients/")
async def create_client(
request: Request,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
client: Annotated[ClientCreate, Form()],
) -> Response:
"""Create client."""
sources: list[str] | None = None
if client.sources:
sources = [source.strip() for source in client.sources.split(",")]
await admin.create_client(
name=client.name,
public_key=client.public_key,
description=client.description,
sources=sources,
)
headers = {"Hx-Refresh": "true"}
return Response(
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")
async def validate_client_source(
request: Request,
sources: Annotated[str, Form()],
) -> Response:
"""Validate source."""
source_str = sources.split(",")
for source in source_str:
if "/" in source:
try:
_network = ipaddress.ip_network(source.strip())
except Exception:
return templates.TemplateResponse(
request,
"/clients/field_invalid.html.j2",
{"explanation": f"Invalid network {source.strip()}"},
)
else:
try:
_address = ipaddress.ip_address(source.strip())
except Exception:
return templates.TemplateResponse(
request,
"/clients/field_invalid.html.j2",
{"explanation": f"Invalid address {source.strip()}"},
)
return templates.TemplateResponse(
request,
"/clients/field_valid.html.j2",
)
@app.post("/clients/validate/public_key")
async def validate_client_public_key(
request: Request,
public_key: Annotated[str, Form()],
) -> Response:
"""Validate source."""
if validate_public_key(public_key.rstrip()):
return templates.TemplateResponse(
request,
"/clients/field_valid.html.j2",
)
return templates.TemplateResponse(
request,
"/clients/field_invalid.html.j2",
{"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

View File

@ -1,41 +0,0 @@
"""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,190 +0,0 @@
"""Front page view factory."""
# pyright: reportUnusedFunction=false
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Form, Request, Response
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sshecret_admin.auth import LocalUserInfo, authenticate_user_async
from sshecret_admin.auth.authentication import hash_password
from sshecret_admin.frontend.exceptions import RedirectException
from sshecret_admin.services import AdminBackend
from starlette.datastructures import URL
from sshecret.backend.models import Operation
from ..dependencies import FrontendDependencies
LOG = logging.getLogger(__name__)
START_PAGE = "/dashboard"
LOGIN_PAGE = "/login"
class StatsView(BaseModel):
"""Stats for the frontend."""
clients: int = 0
secrets: int = 0
audit_events: int = 0
class PasswordChangeForm(BaseModel):
"""Password change form data."""
current_password: str
password: str
confirm_password: str
async def get_stats(admin: AdminBackend) -> StatsView:
"""Get stats for the frontpage."""
clients = await admin.get_clients()
secrets = await admin.get_secrets()
audit = await admin.get_audit_log_count()
return StatsView(clients=len(clients), secrets=len(secrets), audit_events=audit)
def create_router(dependencies: FrontendDependencies) -> APIRouter:
"""Create auth router."""
app = APIRouter()
templates = dependencies.templates
@app.get("/")
def get_index(logged_in: Annotated[bool, Depends(dependencies.get_login_status)]):
"""Get the index."""
next = LOGIN_PAGE
if logged_in:
next = START_PAGE
return RedirectResponse(url=next)
@app.get("/dashboard")
async def get_dashboard(
request: Request,
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
):
"""Dashboard for mocking up the dashboard."""
stats = await get_stats(admin)
last_login_events = await admin.get_audit_log_detailed(
limit=5, operation="login"
)
last_audit_events = await admin.get_audit_log_detailed(limit=10)
LOG.info("CurrentUser: %r", current_user)
return templates.TemplateResponse(
request,
"dashboard.html",
{
"page_title": "Dashboard",
"user": current_user,
"stats": stats,
"last_login_events": last_login_events,
"last_audit_events": last_audit_events,
},
)
@app.get("/password")
async def get_change_password(
request: Request,
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
):
"""Render Change password site."""
if not current_user.local:
LOG.debug("User tried to change password, but is not a local user.")
return RedirectException(to=URL("/"))
return templates.TemplateResponse(
request,
"change_password/index.html.j2",
{
"page_title": "Change Password",
"user": current_user,
"errors": [],
},
)
@app.post("/password")
async def change_password(
request: Request,
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
session: Annotated[AsyncSession, Depends(dependencies.get_async_session)],
passwd_form: Annotated[PasswordChangeForm, Form()],
):
"""Change password."""
errors: list[str] = []
user = await authenticate_user_async(
session, current_user.display_name, passwd_form.current_password
)
new_password_matches = passwd_form.password == passwd_form.confirm_password
if not user:
errors.append("Invalid current password entered")
if not new_password_matches:
errors.append("Passwords do not match")
if errors:
return templates.TemplateResponse(
request,
"change_password/index.html.j2",
{
"page_title": "Change Password",
"user": current_user,
"errors": errors,
},
)
assert user is not None
new_password_hash = hash_password(passwd_form.password)
user.hashed_password = new_password_hash
session.add(user)
await session.commit()
origin = "UNKNOWN"
if request.client:
origin = request.client.host
await admin.write_audit_message(
Operation.UPDATE,
"User changed their password",
origin,
username=user.username,
)
return templates.TemplateResponse(
request,
"change_password/success.html.j2",
{
"page_title": "Change Password success",
"user": current_user,
},
)
@app.post("/password/validate-confirm")
async def validate_password_match(
request: Request,
password: Annotated[str, Form()],
confirm_password: Annotated[str, Form()],
):
"""Validate password matches."""
valid = "/change_password/valid_password.html.j2"
invalid = "/change_password/invalid_password.html.j2"
template = valid
if password != confirm_password:
template = invalid
LOG.info("Password matches: %r", (password == confirm_password))
return templates.TemplateResponse(
request,
template,
{
"password": password,
"confirm_password": confirm_password,
},
)
return app

View File

@ -1,142 +0,0 @@
"""Optional OIDC auth module."""
# pyright: reportUnusedFunction=false
import logging
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from pydantic import ValidationError
from sqlalchemy.ext.asyncio import AsyncSession
from sshecret_admin.auth import create_access_token, create_refresh_token
from sshecret_admin.auth.authentication import generate_user_info, handle_oidc_claim
from sshecret_admin.auth.exceptions import AuthenticationFailedError
from sshecret_admin.auth.oidc import AdminOidc
from sshecret_admin.frontend.exceptions import RedirectException
from sshecret_admin.services import AdminBackend
from starlette.datastructures import URL
from sshecret.backend.models import Operation
from ..dependencies import FrontendDependencies
LOG = logging.getLogger(__name__)
async def audit_login_failure(
admin: AdminBackend,
error_message: str,
request: Request,
) -> None:
"""Write login failure to audit log."""
origin: str | None = None
if request.client:
origin = request.client.host
await admin.write_audit_message(
operation=Operation.DENY,
message="Login failed",
origin=origin or "UNKNOWN",
provider_error_message=error_message,
)
def create_router(dependencies: FrontendDependencies) -> APIRouter:
"""Create auth router."""
app = APIRouter()
def get_oidc_client() -> AdminOidc:
"""Get OIDC client dependency."""
if not dependencies.settings.oidc:
raise RuntimeError("OIDC authentication not configured.")
oidc = AdminOidc(dependencies.settings.oidc)
return oidc
@app.get("/oidc/login")
async def oidc_login(
request: Request, oidc: Annotated[AdminOidc, Depends(get_oidc_client)]
) -> RedirectResponse:
"""Redirect to oidc login."""
redirect_url = request.url_for("oidc_auth")
return await oidc.start_auth(request, redirect_url)
@app.get("/oidc/auth")
async def oidc_auth(
request: Request,
session: Annotated[AsyncSession, Depends(dependencies.get_async_session)],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
oidc: Annotated[AdminOidc, Depends(get_oidc_client)],
):
"""Handle OIDC auth callback."""
try:
claims = await oidc.handle_auth_callback(request)
except AuthenticationFailedError as error:
raise RedirectException(
to=URL("/login").include_query_params(
error_title="Login error from external provider",
error_message=str(error),
)
)
except ValidationError as error:
LOG.error("Validation error: %s", error, exc_info=True)
raise RedirectException(
to=URL("/login").include_query_params(
error_title="Error parsing claim",
error_message="One or more required parameters were not included in the claim.",
)
)
# We now have a IdentityClaims object.
# We need to check if this matches an existing user, or we need to create a new one.
user = await handle_oidc_claim(session, claims)
user.last_login = datetime.now()
session.add(user)
await session.commit()
# Set cookies
token_data: dict[str, str] = {"sub": claims.sub}
access_token = create_access_token(
dependencies.settings, data=token_data, provider=claims.provider
)
refresh_token = create_refresh_token(
dependencies.settings, data=token_data, provider=claims.provider
)
user_info = generate_user_info(user)
response = HTMLResponse("""
<html>
<body>
<p>Login successful. Redirecting...</p>
<script>
setTimeout(() => { window.location.href = "/dashboard"; }, 500);
</script>
</body>
</html>
""")
response.set_cookie(
"access_token",
value=access_token,
httponly=True,
secure=False,
samesite="strict",
)
response.set_cookie(
"refresh_token",
value=refresh_token,
httponly=True,
secure=False,
samesite="strict",
)
origin = "UNKNOWN"
if request.client:
origin = request.client.host
await admin.write_audit_message(
operation=Operation.LOGIN,
message="Logged in to admin frontend",
origin=origin,
username=user_info.display_name,
oidc=claims.provider,
)
return response
return app

View File

@ -1,571 +0,0 @@
"""Secrets views."""
# pyright: reportUnusedFunction=false
import os
import logging
import secrets as pysecrets
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from pydantic import BaseModel, BeforeValidator, Field
from sshecret_admin.auth import LocalUserInfo
from sshecret_admin.services import AdminBackend
from sshecret_admin.services.models import SecretGroupCreate
from sshecret.backend.models import Operation
from ..dependencies import FrontendDependencies
LOG = logging.getLogger(__name__)
def split_clients(clients: Any) -> Any: # pyright: ignore[reportAny]
"""Split clients."""
if isinstance(clients, list):
return clients # pyright: ignore[reportUnknownVariableType]
if not isinstance(clients, str):
raise ValueError("Invalid type for clients.")
if not clients:
return []
return [client.rstrip() for client in clients.split(",")]
def handle_select_bool(value: Any) -> Any: # pyright: ignore[reportAny]
"""Handle boolean from select."""
if isinstance(value, bool):
return value
if value == "on":
return True
if value == "off":
return False
class CreateSecret(BaseModel):
"""Create secret model."""
name: str
value: str | None = None
auto_generate: Annotated[bool, BeforeValidator(handle_select_bool)] = False
clients: Annotated[list[str], BeforeValidator(split_clients)] = Field(
default_factory=list
)
def create_router(dependencies: FrontendDependencies) -> APIRouter:
"""Create secrets router."""
app = APIRouter(dependencies=[Depends(dependencies.require_login)])
templates = dependencies.templates
@app.get("/secrets/")
async def get_secrets_tree(
request: Request,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
):
breadcrumbs = [("secrets", "/secrets/")]
groups = await admin.get_secret_groups()
return templates.TemplateResponse(
request,
"secrets/index.html.j2",
{
"page_title": "Secrets",
"groups": groups,
"breadcrumbs": breadcrumbs,
"user": current_user,
"selected_group": None,
"group_path_nodes": ["/"],
},
)
@app.get("/secrets/group/")
async def show_root_group(
request: Request,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
):
"""Show the root path."""
clients = await admin.get_clients()
breadcrumbs = [
("secrets", "/secrets/"),
("groups", "/secrets/groups/"),
("Ungrouped", "/secrets/groups/"),
]
context: dict[str, Any] = {
"clients": clients,
"breadcrumbs": breadcrumbs,
"root_group_page": True,
"mobile_show_details": True,
}
headers: dict[str, str] = {}
if request.headers.get("HX-Request"):
# This is a HTMX request.
template_name = "secrets/partials/edit_root.html.j2"
headers["HX-Push-Url"] = request.url.path
else:
groups = await admin.get_secret_groups()
template_name = "secrets/index.html.j2"
context["page_title"] = "Secrets"
context["user"] = current_user
context["groups"] = groups
context["group_path_nodes"] = ["/"]
context["selected_group"] = "/"
return templates.TemplateResponse(
request, template_name, context, headers=headers
)
@app.get("/secrets/group/{group_path:path}")
async def show_group(
request: Request,
group_path: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
):
"""Show a group."""
group = await admin.get_secret_group_by_path(group_path)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Group not found"
)
clients = await admin.get_clients()
breadcrumbs = [("secrets", "/secrets/"), ("groups", "/secrets/groups/")]
path_nodes = group.path.split("/")
for x in range(len(path_nodes)):
next_node = x + 1
group_path = "/".join(path_nodes[:next_node])
crumb_path = os.path.join("/secrets", group_path)
breadcrumbs.append((path_nodes[x], crumb_path))
headers: dict[str, str] = {}
context: dict[str, Any] = {
"group_page": True,
"group": group,
"clients": clients,
"breadcrumbs": breadcrumbs,
"mobile_show_details": True,
}
if request.headers.get("HX-Request"):
# This is a HTMX request.
template_name = "secrets/partials/group_detail.html.j2"
headers["HX-Push-Url"] = request.url.path
else:
template_name = "secrets/index.html.j2"
groups = await admin.get_secret_groups()
context["page_title"] = "Secrets"
context["user"] = current_user
context["groups"] = groups
context["group_path_nodes"] = group.path.split("/")
context["selected_group"] = group.path
return templates.TemplateResponse(
request, template_name, context, headers=headers
)
@app.get("/secrets/secret/{name}")
async def get_secret_tree_detail(
request: Request,
name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
):
"""Get secret detail."""
secret = await admin.get_secret(name)
groups = await admin.get_secret_groups()
flat_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"
)
context: dict[str, Any] = {
"secret": secret,
"groups": groups,
"flat_groups": flat_groups,
"events": events,
"secret_page": True,
"mobile_show_details": True,
}
headers: dict[str, str] = {}
if request.headers.get("HX-Request"):
# This is a HTMX request.
template_name = "secrets/partials/tree_detail.html.j2"
headers["HX-Push-Url"] = request.url.path
else:
group_path = ["/"]
if secret.group:
group = await admin.get_secret_group(secret.group)
if group:
group_path = group.path.split("/")
template_name = "secrets/index.html.j2"
context["page_title"] = "Secrets"
context["user"] = current_user
context["groups"] = groups
context["group_path_nodes"] = group_path
context["selected_group"] = None
return templates.TemplateResponse(
request, template_name, context, headers=headers
)
@app.delete("/secrets/group/{name}")
async def delete_secret_group(
request: Request,
name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
):
"""Delete a secret group."""
group = await admin.get_secret_group(name)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Group not found"
)
await admin.delete_secret_group(name)
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(
request,
"secrets/partials/redirect.html.j2",
{"destination": new_path},
headers=headers,
)
@app.post("/secrets/group/")
async def create_group(
request: Request,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
group: Annotated[SecretGroupCreate, Form()],
):
"""Create group."""
LOG.info("Creating secret group: %r", group)
await admin.add_secret_group(
group_name=group.name,
description=group.description,
parent_group=group.parent_group,
)
headers = {"Hx-Refresh": "true"}
return templates.TemplateResponse(
request,
"secrets/partials/default_detail.html.j2",
headers=headers,
)
@app.put("/secrets/set-group/{name}")
async def set_secret_group(
request: Request,
name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
group_name: Annotated[str, Form()],
):
"""Move a secret to a group."""
secret = await admin.get_secret(name)
if not secret:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found"
)
if group_name == "__ROOT":
await admin.set_secret_group(name, None)
else:
group = await admin.get_secret_group(group_name)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Group not found"
)
await admin.set_secret_group(name, group_name)
groups = await admin.get_secret_groups()
events = await admin.get_audit_log_detailed(limit=10, secret_name=secret.name)
secret = await admin.get_secret(name)
headers = {"Hx-Refresh": "true"}
return templates.TemplateResponse(
request,
"secrets/partials/tree_detail.html.j2",
{
"secret": secret,
"groups": groups,
"events": events,
},
headers=headers,
)
@app.put("/secrets/partial/group/{name}/description")
async def update_group_description(
request: Request,
name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
description: Annotated[str, Form()],
):
"""Update group description."""
group = await admin.get_secret_group(name)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Group not found"
)
await admin.set_group_description(group_name=name, description=description)
clients = await admin.get_clients()
headers = {"Hx-Refresh": "true"}
return templates.TemplateResponse(
request,
"secrets/partials/group_detail.html.j2",
{
"group": group,
"clients": clients,
},
headers=headers,
)
@app.put("/secrets/partial/secret/{name}/value")
async def update_secret_value_inline(
request: Request,
name: str,
secret_value: Annotated[str, Form()],
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
):
"""Update secret value."""
secret = await admin.get_secret(name)
if not secret:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found"
)
origin = "UNKNOWN"
if request.client:
origin = request.client.host
await admin.write_audit_message(
operation=Operation.UPDATE,
message="Secret was updated via admin interface",
secret_name=name,
origin=origin,
username=current_user.display_name,
)
await admin.update_secret(name, secret_value)
secret = await admin.get_secret(name)
return templates.TemplateResponse(
request,
"secrets/partials/secret_value.html.j2",
{
"secret": secret,
"updated": True,
},
)
@app.get("/secrets/partial/{name}/viewsecret")
async def view_secret_in_tree(
request: Request,
name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
):
"""View secret inline partial."""
secret = await admin.get_secret(name)
if not secret:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found"
)
origin = "UNKNOWN"
if request.client:
origin = request.client.host
await admin.write_audit_message(
operation=Operation.READ,
message="Secret viewed",
secret_name=name,
origin=origin,
username=current_user.display_name,
)
return templates.TemplateResponse(
request,
"secrets/partials/secret_value.html.j2",
{
"secret": secret,
"updated": False,
},
)
@app.post("/secrets/create/group/{name}")
async def add_secret_in_group(
request: Request,
name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
secret: Annotated[CreateSecret, Form()],
):
"""Create secret in group."""
if secret.value:
value = secret.value
else:
value = pysecrets.token_urlsafe(32)
await admin.add_secret(secret.name, value, secret.clients, group=name)
new_path = f"/secrets/secret/{secret.name}"
headers = {"Hx-Redirect": new_path}
return templates.TemplateResponse(
request,
"secrets/partials/redirect.html.j2",
{"destination": new_path},
headers=headers,
)
@app.post("/secrets/create/root")
async def add_secret_in_root(
request: Request,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
secret: Annotated[CreateSecret, Form()],
):
"""Create secret in the root."""
LOG.info("secret: %s", secret.model_dump_json(indent=2))
if secret.value:
value = secret.value
else:
value = pysecrets.token_urlsafe(32)
await admin.add_secret(secret.name, value, secret.clients, group=None)
new_path = f"/secrets/secret/{secret.name}"
headers = {"Hx-Redirect": new_path}
return templates.TemplateResponse(
request,
"secrets/partials/redirect.html.j2",
{
"destination": new_path,
},
headers=headers,
)
@app.delete("/secrets/{name}/clients/{client_name}")
async def remove_client_secret_access(
request: Request,
name: str,
client_name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
):
"""Remove a client's access to a secret."""
client = await admin.get_client(client_name)
if not client:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Client not found."
)
await admin.delete_client_secret(str(client.id), name)
clients = await admin.get_clients()
secret = await admin.get_secret(name)
if not secret:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found."
)
return templates.TemplateResponse(
request,
"secrets/partials/client_list_inner.html.j2",
{"clients": clients, "secret": secret},
)
@app.get("/secrets/{name}/clients/")
async def show_secret_client_add(
request: Request,
name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
):
"""Show partial to add new client to a secret."""
clients = await admin.get_clients()
secret = await admin.get_secret(name)
if not secret:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found."
)
return templates.TemplateResponse(
request,
"secrets/partials/client_assign.html.j2",
{
"clients": clients,
"secret": secret,
},
)
@app.post("/secrets/{name}/clients/")
async def add_secret_to_client(
request: Request,
name: str,
client: Annotated[str, Form()],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
):
"""Add a secret to a client."""
await admin.create_client_secret(("id", client), name)
secret = await admin.get_secret(name)
if not secret:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found."
)
clients = await admin.get_clients()
return templates.TemplateResponse(
request,
"secrets/partials/client_secret_details.html.j2",
{
"secret": secret,
"clients": clients,
},
)
@app.delete("/secrets/{name}")
async def delete_secret(
request: Request,
name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
):
"""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)
headers = {"Hx-Redirect": new_path}
# headers["HX-Push-Url"] = request.url.path
return templates.TemplateResponse(
request,
"secrets/partials/redirect.html.j2",
{"destination": new_path},
headers=headers,
)
return app