Check in admin page in working state

This commit is contained in:
2025-04-30 08:22:29 +02:00
parent 19a7e2a91b
commit 76ef97d9c4
70 changed files with 8058 additions and 689 deletions

View File

@ -13,12 +13,22 @@ dependencies = [
"cryptography>=44.0.2",
"fastapi[standard]>=0.115.12",
"httpx>=0.28.1",
"jinja2>=3.1.6",
"jinja2-fragments>=1.9.0",
"pydantic>=2.10.6",
"pyjwt>=2.10.1",
"pykeepass>=4.1.1.post1",
"sqlmodel>=0.0.24",
]
[project.scripts]
sshecret-admin = "sshecret_admin.cli:cli"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[dependency-groups]
dev = [
"pytailwindcss>=0.2.0",
]

View File

@ -1,5 +1 @@
"""Sshecret Admin API."""
from .app import app
__all__ = ["app"]

View File

@ -1,36 +1,40 @@
"""Admin API."""
# pyright: reportUnusedFunction=false
import logging
from collections import defaultdict
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from typing import Annotated, Any
from datetime import timedelta
from typing import Annotated
import bcrypt
import jwt
from fastapi import APIRouter, Depends, FastAPI, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel
from sqlmodel import SQLModel
from sqlmodel import Session, select
from sshecret.backend import Client, SshecretBackend
from sshecret.backend.models import Secret
from . import keepass
from .auth_models import User, PasswordDB, get_engine
from .backend import BackendClient, Client
from .master_password import setup_master_password
from .settings import ServerSettings
from .admin_backend import AdminBackend
from .auth_models import (
PasswordDB,
Token,
TokenData,
User,
create_access_token,
verify_password,
)
from .settings import AdminServerSettings
from .types import DBSessionDep
from .view_models import (
ClientCreate,
SecretListView,
SecretCreate,
SecretUpdate,
SecretView,
UpdateKeyModel,
UpdateKeyResponse,
UpdatePoliciesRequest,
)
LOG = logging.getLogger(__name__)
@ -39,64 +43,6 @@ JWT_ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
class TokenData(BaseModel):
"""Token data."""
username: str | None = None
class Token(BaseModel):
access_token: str
token_type: str
settings = ServerSettings()
engine = get_engine(settings.admin_db)
def init_db() -> None:
"""Create database."""
SQLModel.metadata.create_all(engine)
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
async def get_session():
"""Get the session."""
with Session(engine) as session:
yield session
def setup_password_manager() -> None:
"""Setup password manager."""
encr_master_password = setup_master_password(regenerate=False)
if not encr_master_password:
return
with Session(engine) as session:
pwdb = PasswordDB(id=1, encrypted_password=encr_master_password)
session.add(pwdb)
session.commit()
async def get_password_manager(session: Annotated[Session, Depends(get_session)]):
"""Get password manager."""
password_db = session.exec(select(PasswordDB).where(PasswordDB.id == 1)).first()
if not password_db:
raise HTTPException(
500, detail="Error: The password manager has not yet been set up."
)
with keepass.load_password_manager(password_db.encrypted_password) as kp:
yield kp
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify password against stored hash."""
return bcrypt.checkpw(plain_password.encode(), hashed_password.encode())
def authenticate_user(session: Session, username: str, password: str) -> User | None:
"""Authenticate user."""
user = session.exec(select(User).where(User.username == username)).first()
@ -107,10 +53,38 @@ def authenticate_user(session: Session, username: str, password: str) -> User |
return user
async def get_current_user(
async def map_secrets_to_clients(
backend: SshecretBackend,
) -> defaultdict[str, list[str]]:
"""Map secrets to clients."""
clients = await backend.get_clients()
client_secret_map: defaultdict[str, list[str]] = defaultdict(list)
for client in clients:
for secret in client.secrets:
client_secret_map[secret].append(client.name)
return client_secret_map
def get_admin_api(
get_db_session: DBSessionDep, settings: AdminServerSettings
) -> APIRouter:
"""Get Admin API."""
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
async def get_admin_backend(session: Annotated[Session, Depends(get_db_session)]):
"""Get admin backend API."""
password_db = session.exec(select(PasswordDB).where(PasswordDB.id == 1)).first()
if not password_db:
raise HTTPException(
500, detail="Error: The password manager has not yet been set up."
)
admin = AdminBackend(settings, password_db.encrypted_password)
yield admin
async def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)],
session: Annotated[Session, Depends(get_session)],
) -> User:
session: Annotated[Session, Depends(get_db_session)],
) -> User:
"""Get current user from token."""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@ -133,64 +107,23 @@ async def get_current_user(
raise credentials_exception
return user
async def get_current_active_user(
current_user: Annotated[User, Depends(get_current_user)]
) -> User:
async def get_current_active_user(
current_user: Annotated[User, Depends(get_current_user)],
) -> User:
"""Get current active user."""
if current_user.disabled:
raise HTTPException(status_code=400, detail="Inactive or disabled user")
return current_user
app = APIRouter(
prefix=f"/api/{API_VERSION}", dependencies=[Depends(get_current_active_user)]
)
def create_access_token(
data: dict[str, Any], expires_delta: timedelta | None = None
) -> str:
"""Create access token."""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=JWT_ALGORITHM)
return encoded_jwt
async def get_backend():
"""Get backend client."""
backend_client = BackendClient(settings)
yield backend_client
async def map_secrets_to_clients(backend: BackendClient) -> defaultdict[str, list[str]]:
"""Map secrets to clients."""
clients = await backend.get_clients()
client_secret_map: defaultdict[str, list[str]] = defaultdict(list)
for client in clients:
for secret in client.secrets:
client_secret_map[secret].append(client.name)
return client_secret_map
@asynccontextmanager
async def lifespan(_app: FastAPI):
"""Create lifespan context for the app."""
init_db()
setup_password_manager()
yield
api = APIRouter(
prefix=f"/api/{API_VERSION}",
lifespan=lifespan,
)
@api.post("/token")
async def login_for_access_token(
session: Annotated[Session, Depends(get_session)],
@app.post("/token")
async def login_for_access_token(
session: Annotated[Session, Depends(get_db_session)],
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
) -> Token:
) -> Token:
"""Login user and generate token."""
user = authenticate_user(session, form_data.username, form_data.password)
if not user:
@ -201,184 +134,49 @@ async def login_for_access_token(
)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
settings,
data={"sub": user.username},
expires_delta=access_token_expires,
)
return Token(access_token=access_token, token_type="bearer")
@api.get("/secrets/")
async def get_secret_names(
current_user: Annotated[User, Depends(get_current_active_user)],
backend: Annotated[BackendClient, Depends(get_backend)],
password_manager: Annotated[keepass.PasswordContext, Depends(get_password_manager)],
) -> list[SecretListView]:
"""Get Secret Names."""
# We get the list of clients first, so we can resolve access.
LOG.info("User %s requested get_secret_names", current_user.username)
client_secret_map = await map_secrets_to_clients(backend)
secrets = password_manager.get_available_secrets()
results: list[SecretListView] = []
for secret in secrets:
client_list = client_secret_map.get(secret, [])
results.append(SecretListView(name=secret, clients=client_list))
return results
@api.get("/secrets/{name}")
async def get_secret(
name: str,
current_user: Annotated[User, Depends(get_current_active_user)],
backend: Annotated[BackendClient, Depends(get_backend)],
password_manager: Annotated[keepass.PasswordContext, Depends(get_password_manager)],
) -> SecretView:
"""Get a secret."""
LOG.info("User %s viewed secret %s", current_user.username, name)
client_secret_map = await map_secrets_to_clients(backend)
secret = password_manager.get_secret(name)
if not secret:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found."
)
clients = client_secret_map[secret]
return SecretView(name=name, secret=secret, clients=clients)
@api.get("/clients/")
async def get_clients(
current_user: Annotated[User, Depends(get_current_active_user)],
backend: Annotated[BackendClient, Depends(get_backend)],
) -> list[Client]:
@app.get("/clients/")
async def get_clients(
admin: Annotated[AdminBackend, Depends(get_admin_backend)]
) -> list[Client]:
"""Get clients."""
LOG.info("User %s requested get_clients", current_user.username)
clients = await backend.get_clients()
clients = await admin.get_clients()
return clients
@api.post("/clients/")
async def create_client(
@app.post("/clients/")
async def create_client(
new_client: ClientCreate,
current_user: Annotated[User, Depends(get_current_active_user)],
backend: Annotated[BackendClient, Depends(get_backend)],
) -> Client:
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
) -> Client:
"""Create a new client."""
LOG.info(
"User %s requested create_client %", current_user.username, new_client.name
)
await backend.register_client(new_client.name, new_client.public_key)
sources: list[str] | None = None
if new_client.sources:
LOG.debug("Creating policy sources")
sources = [str(source) for source in new_client.sources]
await backend.update_client_sources(new_client.name, sources)
client = await backend.get_client(new_client.name)
if not client:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="The client could not be created",
client = await admin.create_client(
new_client.name, new_client.public_key, sources
)
return client
@api.delete("/clients/{name}")
async def delete_client(
name: str,
current_user: Annotated[User, Depends(get_current_active_user)],
backend: Annotated[BackendClient, Depends(get_backend)],
) -> None:
@app.delete("/clients/{name}")
async def delete_client(
name: str, admin: Annotated[AdminBackend, Depends(get_admin_backend)]
) -> None:
"""Delete a client."""
LOG.info("User %s requested delete_client %s", current_user.username, name)
await backend.delete_client(name)
await admin.delete_client(name)
@api.put("/clients/{name}/public-key")
async def update_client_public_key(
name: str,
updated: UpdateKeyModel,
current_user: Annotated[User, Depends(get_current_active_user)],
backend: Annotated[BackendClient, Depends(get_backend)],
password_manager: Annotated[keepass.PasswordContext, Depends(get_password_manager)],
) -> UpdateKeyResponse:
"""Update client public key.
Updating the public key will invalidate the current secrets, so these well
be resolved first, and re-encrypted using the new key.
"""
LOG.info(
"User %s requested public key update for client %s", current_user.username, name
)
# Let's first ensure that the key is actually updated.
client = await backend.get_client(name)
if not client:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found."
)
if client.public_key == updated.public_key:
return UpdateKeyResponse(
public_key=updated.public_key,
detail="Updated key identical to existing key. No changes were made.",
)
LOG.debug("Updating public key on backend.")
await backend.update_client_key(name, updated.public_key)
client.public_key = updated.public_key
response = UpdateKeyResponse(public_key=updated.public_key)
for secret in client.secrets:
LOG.debug("Re-encrypting secret %s for client %s", secret, name)
secret_value = password_manager.get_secret(secret)
if not secret_value:
LOG.warning("Referenced secret %s does not exist! Skipping.", secret_value)
continue
encrypted = client.encrypt(secret_value)
LOG.debug("Sending new encrypted value to backend.")
await backend.create_secret(name, secret, encrypted)
response.updated_secrets.append(secret)
return response
@api.put("/clients/{name}/secrets/{secret_name}")
async def add_secret_to_client(
@app.delete("/clients/{name}/secrets/{secret_name}")
async def delete_secret_from_client(
name: str,
secret_name: str,
current_user: Annotated[User, Depends(get_current_active_user)],
backend: Annotated[BackendClient, Depends(get_backend)],
password_manager: Annotated[keepass.PasswordContext, Depends(get_password_manager)],
) -> None:
"""Add secret to a client."""
LOG.info(
"User %s requested add_secret_to_client for secret %s",
current_user.username,
secret_name,
)
client = await backend.get_client(name)
if not client:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found"
)
secret = password_manager.get_secret(secret_name)
if not secret:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found"
)
await backend.create_secret(name, secret_name, client.encrypt(secret))
@api.delete("/clients/{name}/secrets/{secret_name}")
async def delete_secret_from_client(
name: str,
secret_name: str,
current_user: Annotated[User, Depends(get_current_active_user)],
backend: Annotated[BackendClient, Depends(get_backend)],
) -> None:
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
) -> None:
"""Delete a secret from a client."""
LOG.info(
"User % requested delete of secret. Client: %s, secret: %s",
current_user.username,
name,
secret_name,
)
client = await backend.get_client(name)
client = await admin.get_client(name)
if not client:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found"
@ -388,19 +186,16 @@ async def delete_secret_from_client(
LOG.debug("Client does not have requested secret. No action to perform.")
return None
await backend.delete_client_secret(name, secret_name)
await admin.delete_client_secret(name, secret_name)
@api.put("/clients/{name}/policies")
async def update_client_policies(
@app.put("/clients/{name}/policies")
async def update_client_policies(
name: str,
updated: UpdatePoliciesRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
backend: Annotated[BackendClient, Depends(get_backend)],
) -> Client:
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
) -> Client:
"""Update the client access policies."""
LOG.info("User %s requested update_client_policies.", current_user.username)
client = await backend.get_client(name)
client = await admin.get_client(name)
if not client:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found"
@ -409,9 +204,81 @@ async def update_client_policies(
LOG.debug("Old policies: %r. New: %r", client.policies, updated.sources)
addresses: list[str] = [str(source) for source in updated.sources]
await backend.update_client_sources(name, addresses)
client = await backend.get_client(name)
await admin.update_client_sources(name, addresses)
client = await admin.get_client(name)
assert client is not None, "Critical: The client disappeared after update!"
return client
@app.get("/secrets/")
async def get_secret_names(
admin: Annotated[AdminBackend, Depends(get_admin_backend)]
) -> list[Secret]:
"""Get Secret Names."""
return await admin.get_secrets()
@app.post("/secrets/")
async def add_secret(
secret: SecretCreate, admin: Annotated[AdminBackend, Depends(get_admin_backend)]
) -> None:
"""Create a secret."""
await admin.add_secret(secret.name, secret.get_secret(), secret.clients)
@app.get("/secrets/{name}")
async def get_secret(
name: str, admin: Annotated[AdminBackend, Depends(get_admin_backend)]
) -> SecretView:
"""Get a secret."""
secret_view = await admin.get_secret(name)
if not secret_view:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found."
)
return secret_view
@app.put("/secrets/{name}")
async def update_secret(
name: str,
value: SecretUpdate,
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
) -> None:
new_value = value.get_secret()
await admin.update_secret(name, new_value)
@app.delete("/secrets/{name}")
async def delete_secret(
name: str,
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
) -> None:
"""Delete secret."""
await admin.delete_secret(name)
@app.put("/clients/{name}/public-key")
async def update_client_public_key(
name: str,
updated: UpdateKeyModel,
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
) -> UpdateKeyResponse:
"""Update client public key.
Updating the public key will invalidate the current secrets, so these well
be resolved first, and re-encrypted using the new key.
"""
# Let's first ensure that the key is actually updated.
updated_secrets = await admin.update_client_public_key(name, updated.public_key)
return UpdateKeyResponse(
public_key=updated.public_key, updated_secrets=updated_secrets
)
@app.put("/clients/{name}/secrets/{secret_name}")
async def add_secret_to_client(
name: str,
secret_name: str,
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
) -> None:
"""Add secret to a client."""
await admin.create_client_secret(name, secret_name)
return app

View File

@ -0,0 +1,402 @@
"""API for working with the clients.
Since we have a frontend and a REST API, it makes sense to have a generic library to work with the clients.
"""
import logging
from collections.abc import Iterator
from contextlib import contextmanager
from sshecret.backend import AuditLog, Client, ClientFilter, Secret, SshecretBackend
from sshecret.backend.models import DetailedSecrets
from sshecret.crypto import encrypt_string, load_public_key
from .keepass import PasswordContext, load_password_manager
from .settings import AdminServerSettings
from .view_models import SecretView
class ClientManagementError(Exception):
"""Base exception for client management operations."""
class ClientNotFoundError(ClientManagementError):
"""Client not found."""
class SecretNotFoundError(ClientManagementError):
"""Secret not found."""
class BackendUnavailableError(ClientManagementError):
"""Backend unavailable."""
LOG = logging.getLogger(__name__)
class AdminBackend:
"""Admin backend API."""
def __init__(self, settings: AdminServerSettings, keepass_password: str) -> None:
"""Create client management API."""
self.settings: AdminServerSettings = settings
self.backend: SshecretBackend = SshecretBackend(
str(settings.backend_url), settings.backend_token
)
self.keepass_password: str = keepass_password
@contextmanager
def password_manager(self) -> Iterator[PasswordContext]:
"""Open the password manager."""
with load_password_manager(self.settings, self.keepass_password) as kp:
yield kp
async def _get_clients(self, filter: ClientFilter | None = None) -> list[Client]:
"""Get clients from backend."""
return await self.backend.get_clients(filter)
async def get_clients(self, filter: ClientFilter | None = None) -> list[Client]:
"""Get clients from backend."""
try:
return await self._get_clients(filter)
except ClientManagementError:
raise
except Exception as e:
raise BackendUnavailableError() from e
async def _get_client(self, name: str) -> Client | None:
"""Get a client from the backend."""
return await self.backend.get_client(name)
async def _verify_client_exists(self, name: str) -> None:
"""Check that a client exists."""
client = await self.backend.get_client(name)
if not client:
raise ClientNotFoundError()
return None
async def verify_client_exists(self, name: str) -> None:
"""Check that a client exists."""
try:
await self._verify_client_exists(name)
except ClientManagementError:
raise
except Exception as e:
raise BackendUnavailableError() from e
async def get_client(self, name: str) -> Client | None:
"""Get a client from the backend."""
try:
return await self._get_client(name)
except ClientManagementError:
raise
except Exception as e:
raise BackendUnavailableError() from e
async def _create_client(
self,
name: str,
public_key: str,
description: str | None = None,
sources: list[str] | None = None,
) -> Client:
"""Create client."""
await self.backend.create_client(name, public_key, description)
if sources:
await self.backend.update_client_sources(name, sources)
client = await self.get_client(name)
if not client:
raise ClientNotFoundError()
return client
async def create_client(
self,
name: str,
public_key: str,
description: str | None = None,
sources: list[str] | None = None,
) -> Client:
"""Create client."""
try:
return await self._create_client(name, public_key, description, sources)
except ClientManagementError:
raise
except Exception as e:
raise BackendUnavailableError() from e
async def _update_client_public_key(
self, name: str, new_key: str, password_manager: PasswordContext
) -> list[str]:
"""Update client public key."""
LOG.info(
"Updating client %s public key. This will invalidate all existing secrets."
)
client = await self.get_client(name)
if not client:
raise ClientNotFoundError()
await self.backend.update_client_key(name, new_key)
updated_secrets: list[str] = []
for secret in client.secrets:
LOG.debug("Re-encrypting secret %s for client %s", secret, name)
secret_value = password_manager.get_secret(secret)
if not secret_value:
LOG.warning(
"Referenced secret %s does not exist! Skipping.", secret_value
)
continue
rsa_public_key = load_public_key(client.public_key.encode())
encrypted = encrypt_string(secret_value, rsa_public_key)
LOG.debug("Sending new encrypted value to backend.")
await self.backend.create_client_secret(name, secret, encrypted)
updated_secrets.append(secret)
return updated_secrets
async def update_client_public_key(self, name: str, new_key: str) -> list[str]:
"""Update client public key."""
try:
with self.password_manager() as password_manager:
return await self._update_client_public_key(
name, new_key, password_manager
)
except ClientManagementError:
raise
except Exception as e:
raise BackendUnavailableError() from e
async def _update_client(self, new_client: Client) -> Client:
"""Update a client object."""
existing_client = await self.get_client(new_client.name)
if not existing_client:
raise ClientNotFoundError()
await self.backend.update_client(new_client)
if new_client.public_key != existing_client.public_key:
await self.update_client_public_key(new_client.name, new_client.public_key)
updated_client = await self.get_client(new_client.name)
if not updated_client:
raise ClientNotFoundError()
return updated_client
async def update_client(self, new_client: Client) -> Client:
"""Update a client object."""
try:
return await self._update_client(new_client)
except ClientManagementError:
raise
except Exception as e:
raise BackendUnavailableError() from e
async def update_client_sources(self, name: str, sources: list[str]) -> None:
"""Update client sources."""
try:
await self.backend.update_client_sources(name, sources)
except Exception as e:
raise BackendUnavailableError() from e
async def _delete_client(self, name: str) -> None:
"""Delete client."""
await self.backend.delete_client(name)
async def delete_client(self, name: str) -> None:
"""Delete client."""
try:
await self._delete_client(name)
except ClientManagementError:
raise
except Exception as e:
raise BackendUnavailableError() from e
async def delete_client_secret(self, client_name: str, secret_name: str) -> None:
"""Delete a secret from a client."""
try:
await self.backend.delete_client_secret(client_name, secret_name)
except Exception as e:
raise BackendUnavailableError() from e
async def _get_secrets(self) -> list[Secret]:
"""Get secrets.
This fetches the secret to client mapping from backend, and adds secrets from the password manager.
"""
with self.password_manager() as password_manager:
all_secrets = password_manager.get_available_secrets()
secrets = await self.backend.get_secrets()
backend_secret_names = [secret.name for secret in secrets]
for secret in all_secrets:
if secret not in backend_secret_names:
secrets.append(Secret(name=secret, clients=[]))
return secrets
async def get_secrets(self) -> list[Secret]:
"""Get secrets from backend."""
try:
return await self._get_secrets()
except ClientManagementError:
raise
except Exception as e:
raise BackendUnavailableError() from e
async def _get_detailed_secrets(self) -> list[DetailedSecrets]:
"""Get detailed secrets.
This fetches the secret to client mapping from backend, and adds secrets from the password manager.
"""
with self.password_manager() as password_manager:
all_secrets = password_manager.get_available_secrets()
secrets = await self.backend.get_detailed_secrets()
backend_secret_names = [secret.name for secret in secrets]
for secret in all_secrets:
if secret not in backend_secret_names:
secrets.append(DetailedSecrets(name=secret, ids=[], clients=[]))
return secrets
async def get_detailed_secrets(self) -> list[DetailedSecrets]:
"""Get detailed secrets from backend."""
try:
return await self._get_detailed_secrets()
except ClientManagementError:
raise
except Exception as e:
raise BackendUnavailableError() from e
async def get_secret(self, name: str) -> SecretView | None:
"""Get secrets from backend."""
try:
return await self._get_secret(name)
except ClientManagementError:
raise
except Exception as e:
raise BackendUnavailableError() from e
async def _get_secret(self, name: str) -> SecretView | None:
"""Get a secret, including the actual unencrypted value and clients."""
with self.password_manager() as password_manager:
secret = password_manager.get_secret(name)
if not secret:
return None
secret_view = SecretView(name=name, secret=secret)
secret_mapping = await self.backend.get_secret(name)
if secret_mapping:
secret_view.clients = secret_mapping.clients
return secret_view
async def delete_secret(self, name: str) -> None:
"""Delete a secret."""
try:
return await self._delete_secret(name)
except ClientManagementError:
raise
except Exception as e:
raise BackendUnavailableError() from e
async def _delete_secret(self, name: str) -> None:
"""Delete a secret."""
with self.password_manager() as password_manager:
password_manager.delete_entry(name)
secret_mapping = await self.backend.get_secret(name)
if not secret_mapping:
return
for client in secret_mapping.clients:
LOG.info("Deleting secret %s from client %s", name, client)
await self.backend.delete_client_secret(client, name)
async def _add_secret(
self, name: str, value: str, clients: list[str] | None, update: bool = False
) -> None:
"""Add a secret."""
with self.password_manager() as password_manager:
password_manager.add_entry(name, value, update)
if update:
secret_map = await self.backend.get_secret(name)
if secret_map:
clients = secret_map.clients
if not clients:
return
for client_name in clients:
client = await self.get_client(client_name)
if not client:
if update:
raise ClientNotFoundError()
LOG.warning("Requested client %s not found!", client_name)
continue
public_key = load_public_key(client.public_key.encode())
encrypted = encrypt_string(value, public_key)
LOG.info("Wrote encrypted secret for client %s", client_name)
await self.backend.create_client_secret(client_name, name, encrypted)
async def add_secret(
self, name: str, value: str, clients: list[str] | None = None
) -> None:
"""Add a secret."""
try:
await self._add_secret(name, value, clients)
except ClientManagementError:
raise
except Exception as e:
raise BackendUnavailableError() from e
async def update_secret(self, name: str, value: str) -> None:
"""Update secrets."""
try:
await self._add_secret(name, value, None, True)
except ClientManagementError:
raise
except Exception as e:
raise BackendUnavailableError() from e
async def _create_client_secret(self, client_name: str, secret_name: str) -> None:
"""Create client secret."""
client = await self.get_client(client_name)
if not client:
raise ClientNotFoundError()
with self.password_manager() as password_manager:
secret = password_manager.get_secret(secret_name)
if not secret:
raise SecretNotFoundError()
public_key = load_public_key(client.public_key.encode())
encrypted = encrypt_string(secret, public_key)
await self.backend.create_client_secret(client_name, secret_name, encrypted)
async def create_client_secret(self, client_name: str, secret_name: str) -> None:
"""Create client secret."""
try:
await self._create_client_secret(client_name, secret_name)
except ClientManagementError:
raise
except Exception as e:
raise BackendUnavailableError() from e
async def get_audit_log(
self,
offset: int = 0,
limit: int = 100,
client_name: str | None = None,
subsystem: str | None = None,
) -> list[AuditLog]:
"""Get audit log from backend."""
return await self.backend.get_audit_log(offset, limit, client_name, subsystem)
async def write_audit_log(self, entry: AuditLog) -> None:
"""Write to the audit log."""
if not entry.subsystem:
entry.subsystem = "admin"
await self.backend.add_audit_log(entry)
async def get_audit_log_count(self) -> int:
"""Get audit log count."""
return await self.backend.get_audit_log_count()

View File

@ -1,21 +1,118 @@
"""FastAPI app."""
# pyright: reportUnusedFunction=false
#
import logging
import os
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request, status
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from .admin_api import api
from sqlmodel import Session, select
from .admin_api import get_admin_api
from .auth_models import init_db, PasswordDB, AuthenticationFailedError, AuthenticationNeededError
from .db import setup_database
from .master_password import setup_master_password
from .settings import AdminServerSettings
from .frontend import create_frontend
from .types import DBSessionDep
LOG = logging.getLogger(__name__)
# dir_path = os.path.dirname(os.path.realpath(__file__))
app = FastAPI()
def setup_frontend(
app: FastAPI, settings: AdminServerSettings, get_db_session: DBSessionDep
) -> None:
"""Setup frontend."""
script_path = Path(os.path.dirname(os.path.realpath(__file__)))
static_path = script_path / "static"
app.mount("/static", StaticFiles(directory=static_path), name="static")
frontend = create_frontend(settings, get_db_session)
app.include_router(frontend)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
def create_admin_app(
settings: AdminServerSettings, with_frontend: bool = True
) -> FastAPI:
"""Create admin app."""
engine, get_db_session = setup_database(settings.admin_db)
def setup_password_manager() -> None:
"""Setup password manager."""
encr_master_password = setup_master_password(
settings=settings, regenerate=False
)
with Session(engine) as session:
existing_password = session.exec(select(PasswordDB).where(PasswordDB.id == 1)).first()
if not encr_master_password:
if existing_password:
LOG.info("Master password already defined.")
return
# Looks like we have to regenerate it
LOG.warning("Master password was set, but not saved to the database. Regenerating it.")
encr_master_password = setup_master_password(settings=settings, regenerate=True)
assert encr_master_password is not None
with Session(engine) as session:
pwdb = PasswordDB(id=1, encrypted_password=encr_master_password)
session.add(pwdb)
session.commit()
@asynccontextmanager
async def lifespan(_app: FastAPI):
"""Create database before starting the server."""
init_db(engine)
setup_password_manager()
yield
app = FastAPI(lifespan=lifespan)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request, exc: RequestValidationError
):
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content=jsonable_encoder({"detail": exc.errors(), "body": exc.body}),
)
app.include_router(api)
@app.exception_handler(AuthenticationNeededError)
async def authentication_needed_handler(
request: Request, exc: AuthenticationNeededError,
):
qs = f"error_title={exc.login_error.title}&error_message={exc.login_error.message}"
return RedirectResponse(f"/?{qs}")
@app.exception_handler(AuthenticationFailedError)
async def authentication_failed_handler(
request: Request, exc: AuthenticationNeededError,
):
qs = f"error_title={exc.login_error.title}&error_message={exc.login_error.message}"
return RedirectResponse(f"/?{qs}")
@app.get("/health")
async def get_health() -> JSONResponse:
"""Provide simple health check."""
return JSONResponse(
status_code=status.HTTP_200_OK, content=jsonable_encoder({"status": "LIVE"})
)
admin_api = get_admin_api(get_db_session, settings)
app.include_router(admin_api)
if with_frontend:
setup_frontend(app, settings, get_db_session)
return app

View File

@ -1,11 +1,16 @@
"""Models for authentication."""
from datetime import datetime
from pathlib import Path
from datetime import datetime, timedelta, timezone
import bcrypt
import sqlalchemy as sa
from sqlalchemy.engine import URL
from typing import Any, override
import jwt
from sqlmodel import SQLModel, Field
from sshecret_admin.settings import AdminServerSettings
from sqlmodel import SQLModel, Field, create_engine
JWT_ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
class User(SQLModel, table=True):
@ -22,9 +27,9 @@ class User(SQLModel, table=True):
)
class PasswordDB(SQLModel, table=True):
"""Password database."""
id: int | None = Field(default=None, primary_key=True)
encrypted_password: str
@ -42,9 +47,79 @@ class PasswordDB(SQLModel, table=True):
)
def get_engine(filename: Path, echo: bool = False) -> sa.Engine:
"""Initialize the engine."""
url = URL.create(drivername="sqlite", database=str(filename.absolute()))
engine = create_engine(url, echo=echo)
def init_db(engine: sa.Engine) -> None:
"""Create database."""
SQLModel.metadata.create_all(engine)
return engine
class TokenData(SQLModel):
"""Token data."""
username: str | None = None
class Token(SQLModel):
access_token: str
token_type: str
def create_access_token(
settings: AdminServerSettings,
data: dict[str, Any],
expires_delta: timedelta | None = None,
) -> str:
"""Create access token."""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=JWT_ALGORITHM)
return encoded_jwt
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify password against stored hash."""
return bcrypt.checkpw(plain_password.encode(), hashed_password.encode())
def check_password(plain_password: str, hashed_password: str) -> None:
"""Check password.
If password doesn't match, throw AuthenticationFailedError.
"""
if not verify_password(plain_password, hashed_password):
raise AuthenticationFailedError()
class LoginError(SQLModel):
"""Login Error model."""
title: str
message: str
class AuthenticationFailedError(Exception):
"""Authentication failed."""
@override
def __init__(self, message: str | None = None) -> None:
"""Initialize exception class."""
if not message:
message = "Invalid user or password."
super().__init__(message)
self.login_error: LoginError = LoginError(
title="Authentication Failed", message=message
)
class AuthenticationNeededError(Exception):
"""Authentication needed error."""
@override
def __init__(self, message: str | None = None) -> None:
"""Initialize exception class."""
if not message:
message = "You need to be logged in to continue."
super().__init__(message)
self.login_error: LoginError = LoginError(title="Unauthorized", message=message)

View File

@ -1,156 +0,0 @@
"""Backend API.
TODO:
- Handle exceptions with custom exceptions
- Move to shared library.
"""
import uuid
import urllib.parse
from datetime import datetime
import httpx
from pydantic import BaseModel, IPvAnyAddress, IPvAnyNetwork, TypeAdapter
from .crypto import load_public_key, encrypt_string
from .settings import ServerSettings
class Client(BaseModel):
"""Implementation of the backend class ClientView."""
id: uuid.UUID
name: str
public_key: str
secrets: list[str]
policies: list[IPvAnyNetwork | IPvAnyAddress]
created_at: datetime
updated_at: datetime | None
def encrypt(self, value: str) -> str:
"""Encrypt a string."""
public_key = load_public_key(self.public_key.encode())
return encrypt_string(value, public_key=public_key)
class BackendClient:
"""Backend Client."""
def __init__(self, settings: ServerSettings | None = None) -> None:
"""Initialize backend client."""
if not settings:
settings = ServerSettings() # pyright: ignore[reportCallIssue]
self.settings: ServerSettings = settings
@property
def headers(self) -> dict[str, str]:
"""Get the headers."""
return {"X-Api-Token": self.settings.backend_token}
def _format_url(self, path: str) -> str:
"""Format a URL."""
return urllib.parse.urljoin(str(self.settings.backend_url), path)
async def request(self, path: str) -> httpx.Response:
"""Send a simple GET request."""
url = self._format_url(path)
async with httpx.AsyncClient() as http_client:
response = await http_client.get(url, headers=self.headers)
return response
async def register_client(self, username: str, public_key: str) -> None:
"""Register a new client."""
data = {
"name": username,
"public_key": public_key,
}
path = "api/v1/clients/"
url = self._format_url(path)
async with httpx.AsyncClient() as http_client:
response = await http_client.post(url, headers=self.headers, json=data)
response.raise_for_status()
async def update_client_key(self, client_name: str, public_key: str) -> None:
"""Update the client key."""
path = f"api/v1/clients/{client_name}/public-key"
url = self._format_url(path)
async with httpx.AsyncClient() as http_client:
response = await http_client.post(
url, headers=self.headers, json={"public_key": public_key}
)
response.raise_for_status()
async def create_secret(
self, client_name: str, secret_name: str, encrypted_secret: str
) -> None:
"""Create a secret.
This will overwrite any existing secret with that name.
"""
path = f"api/v1/clients/{client_name}/secrets/{secret_name}"
url = self._format_url(path)
async with httpx.AsyncClient() as http_client:
response = await http_client.put(
url, headers=self.headers, json={"value": encrypted_secret}
)
response.raise_for_status()
async def delete_client_secret(self, client_name: str, secret_name: str) -> None:
"""Delete a secret from a client."""
path = f"api/v1/clients/{client_name}/secrets/{secret_name}"
url = self._format_url(path)
async with httpx.AsyncClient() as http_client:
response = await http_client.delete(url, headers=self.headers)
response.raise_for_status()
async def get_client(self, name: str) -> Client | None:
"""Get a single client."""
path = f"api/v1/clients/{name}"
response = await self.request(path)
if response.status_code == 404:
return None
response.raise_for_status()
client = Client.model_validate(response.json())
return client
async def get_clients(self) -> list[Client]:
"""Get all clients."""
path = "api/v1/clients/"
url = self._format_url(path)
async with httpx.AsyncClient() as http_client:
response = await http_client.get(url, headers=self.headers)
response.raise_for_status()
client_list_adapter = TypeAdapter(list[Client])
return client_list_adapter.validate_python(response.json())
async def update_client_sources(
self, client_name: str, addresses: list[str] | None
) -> None:
"""Update client source addresses.
Pass None to sources to allow from all.
"""
if not addresses:
addresses = []
path = f"api/v1/clients/{client_name}/policies/"
url = self._format_url(path)
async with httpx.AsyncClient() as http_client:
response = await http_client.put(
url, headers=self.headers, json={"sources": addresses}
)
response.raise_for_status()
async def delete_client(self, client_name: str) -> None:
"""Delete a client."""
path = f"api/v1/clients/{client_name}"
url = self._format_url(path)
async with httpx.AsyncClient() as http_client:
response = await http_client.delete(url, headers=self.headers)
response.raise_for_status()

View File

@ -0,0 +1,143 @@
"""Sshecret admin CLI helper."""
import asyncio
import code
from collections.abc import Awaitable
import logging
from typing import Any, cast
import bcrypt
import click
from sshecret_admin.admin_backend import AdminBackend
import uvicorn
from pydantic import ValidationError
from sqlmodel import Session, create_engine, select
from .auth_models import init_db, User, PasswordDB
from .settings import AdminServerSettings
handler = logging.StreamHandler()
formatter = logging.Formatter("%(asctime)s [%(processName)s: %(process)d] [%(threadName)s: %(thread)d] [%(levelname)s] %(name)s: %(message)s")
handler.setFormatter(formatter)
LOG = logging.getLogger()
LOG.addHandler(handler)
LOG.setLevel(logging.INFO)
def hash_password(password: str) -> str:
"""Hash password."""
salt = bcrypt.gensalt()
hashed_password = bcrypt.hashpw(password.encode(), salt)
return hashed_password.decode()
def create_user(session: Session, username: str, password: str) -> None:
"""Create a user."""
hashed_password = hash_password(password)
user = User(username=username, hashed_password=hashed_password)
session.add(user)
session.commit()
@click.group()
@click.option("--debug", is_flag=True)
@click.pass_context
def cli(ctx: click.Context, debug: bool) -> None:
"""Sshecret Admin."""
if debug:
LOG.setLevel(logging.DEBUG)
try:
settings = AdminServerSettings() # pyright: ignore[reportCallIssue]
except ValidationError as e:
raise click.ClickException("Error: One or more required environment options are missing.") from e
ctx.obj = settings
@cli.command("adduser")
@click.argument("username")
@click.password_option()
@click.pass_context
def cli_create_user(ctx: click.Context, username: str, password: str) -> None:
"""Create user."""
settings = cast(AdminServerSettings, ctx.obj)
engine = create_engine(settings.admin_db)
init_db(engine)
with Session(engine) as session:
create_user(session, username, password)
click.echo("User created.")
@cli.command("passwd")
@click.argument("username")
@click.password_option()
@click.pass_context
def cli_change_user_passwd(ctx: click.Context, username: str, password: str) -> None:
"""Change password on user."""
settings = cast(AdminServerSettings, ctx.obj)
engine = create_engine(settings.admin_db)
init_db(engine)
with Session(engine) as session:
user = session.exec(select(User).where(User.username == username)).first()
if not user:
raise click.ClickException(f"Error: No such user, {username}.")
new_passwd_hash = hash_password(password)
user.hashed_password = new_passwd_hash
session.add(user)
session.commit()
click.echo("Password updated.")
@cli.command("deluser")
@click.argument("username")
@click.confirmation_option()
@click.pass_context
def cli_delete_user(ctx: click.Context, username: str) -> None:
"""Remove a user."""
settings = cast(AdminServerSettings, ctx.obj)
engine = create_engine(settings.admin_db)
init_db(engine)
with Session(engine) as session:
user = session.exec(select(User).where(User.username == username)).first()
if not user:
raise click.ClickException(f"Error: No such user, {username}.")
session.delete(user)
session.commit()
click.echo("User deleted.")
@cli.command("run")
@click.option("--host", default="127.0.0.1")
@click.option("--port", default=8822, type=click.INT)
@click.option("--dev", is_flag=True)
@click.option("--workers", type=click.INT)
def cli_run(host: str, port: int, dev: bool, workers: int | None) -> None:
"""Run the server."""
uvicorn.run("sshecret_admin.main:app", host=host, port=port, reload=dev, workers=workers)
@cli.command("repl")
@click.pass_context
def cli_repl(ctx: click.Context) -> None:
"""Run an interactive console."""
settings = cast(AdminServerSettings, ctx.obj)
engine = create_engine(settings.admin_db)
init_db(engine)
with Session(engine) as session:
password_db = session.exec(select(PasswordDB).where(PasswordDB.id == 1)).first()
if not password_db:
raise click.ClickException("Error: Password database has not yet been setup. Start the server to finish setup.")
def run(func: Awaitable[Any]) -> Any:
"""Run an async function."""
loop = asyncio.get_event_loop()
return loop.run_until_complete(func)
admin = AdminBackend(settings, password_db.encrypted_password)
locals = {
"run": run,
"admin": admin,
}
banner = "Sshecret-admin REPL\nAdmin backend API bound to 'admin'. Run async functions with run()"
console = code.InteractiveConsole(locals=locals, local_exit=True)
console.interact(banner=banner, exitmsg="Bye!")

View File

@ -1,2 +0,0 @@
RSA_PUBLIC_EXPONENT = 65537
RSA_KEY_SIZE = 2048

View File

@ -1,125 +0,0 @@
"""Encryption related functions.
Note! Encryption uses the less secure PKCS1v15 padding. This is to allow
decryption via openssl on the command line.
"""
import base64
import logging
from pathlib import Path
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.asymmetric import padding
from . import constants
LOG = logging.getLogger(__name__)
def load_public_key(keybytes: bytes) -> rsa.RSAPublicKey:
public_key = serialization.load_ssh_public_key(keybytes)
if not isinstance(public_key, rsa.RSAPublicKey):
raise RuntimeError("Only RSA keys are supported.")
return public_key
def validate_public_key(key: str) -> bool:
"""Check if key provided in a string is valid."""
valid = False
public_key: rsa.RSAPublicKey | None = None
try:
keybytes = key.encode()
public_key = load_public_key(keybytes)
except Exception as e:
LOG.debug("Validation of public key failed: %s", e, exc_info=True)
else:
valid = True
if not isinstance(public_key, rsa.RSAPublicKey):
valid = False
return valid
def load_private_key(filename: str, password: str | None = None) -> rsa.RSAPrivateKey:
"""Load a private key."""
password_bytes: bytes | None = None
if password:
password_bytes = password.encode()
with open(filename, "rb") as f:
private_key = serialization.load_ssh_private_key(f.read(), password=password_bytes)
if not isinstance(private_key, rsa.RSAPrivateKey):
raise RuntimeError("Only RSA keys are supported.")
return private_key
def encrypt_string(string: str, public_key: rsa.RSAPublicKey) -> str:
"""Encrypt string, end return it base64 encoded."""
message = string.encode()
ciphertext = public_key.encrypt(
message,
padding.PKCS1v15(),
)
return base64.b64encode(ciphertext).decode()
def decode_string(ciphertext: str, private_key: rsa.RSAPrivateKey) -> str:
"""Decode a string. String must be base64 encoded."""
decoded = base64.b64decode(ciphertext)
decrypted = private_key.decrypt(
decoded,
padding.PKCS1v15(),
)
return decrypted.decode()
def generate_private_key() -> rsa.RSAPrivateKey:
"""Generate private RSA key."""
private_key = rsa.generate_private_key(
public_exponent=constants.RSA_PUBLIC_EXPONENT, key_size=constants.RSA_KEY_SIZE
)
return private_key
def generate_pem(private_key: rsa.RSAPrivateKey) -> str:
"""Generate PEM."""
pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.OpenSSH,
encryption_algorithm=serialization.NoEncryption(),
)
return pem.decode()
def create_private_rsa_key(filename: Path, password: str | None = None) -> None:
"""Create an RSA Private key at the given path.
A password may be provided for secure storage.
"""
if filename.exists():
raise RuntimeError("Error: private key file already exists.")
LOG.debug("Generating private RSA key at %s", filename)
private_key = generate_private_key()
encryption_algorithm = serialization.NoEncryption()
if password:
password_bytes = password.encode()
encryption_algorithm = serialization.BestAvailableEncryption(password_bytes)
with open(filename, "wb") as f:
pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.OpenSSH,
encryption_algorithm=encryption_algorithm,
)
lines = f.write(pem)
LOG.debug("Wrote %s lines", lines)
f.flush()
def generate_public_key_string(public_key: rsa.RSAPublicKey) -> str:
"""Generate public key string."""
keybytes = public_key.public_bytes(
encoding=serialization.Encoding.OpenSSH,
format=serialization.PublicFormat.OpenSSH,
)
return keybytes.decode()

View File

@ -0,0 +1,22 @@
"""Database setup."""
from collections.abc import Generator, Callable
from sqlmodel import Session, create_engine
import sqlalchemy as sa
from sqlalchemy.engine import URL
def setup_database(
db_url: URL | str,
) -> tuple[sa.Engine, Callable[[], Generator[Session, None, None]]]:
"""Setup database."""
engine = create_engine(db_url, echo=True)
def get_db_session() -> Generator[Session, None, None]:
"""Get DB Session."""
with Session(engine) as session:
yield session
return engine, get_db_session

View File

@ -0,0 +1,240 @@
"""Frontend methods."""
# pyright: reportUnusedFunction=false
import logging
import os
from datetime import timedelta
from pathlib import Path
from typing import Annotated
import jwt
from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from jinja2_fragments.fastapi import Jinja2Blocks
from sqlmodel import Session, select
from sshecret_admin.settings import AdminServerSettings
from sshecret.backend import SshecretBackend
from .admin_backend import AdminBackend
from .auth_models import (
JWT_ALGORITHM,
AuthenticationFailedError,
AuthenticationNeededError,
LoginError,
PasswordDB,
User,
TokenData,
create_access_token,
verify_password,
)
from .types import DBSessionDep
from .views import create_audit_view, create_client_view, create_secrets_view
ACCESS_TOKEN_EXPIRE_MINUTES = 45
LOG = logging.getLogger(__name__)
def login_error(templates: Jinja2Blocks, request: Request):
"""Return a login error."""
return templates.TemplateResponse(
request,
"login.html",
{
"page_title": "Login",
"page_description": "Login Page",
"error": "Invalid Login.",
},
)
def create_frontend(
settings: AdminServerSettings, get_db_session: DBSessionDep
) -> APIRouter:
"""Create frontend."""
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)
# @app.exception_handler(AuthenticationFailedError)
# async def handle_authentication_failed(request: Request, exc: AuthenticationFailedError):
# """Handle authentication failed error."""
# return templates.TemplateResponse(request, "login.html")
async def get_backend():
"""Get backend client."""
backend_client = SshecretBackend(
str(settings.backend_url), settings.backend_token
)
yield backend_client
async def get_admin_backend(session: Annotated[Session, Depends(get_db_session)]):
"""Get admin backend API."""
password_db = session.exec(select(PasswordDB).where(PasswordDB.id == 1)).first()
if not password_db:
raise HTTPException(
500, detail="Error: The password manager has not yet been set up."
)
admin = AdminBackend(settings, password_db.encrypted_password)
yield admin
async def get_login_status(
request: Request, session: Annotated[Session, Depends(get_db_session)]
) -> bool:
"""Get login status."""
token = request.cookies.get("access_token")
if not token:
return False
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[JWT_ALGORITHM])
username = payload.get("sub")
if not username:
return False
except jwt.InvalidTokenError:
return False
token_data = TokenData(username=username)
user = session.exec(
select(User).where(User.username == token_data.username)
).first()
if not user:
return False
return True
async def get_current_user_from_token(
request: Request, session: Annotated[Session, Depends(get_db_session)]
) -> User:
credentials_exception = AuthenticationNeededError()
"""Get current user from token."""
token = request.cookies.get("access_token")
if not token:
raise credentials_exception
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[JWT_ALGORITHM])
username = payload.get("sub")
if not username:
raise credentials_exception
except jwt.InvalidTokenError:
raise credentials_exception
token_data = TokenData(username=username)
user = session.exec(
select(User).where(User.username == token_data.username)
).first()
if not user:
raise credentials_exception
return user
@app.get("/")
async def get_index(
request: Request,
login_status: Annotated[bool, Depends(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:
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,
},
)
@app.post("/")
async def post_index(
request: Request,
error_title: str | None = None,
error_message: str | None = None,
):
"""Get index."""
login_error: LoginError | None = None
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,
},
)
@app.post("/login")
async def login_user(
response: Response,
request: Request,
session: Annotated[Session, Depends(get_db_session)],
username: Annotated[str, Form()],
password: Annotated[str, Form()],
):
"""Log in user."""
user = session.exec(select(User).where(User.username == username)).first()
auth_error = AuthenticationFailedError()
if not user:
raise auth_error
if not verify_password(password, user.hashed_password):
raise auth_error
token_data = {"sub": user.username}
expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
token = create_access_token(settings, token_data, expires_delta=expires)
response = RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
response.set_cookie(
key="access_token", value=token, httponly=True, secure=False, samesite="lax"
)
return response
@app.get("/success")
async def success_page(
request: Request,
current_user: Annotated[User, Depends(get_current_user_from_token)],
):
"""Display a success page."""
return templates.TemplateResponse(
request, "success.html", {"page_title": "Success!", "user": current_user}
)
@app.get("/dashboard")
async def get_dashboard(
request: Request,
current_user: Annotated[User, Depends(get_current_user_from_token)],
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
):
"""Dashboard for mocking up the dashboard."""
# secrets = await admin.get_secrets()
return templates.TemplateResponse(
request,
"dashboard.html",
{
"page_title": "sshecret",
"user": current_user.username,
},
)
# Stop adding routes here.
app.include_router(
create_client_view(templates, get_current_user_from_token, get_admin_backend)
)
app.include_router(
create_secrets_view(templates, get_current_user_from_token, get_admin_backend)
)
app.include_router(
create_audit_view(templates, get_current_user_from_token, get_admin_backend)
)
return app

View File

@ -7,17 +7,18 @@ from pathlib import Path
from typing import cast
import pykeepass
from sshecret_admin.master_password import retrieve_master_password
from .master_password import decrypt_master_password
from .settings import AdminServerSettings
LOG = logging.getLogger(__name__)
NO_USERNAME = "NO_USERNAME"
settings = ServerSettings()
DEFAULT_LOCATION = "keepass.kdbx"
def create_password_db(location: Path, password: str) -> None:
"""Create the password database."""
LOG.info("Creating password database at %s", location)
@ -64,7 +65,7 @@ class PasswordContext:
LOG.warning("Secret name %s accessed", entry_name)
if password := cast(str, entry.password):
return str(entry.password)
return str(password)
raise RuntimeError(f"Cannot get password for entry {entry_name}")
@ -75,6 +76,17 @@ class PasswordContext:
return []
return [str(entry.title) for entry in entries]
def delete_entry(self, entry_name: str) -> None:
"""Delete entry."""
entry = cast(
"pykeepass.entry.Entry | None",
self.keepass.find_entries(title=entry_name, first=True),
)
if not entry:
return
entry.delete()
self.keepass.save()
@contextmanager
def _password_context(location: Path, password: str) -> Iterator[PasswordContext]:
@ -84,16 +96,19 @@ def _password_context(location: Path, password: str) -> Iterator[PasswordContext
yield context
@contextmanager
def load_password_manager(encrypted_password: str, location: str = DEFAULT_LOCATION) -> Iterator[PasswordContext]:
def load_password_manager(
settings: AdminServerSettings,
encrypted_password: str,
location: str = DEFAULT_LOCATION,
) -> Iterator[PasswordContext]:
"""Load password manager.
This function decrypts the password, and creates the password database if it
has not yet been created.
"""
db_location = Path(location)
password = retrieve_master_password(encrypted_password)
password = decrypt_master_password(settings=settings, encrypted=encrypted_password)
if not db_location.exists():
create_password_db(db_location, password)

View File

@ -0,0 +1,18 @@
"""Main server app."""
import sys
import uvicorn
import click
from pydantic import ValidationError
from .app import create_admin_app
from .settings import AdminServerSettings
try:
app = create_admin_app(AdminServerSettings()) # pyright: ignore[reportCallIssue]
except ValidationError as e:
error = click.style("Error", bold=True, fg="red")
click.echo(f"{error}: One or more required environment variables are missing.")
for error in e.errors():
click.echo(f" - {error['loc'][0]}")
sys.exit(1)

View File

@ -1,24 +1,22 @@
"""Functions related to handling the password database master password."""
import secrets
import shutil
from pathlib import Path
from sshecret_admin.crypto import (
from sshecret.crypto import (
create_private_rsa_key,
load_private_key,
encrypt_string,
decode_string,
)
from sshecret_admin.settings import ServerSettings
from .settings import AdminServerSettings
KEY_FILENAME = "sshecret-admin-key"
settings = ServerSettings()
def setup_master_password(
filename: str = KEY_FILENAME, regenerate: bool = False
settings: AdminServerSettings,
filename: str = KEY_FILENAME,
regenerate: bool = False,
) -> str | None:
"""Setup master password.
@ -26,14 +24,16 @@ def setup_master_password(
This method should run just after setting up the database.
"""
created = _initial_key_setup(filename, regenerate)
created = _initial_key_setup(settings, filename, regenerate)
if not created:
return None
return _generate_master_password(filename)
return _generate_master_password(settings, filename)
def decrypt_master_password(encrypted: str, filename: str = KEY_FILENAME) -> str:
def decrypt_master_password(
settings: AdminServerSettings, encrypted: str, filename: str = KEY_FILENAME
) -> str:
"""Retrieve master password."""
keyfile = Path(filename)
if not keyfile.exists():
@ -48,21 +48,27 @@ def _generate_password() -> str:
return secrets.token_urlsafe(32)
def _initial_key_setup(filename: str = KEY_FILENAME, regenerate: bool = False) -> bool:
def _initial_key_setup(
settings: AdminServerSettings,
filename: str = KEY_FILENAME,
regenerate: bool = False,
) -> bool:
"""Set up initial keys."""
keyfile = Path(filename)
if keyfile.exists() and not regenerate:
return False
assert (
settings.secret_key is not None
), "Error: Could not load a secret key from environment."
assert settings.secret_key is not None, (
"Error: Could not load a secret key from environment."
)
create_private_rsa_key(keyfile, password=settings.secret_key)
return True
def _generate_master_password(filename: str = KEY_FILENAME) -> str:
def _generate_master_password(
settings: AdminServerSettings, filename: str = KEY_FILENAME
) -> str:
"""Generate master password for password database.
Returns the encrypted string, base64 encoded.

View File

@ -1,23 +1,25 @@
"""SSH Server settings."""
from pathlib import Path
from pydantic import AnyHttpUrl, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
DEFAULT_LISTEN_PORT = 8822
DEFAULT_DB = "ssh_admin.db"
DEFAULT_DATABASE = "sqlite:///ssh_admin.db"
class ServerSettings(BaseSettings):
class AdminServerSettings(BaseSettings):
"""Server Settings."""
model_config = SettingsConfigDict(env_file=".admin.env", env_prefix="sshecret_admin_", secrets_dir='/var/run')
model_config = SettingsConfigDict(
env_file=".admin.env", env_prefix="sshecret_admin_", secrets_dir="/var/run"
)
backend_url: AnyHttpUrl = Field(alias="sshecret_backend_url")
backend_token: str
listen_address: str = Field(default="")
secret_key: str
port: int = DEFAULT_LISTEN_PORT
admin_db: Path = Path(DEFAULT_DB)
admin_db: str = Field(default=DEFAULT_DATABASE)
debug: bool = False

View File

@ -0,0 +1,30 @@
@import "tailwindcss";
@source "../node_modules/flowbite";
@source "../node_modules/flowbite-datepicker";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-primary-50: #eff6ff;
--color-primary-100: #dbeafe;
--color-primary-200: #bfdbfe;
--color-primary-300: #93c5fd;
--color-primary-400: #60a5fa;
--color-primary-500: #3b82f6;
--color-primary-600: #2563eb;
--color-primary-700: #1d4ed8;
--color-primary-800: #1e40af;
--color-primary-900: #1e3a8a;
--font-sans: "Inter", "ui-sans-serif", "system-ui", "-apple-system",
"system-ui", "Segoe UI", "Roboto", "Helvetica Neue", "Arial",
"Noto Sans", "sans-serif", "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol", "Noto Color Emoji";
--font-body: "Inter", "ui-sans-serif", "system-ui", "-apple-system",
"system-ui", "Segoe UI", "Roboto", "Helvetica Neue", "Arial",
"Noto Sans", "sans-serif", "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol", "Noto Color Emoji";
--font-mono: "ui-monospace", "SFMono-Regular", "Menlo", "Monaco",
"Consolas", "Liberation Mono", "Courier New", "monospace";
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,3 @@
/* PrismJS 1.30.0
https://prismjs.com/download.html#themes=prism&languages=markup+css+json */
code[class*=language-],pre[class*=language-]{color:#000;background:0 0;text-shadow:0 1px #fff;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}code[class*=language-] ::-moz-selection,code[class*=language-]::-moz-selection,pre[class*=language-] ::-moz-selection,pre[class*=language-]::-moz-selection{text-shadow:none;background:#b3d4fc}code[class*=language-] ::selection,code[class*=language-]::selection,pre[class*=language-] ::selection,pre[class*=language-]::selection{text-shadow:none;background:#b3d4fc}@media print{code[class*=language-],pre[class*=language-]{text-shadow:none}}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#f5f2f0}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#708090}.token.punctuation{color:#999}.token.namespace{opacity:.7}.token.boolean,.token.constant,.token.deleted,.token.number,.token.property,.token.symbol,.token.tag{color:#905}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#690}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url{color:#9a6e3a;background:hsla(0,0%,100%,.5)}.token.atrule,.token.attr-value,.token.keyword{color:#07a}.token.class-name,.token.function{color:#dd4a68}.token.important,.token.regex,.token.variable{color:#e90}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}

View File

@ -0,0 +1,23 @@
<!doctype html>
<html class="no-js" lang="">
<head>
<meta charset="utf-8" />
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<title>Static in lib</title>
<meta name="description" content="" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<!-- Place favicon.ico in the root directory -->
</head>
<body>
<!--[if lt IE 8]>
<p class="browserupgrade">
You are using an <strong>outdated</strong> browser. Please
<a href="http://browsehappy.com/">upgrade your browser</a> to improve
your experience.
</p>
<![endif]-->
<h1>I'm inside the package</h1>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,54 @@
const sidebar = document.getElementById("sidebar");
if (sidebar) {
const toggleSidebarMobile = (
sidebar,
sidebarBackdrop,
toggleSidebarMobileHamburger,
toggleSidebarMobileClose,
) => {
sidebar.classList.toggle("hidden");
sidebarBackdrop.classList.toggle("hidden");
toggleSidebarMobileHamburger.classList.toggle("hidden");
toggleSidebarMobileClose.classList.toggle("hidden");
};
const toggleSidebarMobileEl = document.getElementById("toggleSidebarMobile");
const sidebarBackdrop = document.getElementById("sidebarBackdrop");
const toggleSidebarMobileHamburger = document.getElementById(
"toggleSidebarMobileHamburger",
);
const toggleSidebarMobileClose = document.getElementById(
"toggleSidebarMobileClose",
);
// const toggleSidebarMobileSearch = document.getElementById(
// "toggleSidebarMobileSearch",
// );
// toggleSidebarMobileSearch.addEventListener("click", () => {
// toggleSidebarMobile(
// sidebar,
// sidebarBackdrop,
// toggleSidebarMobileHamburger,
// toggleSidebarMobileClose,
// );
// });
toggleSidebarMobileEl.addEventListener("click", () => {
toggleSidebarMobile(
sidebar,
sidebarBackdrop,
toggleSidebarMobileHamburger,
toggleSidebarMobileClose,
);
});
// sidebarBackdrop.addEventListener("click", () => {
// toggleSidebarMobile(
// sidebar,
// sidebarBackdrop,
// toggleSidebarMobileHamburger,
// toggleSidebarMobileClose,
// );
// });
}

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 504 494" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,-300,-220)">
<g transform="matrix(1.60539,0,0,1.60539,-319.922,-249.523)">
<g transform="matrix(1,0,0,1,8.07567,42.4671)">
<path d="M535,250L561.806,335L613.462,308.734L605.179,367.467L691.924,367.467L621.746,420L661.954,462.5L605.179,472.533L631.985,557.533L561.806,505L535,557.533L508.194,505L438.015,557.533L464.821,472.533L408.046,462.5L448.254,420L378.076,367.467L464.821,367.467L456.538,308.734L508.194,335L535,250Z"/>
</g>
<g transform="matrix(0.8125,0,0,0.8125,331.826,19.4716)">
<path d="M246.833,633.448C236.413,655.429 209.771,670 180,670L180,557.533C209.771,557.533 236.413,572.104 246.833,594.085L275.336,594.085C285.418,572.104 311.195,557.533 340,557.533L340,670C311.195,670 285.418,655.429 275.336,633.448L246.833,633.448Z" style="fill:rgb(180,17,4);"/>
</g>
<g transform="matrix(1,0,0,1,258.076,2.46711)">
<ellipse cx="250" cy="415" rx="20" ry="15" style="fill:white;"/>
</g>
<g transform="matrix(1,0,0,1,328.076,2.46711)">
<ellipse cx="250" cy="415" rx="20" ry="15" style="fill:white;"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1,31 @@
<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"
>
{{ entry.subsystem }}
</td>
<td
class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400"
>
<pre><code class="language-json">
{%- set entry_object = ({"object": entry.object, "object_id": entry.object_id, "client_id": entry.client_id, "client_name": entry.client_name}) -%}
{{- entry_object | tojson(indent=2) -}}</code></pre>
</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>

View File

@ -0,0 +1,61 @@
{% extends "/dashboard/_base.html" %} {% block content %}
<div
class="p-4 bg-white block sm:flex items-center justify-between border-b border-gray-200 lg:mt-1.5 dark:bg-gray-800 dark:border-gray-700"
>
<div class="w-full mb-1">
<div class="mb-4">
<nav class="flex mb-5" aria-label="Breadcrumb">
<ol
class="inline-flex items-center space-x-1 text-sm font-medium md:space-x-2"
>
<li class="inline-flex items-center">
<a
href="/"
class="inline-flex items-center text-gray-700 hover:text-primary-600 dark:text-gray-300 dark:hover:text-white"
>
<svg
class="w-5 h-5 mr-2.5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"
></path>
</svg>
Home
</a>
</li>
<li>
<div class="flex items-center">
<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"
/>
<span class="ml-1 text-gray-400 md:ml-2 dark:text-gray-500" aria-current="page">Audit Log</span>
</svg>
</div>
</li>
</ol>
</nav>
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Audit Log</h1>
</div>
</div>
</div>
<div id="auditContent">
{% include 'audit/inner.html.j2' %}
</div>
{% endblock %}

View File

@ -0,0 +1,55 @@
<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"
>
Timestamp
</th>
<th
scope="col"
class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400"
>
Subsystem
</th>
<th
scope="col"
class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400"
>
Object
</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

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

@ -0,0 +1,67 @@
<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
<span class="font-semibold text-gray-900 dark:text-white">{{page_info.first }}-{{ page_info.last}}</span> of
<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

@ -0,0 +1,82 @@
<tr
class="hover:bg-gray-100 dark:hover:bg-gray-700"
id="client-{{ client.id }}"
>
<td
class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400"
>
{{ client.name }}
</td>
<td
class="p-4 text-base font-medium text-gray-900 whitespace-nowrap dark:text-white"
>
{{ client.id }}
</td>
<td
class="max-w-sm p-4 overflow-hidden text-base font-normal text-gray-500 truncate xl:max-w-xs dark:text-gray-400"
>
{{ client.description }}
</td>
<td
class="max-w-sm p-4 text-base font-normal text-gray-500 truncate xl:max-w-xs dark:text-gray-400"
>
{{ client.secrets|length }}
</td>
<td
class="max-w-sm p-4 text-base font-normal text-gray-500 truncate xl:max-w-xs dark:text-gray-400"
>
{{ client.policies|join(', ') }}
</td>
<td class="p-4 space-x-2 whitespace-nowrap">
<button
type="button"
id="updateClientButton"
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="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white rounded-lg bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
>
<svg
class="w-4 h-4 mr-2"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"
></path>
<path
fill-rule="evenodd"
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
clip-rule="evenodd"
></path>
</svg>
Update
</button>
<button
type="button"
id="deleteClientButton"
data-drawer-target="drawer-delete-client-{{ client.id }}"
data-drawer-show="drawer-delete-client-{{ client.id }}"
aria-controls="drawer-delete-client-{{ client.id }}"
data-drawer-placement="right"
class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-red-700 rounded-lg hover:bg-red-800 focus:ring-4 focus:ring-red-300 dark:focus:ring-red-900"
>
<svg
class="w-4 h-4 mr-2"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clip-rule="evenodd"
></path>
</svg>
Delete item
</button>
</td>
</tr>

View File

@ -0,0 +1,145 @@
<div
id="drawer-create-client-default"
class="fixed top-0 right-0 z-40 w-full h-screen max-w-xs p-4 overflow-y-auto transition-transform translate-x-full bg-white dark:bg-gray-800"
tabindex="-1"
aria-labelledby="drawer-label"
aria-hidden="true"
>
<h5
id="drawer-label"
class="inline-flex items-center mb-6 text-sm font-semibold text-gray-500 uppercase dark:text-gray-400"
>
New Client
</h5>
<button
type="button"
data-drawer-dismiss="drawer-create-client-default"
aria-controls="drawer-create-client-default"
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 absolute top-2.5 right-2.5 inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white"
>
<svg
aria-hidden="true"
class="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
></path>
</svg>
<span class="sr-only">Close menu</span>
</button>
<form hx-post="/clients/" hx-target="#clientContent">
<div class="space-y-4">
<div>
<label
for="name"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Name</label
>
<input
type="text"
name="name"
id="name"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
placeholder="Client name"
required=""
/>
</div>
<div>
<label
for="description"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Description</label
>
<input
type="text"
name="description"
id="description"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
placeholder="Client description"
/>
</div>
<div>
<label
for="sources"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Allowed subnets or IPs</label
>
<p
id="helper-text-explanation"
class="mt-2 text-sm text-gray-500 dark:text-gray-400"
>
Separate multiple entries with comma.
</p>
<input
type="text"
name="sources"
id="sources"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
placeholder="0.0.0.0/0"
value="0.0.0.0/0"
hx-post="/clients/validate/source"
hx-target="#clientSourceValidation"
/>
<span id="clientSourceValidation"></span>
</div>
<div>
<label
for="public_key"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Public Key</label
>
<textarea
id="public_key"
name="public_key"
rows="4"
class="block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
placeholder="Enter RSA SSH Public Key here"
hx-post="/clients/validate/public_key"
hx-target="#clientPublicKeyValidation"
></textarea>
<span id="clientPublicKeyValidation"></span>
</div>
<div
class="bottom-0 left-0 flex justify-center w-full pb-4 space-x-4 md:px-4 md:absolute"
>
<button
type="submit"
class="text-white w-full justify-center bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
>
Add Client
</button>
<button
type="button"
data-drawer-dismiss="drawer-create-client-default"
aria-controls="drawer-create-client-default"
class="inline-flex w-full justify-center text-gray-500 items-center bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-primary-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600"
>
<svg
aria-hidden="true"
class="w-5 h-5 -ml-1 sm:mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
Cancel
</button>
</div>
</div>
</form>
</div>

View File

@ -0,0 +1,67 @@
<div
id="drawer-delete-client-{{ client.id }}"
class="fixed top-0 right-0 z-40 w-full h-screen max-w-xs p-4 overflow-y-auto transition-transform translate-x-full bg-white dark:bg-gray-800"
tabindex="-1"
aria-labelledby="drawer-label"
aria-hidden="true"
>
<h5
id="drawer-label"
class="inline-flex items-center text-sm font-semibold text-gray-500 uppercase dark:text-gray-400"
>
Delete Client {{client.name}}
</h5>
<button
type="button"
data-drawer-dismiss="drawer-delete-client-{{ client.id }}"
aria-controls="drawer-delete-client-{{ client.id }}"
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 absolute top-2.5 right-2.5 inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white"
>
<svg
aria-hidden="true"
class="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
></path>
</svg>
<span class="sr-only">Close menu</span>
</button>
<svg
class="w-10 h-10 mt-8 mb-4 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<h3 class="mb-6 text-lg text-gray-500 dark:text-gray-400">
Are you sure you want to delete this client?
</h3>
<button
type="button"
class="text-white bg-red-600 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm inline-flex items-center px-3 py-2.5 text-center mr-2 dark:focus:ring-red-900"
hx-delete="/clients/{{ client.id }}"
hx-target="#clientContent"
>
Yes, delete the client
</button>
<a
href="#"
class="text-gray-900 bg-white hover:bg-gray-100 focus:ring-4 focus:ring-primary-300 border border-gray-200 font-medium inline-flex items-center rounded-lg text-sm px-3 py-2.5 text-center dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-gray-700"
data-drawer-hide="drawer-delete-client-{{ client.id }}"
>
No, cancel
</a>
</div>

View File

@ -0,0 +1,173 @@
<div
id="drawer-update-client-{{ client.id }}"
class="fixed top-0 right-0 z-40 w-full h-screen max-w-xs p-4 overflow-y-auto transition-transform translate-x-full bg-white dark:bg-gray-800"
tabindex="-1"
aria-labelledby="drawer-label-{{ client.id }}"
aria-hidden="true"
>
<h5
id="drawer-label-{{ client.id }}"
class="inline-flex items-center mb-6 text-sm font-semibold text-gray-500 uppercase dark:text-gray-400"
>
<div role="status" class="mr-2 htmx-indicator" id="spinner-{{ client.id}}">
<svg aria-hidden="true" class="inline w-4 h-4 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor"/>
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill"/>
</svg>
<span class="sr-only">Loading...</span>
</div>
Update Client
</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>
<form
hx-put="/clients/{{ client.id }}"
hx-target="#clientContent"
hx-indicator="spinner-{{ client.id }}"
>
<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"
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 updating the key will invalidate all secrets associated with
this client.
</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="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-confirm="Are you sure?"
hx-target="#clientContent"
>
<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

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

View File

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

View File

@ -0,0 +1 @@
<span></span>

View File

@ -0,0 +1,45 @@
{% extends "/dashboard/_base.html" %} {% block content %}
<div class="p-4 bg-white block sm:flex items-center justify-between border-b border-gray-200 lg:mt-1.5 dark:bg-gray-800 dark:border-gray-700">
<div class="w-full mb-1">
<div class="mb-4">
<nav class="flex mb-5" aria-label="Breadcrumb">
<ol class="inline-flex items-center space-x-1 text-sm font-medium md:space-x-2">
<li class="inline-flex items-center">
<a href="/" class="inline-flex items-center text-gray-700 hover:text-primary-600 dark:text-gray-300 dark:hover:text-white">
<svg class="w-5 h-5 mr-2.5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"></path></svg>
Home
</a>
</li>
<li>
<div class="flex items-center">
<svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg>
<span class="ml-1 text-gray-400 md:ml-2 dark:text-gray-500" aria-current="page">Clients</span>
</svg>
</div>
</li>
</ol>
</nav>
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Clients</h1>
</div>
<div class="items-center justify-between block sm:flex">
<div class="flex items-center mb-4 sm:mb-0">
<label for="client-search" class="sr-only">Search</label>
<div class="relative w-48 mt-1 sm:w-64 xl:w-96">
<input type="search" name="query" id="client-search" class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500" placeholder="Search for clients" hx-post="/clients/query" hx-trigger="keyup changed delay:500ms, query" hx-target="#clientContent">
</div>
</div>
<button id="createClientButton" class="text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-primary-600 dark:hover:bg-primary-700 focus:outline-none dark:focus:ring-primary-800" type="button" data-drawer-target="drawer-create-client-default" data-drawer-show="drawer-create-client-default" aria-controls="drawer-create-client-default" data-drawer-placement="right">
Add new client
</button>
</div>
</div>
</div>
<div id="clientContent">
{% include '/clients/inner.html.j2' %}
</div>
{% include '/clients/drawer_client_create.html.j2' %}
{% endblock %}

View File

@ -0,0 +1,48 @@
<div>
<div class="flex flex-col">
<div class="overflow-x-auto">
<div class="inline-block min-w-full align-middle">
<div class="overflow-hidden shadow">
<table class="min-w-full divide-y divide-gray-200 table-fixed dark:divide-gray-600">
<thead class="bg-gray-100 dark:bg-gray-700">
<tr>
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
Name
</th>
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
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

@ -0,0 +1,10 @@
{% extends "/dashboard/_base.html" %} {% block content %}
<div
class="p-4 bg-white block sm:flex items-center justify-between border-b border-gray-200 lg:mt-1.5 dark:bg-gray-800 dark:border-gray-700"
>
<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>
{% endblock %}

View File

@ -0,0 +1,24 @@
<!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">
<main>
{% block content %}
{% endblock %}
</main>
</div>
</div>
{% include '/dashboard/_scripts.html' %}
</body>
</html>

View File

@ -0,0 +1 @@
<!-- todo -->

View File

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

@ -0,0 +1,14 @@
<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="text/javascript" src="{{ url_for('static', path="js/prism.js") }}"></script>
<script>
document.body.addEventListener("htmx:afterSwap", () => {
if (typeof window.initFlowbite === "function") {
window.initFlowbite();
}
});
</script>

View File

@ -0,0 +1,21 @@
<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
href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css"
rel="stylesheet"
/>

View File

@ -0,0 +1,47 @@
<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>
<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="M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0 0a8.949 8.949 0 0 0 4.951-1.488A3.987 3.987 0 0 0 13 16h-2a3.987 3.987 0 0 0-3.951 3.512A8.948 8.948 0 0 0 12 21Zm3-11a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
</svg>
</button>
</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 }}
</p>
</div>
<ul class="py-1" role="none">
<li>
<a href="#" 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>
<li>
<a href="#" 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

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

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

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

View File

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

View File

@ -0,0 +1,55 @@
{% extends "/shared/_base.html" %} {% 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"
/>
</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="••••••••"
/>
</div>
<button
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2.5 rounded-lg transition-colors"
>
Sign In
</button>
</form>
</div>
</div>
{% endblock %}

View File

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

View File

@ -0,0 +1,155 @@
<div
id="drawer-create-secret-default"
class="fixed top-0 right-0 z-40 w-full h-screen max-w-xs p-4 overflow-y-auto transition-transform translate-x-full bg-white dark:bg-gray-800"
tabindex="-1"
aria-labelledby="drawer-label"
aria-hidden="true"
>
<h5
id="drawer-label"
class="inline-flex items-center mb-6 text-sm font-semibold text-gray-500 uppercase dark:text-gray-400"
>
New Secret
</h5>
<button
type="button"
data-drawer-dismiss="drawer-create-secret-default"
aria-controls="drawer-create-secret-default"
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 absolute top-2.5 right-2.5 inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white"
>
<svg
aria-hidden="true"
class="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
></path>
</svg>
<span class="sr-only">Close menu</span>
</button>
<form hx-post="/secrets/" hx-target="#secretsContent">
<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">
<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
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 Secret
</button>
<button
type="button"
data-drawer-dismiss="drawer-create-secret-default"
aria-controls="drawer-create-secret-default"
class="inline-flex w-full justify-center text-gray-500 items-center bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-primary-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600"
>
<svg
aria-hidden="true"
class="w-5 h-5 -ml-1 sm:mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
Cancel
</button>
</div>
</div>
</form>
</div>

View File

@ -0,0 +1,45 @@
{% extends "/dashboard/_base.html" %} {% block content %}
<div class="p-4 bg-white block sm:flex items-center justify-between border-b border-gray-200 lg:mt-1.5 dark:bg-gray-800 dark:border-gray-700">
<div class="w-full mb-1">
<div class="mb-4">
<nav class="flex mb-5" aria-label="Breadcrumb">
<ol class="inline-flex items-center space-x-1 text-sm font-medium md:space-x-2">
<li class="inline-flex items-center">
<a href="/" class="inline-flex items-center text-gray-700 hover:text-primary-600 dark:text-gray-300 dark:hover:text-white">
<svg class="w-5 h-5 mr-2.5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"></path></svg>
Home
</a>
</li>
<li>
<div class="flex items-center">
<svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg>
<span class="ml-1 text-gray-400 md:ml-2 dark:text-gray-500" aria-current="page">Secrets</span>
</svg>
</div>
</li>
</ol>
</nav>
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Secrets</h1>
</div>
<div class="items-center justify-between block sm:flex">
<div class="flex items-center mb-4 sm:mb-0">
<label for="secret-search" class="sr-only">Search</label>
<div class="relative w-48 mt-1 sm:w-64 xl:w-96">
<input type="search" name="query" id="secret-search" class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500" placeholder="Search for secrets" hx-post="/secrets/query" hx-trigger="keyup changed delay:500ms, query" hx-target="#secretsContent">
</div>
</div>
<button id="createSecretButton" class="text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-primary-600 dark:hover:bg-primary-700 focus:outline-none dark:focus:ring-primary-800" type="button" data-drawer-target="drawer-create-secret-default" data-drawer-show="drawer-create-secret-default" aria-controls="drawer-create-secret-default" data-drawer-placement="right">
Add new secret
</button>
</div>
</div>
</div>
<div id="secretsContent">
{% include '/secrets/inner.html.j2' %}
</div>
{% include '/secrets/drawer_secret_create.html.j2' %}
{% endblock %}

View File

@ -0,0 +1,59 @@
<div>
<div class="flex flex-col">
<div class="overflow-x-auto">
<div class="inline-block min-w-full align-middle">
<div class="overflow-hidden shadow">
<table class="min-w-full divide-y divide-gray-200 table-fixed dark:divide-gray-600">
<thead class="bg-gray-100 dark:bg-gray-700">
<tr>
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
Name
</th>
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
Clients associated
</th>
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
Actions
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200 dark:bg-gray-800 dark:divide-gray-700">
{% for secret in secrets %}
{% include '/secrets/secret.html.j2'%}
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div 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">
<a href="#" class="inline-flex justify-center p-1 text-gray-500 rounded cursor-pointer hover:text-gray-900 hover:bg-gray-100 dark:hover:bg-gray-700 dark:hover:text-white">
<svg class="w-7 h-7" 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>
</a>
<a href="#" class="inline-flex justify-center p-1 mr-2 text-gray-500 rounded cursor-pointer hover:text-gray-900 hover:bg-gray-100 dark:hover:bg-gray-700 dark:hover:text-white">
<svg class="w-7 h-7" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg>
</a>
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">Showing <span class="font-semibold text-gray-900 dark:text-white">1-20</span> of <span class="font-semibold text-gray-900 dark:text-white">2290</span></span>
</div>
<div class="flex items-center space-x-3">
<a href="#" class="inline-flex items-center justify-center flex-1 px-3 py-2 text-sm font-medium text-center text-white rounded-lg bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">
<svg 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="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>
Previous
</a>
<a href="#" class="inline-flex items-center justify-center flex-1 px-3 py-2 text-sm font-medium text-center text-white rounded-lg bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">
Next
<svg class="w-5 h-5 ml-1 -mr-1" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg>
</a>
</div>
</div>
</div>
{% for secret in secrets %}
{% include '/secrets/modal_client_secret.html.j2' %}
{% endfor %}

View File

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

View File

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

View File

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

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

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

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

View File

@ -0,0 +1,45 @@
"""Testing helper functions."""
import os
import bcrypt
from sqlalchemy import Engine
from sqlmodel import Session, select
from .auth_models import User
def get_test_user_details() -> tuple[str, str]:
"""Resolve testing user."""
test_user = os.getenv("SSHECRET_TEST_USERNAME") or "test"
test_password = os.getenv("SSHECRET_TEST_PASSWORD") or "test"
if test_user and test_password:
return (test_user, test_password)
raise RuntimeError(
"Error: No testing username and password registered in environment."
)
def is_testing_mode() -> bool:
"""Check if we're running in test mode.
We will determine this by looking for the environment variable SSHECRET_TEST_MODE=1
"""
if os.environ.get("PYTEST_VERSION") is not None:
return True
return False
def create_test_user(session: Session, username: str, password: str) -> User:
"""Create test user.
We create a user with whatever username and password is supplied.
"""
salt = bcrypt.gensalt()
hashed_password = bcrypt.hashpw(password.encode(), salt)
user = User(username=username, hashed_password=hashed_password.decode())
session.add(user)
session.commit()
return user

View File

@ -0,0 +1,21 @@
"""Common type definitions."""
from collections.abc import AsyncGenerator, Callable, Generator, Awaitable
from fastapi import Request
from sqlmodel import Session
from sshecret_admin.admin_backend import AdminBackend
from sshecret_admin.auth_models import User
from sshecret.backend import SshecretBackend
from . import keepass
DBSessionDep = Callable[[], Generator[Session, None, None]]
BackendDep = Callable[[], AsyncGenerator[SshecretBackend, None]]
PasswdCtxDep = Callable[[DBSessionDep], AsyncGenerator[keepass.PasswordContext, None]]
AdminDep = Callable[[Session], AsyncGenerator[AdminBackend, None]]
UserTokenDep = Callable[[Request, Session], Awaitable[User]]

View File

@ -1,9 +1,17 @@
"""Models for the API."""
from typing import Annotated
from pydantic import AfterValidator, BaseModel, Field, IPvAnyAddress, IPvAnyNetwork
from .crypto import validate_public_key
from .backend import Client
import secrets
from typing import Annotated, Literal, Self, Union
from pydantic import (
AfterValidator,
BaseModel,
ConfigDict,
Field,
IPvAnyAddress,
IPvAnyNetwork,
model_validator,
)
from sshecret.crypto import validate_public_key
def public_key_validator(value: str) -> str:
@ -12,6 +20,7 @@ def public_key_validator(value: str) -> str:
return value
raise ValueError("Error: Public key must be a valid RSA public key.")
class SecretListView(BaseModel):
"""Model containing a list of all available secrets."""
@ -53,3 +62,52 @@ class ClientCreate(BaseModel):
name: str
public_key: Annotated[str, AfterValidator(public_key_validator)]
sources: list[IPvAnyAddress | IPvAnyNetwork] = Field(default_factory=list)
class AutoGenerateOpts(BaseModel):
"""Option to auto-generate a password."""
auto_generate: Literal[True]
length: int = 32
class SecretUpdate(BaseModel):
"""Model to update a secret."""
value: str | AutoGenerateOpts = Field(
description="Secret as string value or auto-generated with optional length",
examples=["MySecretString", {"auto_generate": True, "length": 32}]
)
def get_secret(self) -> str:
"""Get secret.
This returns the specified one, or generates one according to auto-generation.
"""
if isinstance(self.value, str):
return self.value
secret = secrets.token_urlsafe(self.value.length)
return secret
class SecretCreate(SecretUpdate):
"""Model to create a secret."""
name: str
clients: list[str] | None = Field(default=None, description="Assign the secret to a list of clients.")
model_config: ConfigDict = ConfigDict(
json_schema_extra={
"examples": [
{
"name": "MySecret",
"clients": ["client-1", "client-2"],
"value": { "auto_generate": True, "length": 32 }
},
{
"name": "MySecret",
"value": "mysecretstring",
}
]
}
)

View File

@ -0,0 +1,5 @@
from .audit import create_audit_view
from .clients import create_client_view
from .secrets import create_secrets_view
__all__ = ["create_audit_view", "create_client_view", "create_secrets_view"]

View File

@ -0,0 +1,113 @@
"""Audit view."""
# pyright: reportUnusedFunction=false
import math
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Request, Response
from jinja2_fragments.fastapi import Jinja2Blocks
from pydantic import BaseModel
from sshecret_admin.admin_backend import AdminBackend
from sshecret_admin.types import UserTokenDep, AdminDep
from sshecret_admin.auth_models import User
LOG = logging.getLogger(__name__)
class PagingInfo(BaseModel):
page: int
limit: int
total: int
offset: int = 0
@property
def first(self) -> int:
"""The first result number."""
return self.offset + 1
@property
def last(self) -> int:
"""Return the last result number."""
return self.offset + self.limit
@property
def total_pages(self) -> int:
"""Return total pages."""
return math.ceil(self.total / self.limit)
def create_audit_view(
templates: Jinja2Blocks,
get_current_user_from_token: UserTokenDep,
get_admin_backend: AdminDep,
) -> APIRouter:
"""Create client view."""
app = APIRouter()
async def resolve_audit_entries(
request: Request,
current_user: User,
admin: AdminBackend,
page: int
) -> Response:
"""Resolve audit entries."""
LOG.info("Page: %r", page)
total_messages = await admin.get_audit_log_count()
per_page = 20
offset = 0
if page > 1:
offset = (page - 1) * per_page
entries = await admin.get_audit_log(offset=offset, limit=per_page)
LOG.info("Entries: %r", entries)
page_info = PagingInfo(page=page, limit=per_page, total=total_messages, offset=offset)
if request.headers.get("HX-Request"):
return templates.TemplateResponse(
request,
"audit/inner.html.j2",
{
"entries": entries,
"page_info": page_info,
}
)
return templates.TemplateResponse(
request,
"audit/index.html.j2",
{
"page_title": "Audit",
"entries": entries,
"user": current_user.username,
"page_info": page_info,
}
)
@app.get("/audit/")
async def get_audit_entries(
request: Request,
current_user: Annotated[User, Depends(get_current_user_from_token)],
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
):
"""Get audit entries."""
return await resolve_audit_entries(request, current_user, admin, 1)
@app.get("/audit/page/{page}")
async def get_audit_entries_page(
request: Request,
current_user: Annotated[User, Depends(get_current_user_from_token)],
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
page: int,
):
"""Get audit entries."""
LOG.info("Get audit entries page: %r", page)
return await resolve_audit_entries(request, current_user, admin, page)
# --------------#
# END OF ROUTES #
# --------------#
return app

View File

@ -0,0 +1,229 @@
"""Client views."""
# pyright: reportUnusedFunction=false
import ipaddress
import logging
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, Request, Form
from jinja2_fragments.fastapi import Jinja2Blocks
from pydantic import BaseModel, IPvAnyAddress, IPvAnyNetwork
from sshecret_admin.admin_backend import AdminBackend
from sshecret.backend import ClientFilter
from sshecret.backend.models import FilterType
from sshecret.crypto import validate_public_key
from sshecret_admin.types import UserTokenDep, AdminDep
from sshecret_admin.auth_models import User
LOG = logging.getLogger(__name__)
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
def create_client_view(
templates: Jinja2Blocks,
get_current_user_from_token: UserTokenDep,
get_admin_backend: AdminDep,
) -> APIRouter:
"""Create client view."""
app = APIRouter()
@app.get("/clients")
async def get_clients(
request: Request,
current_user: Annotated[User, Depends(get_current_user_from_token)],
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
):
"""Get clients."""
clients = await admin.get_clients()
LOG.info("Clients %r", clients)
return templates.TemplateResponse(
request,
"clients/index.html.j2",
{
"page_title": "Clients",
"clients": clients,
"user": current_user.username,
},
)
@app.post("/clients/query")
async def query_clients(
request: Request,
_current_user: Annotated[User, Depends(get_current_user_from_token)],
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
query: Annotated[str, Form()],
):
"""Query for a client."""
query_filter: ClientFilter | None = None
if query:
name = f"%{query}%"
query_filter = ClientFilter(name=name, filter_name=FilterType.LIKE)
clients = await admin.get_clients(query_filter)
return templates.TemplateResponse(
request,
"clients/inner.html.j2",
{
"clients": clients,
},
)
@app.put("/clients/{id}")
async def update_client(
request: Request,
id: str,
_current_user: Annotated[User, Depends(get_current_user_from_token)],
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
client: Annotated[ClientUpdate, Form()],
):
"""Update a client."""
original_client = await admin.get_client(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)
await admin.update_client(updated_client)
clients = await admin.get_clients()
headers = {"Hx-Refresh": "true"}
return templates.TemplateResponse(
request,
"clients/inner.html.j2",
{
"clients": clients,
},
headers=headers,
)
@app.delete("/clients/{id}")
async def delete_client(
request: Request,
id: str,
_current_user: Annotated[User, Depends(get_current_user_from_token)],
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
):
"""Delete a client."""
await admin.delete_client(id)
clients = await admin.get_clients()
headers = {"Hx-Refresh": "true"}
return templates.TemplateResponse(
request,
"clients/inner.html.j2",
{
"clients": clients,
},
headers=headers,
)
@app.post("/clients/")
async def create_client(
request: Request,
_current_user: Annotated[User, Depends(get_current_user_from_token)],
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
client: Annotated[ClientCreate, Form()],
):
"""Create client."""
sources: list[str] | None = None
if client.sources:
sources = [source.strip() for source in client.sources.split(",")]
await admin.create_client(
client.name, client.public_key.rstrip(), client.description, sources
)
clients = await admin.get_clients()
headers = {"Hx-Refresh": "true"}
return templates.TemplateResponse(
request,
"clients/inner.html.j2",
{
"clients": clients,
},
headers=headers,
)
@app.post("/clients/validate/source")
async def validate_client_source(
request: Request,
_current_user: Annotated[User, Depends(get_current_user_from_token)],
sources: Annotated[str, Form()],
):
"""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,
_current_user: Annotated[User, Depends(get_current_user_from_token)],
public_key: Annotated[str, Form()],
):
"""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."},
)
return app

View File

@ -0,0 +1,178 @@
"""Secrets view."""
# pyright: reportUnusedFunction=false
import logging
import secrets as pysecrets
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Request, Form
from jinja2_fragments.fastapi import Jinja2Blocks
from pydantic import BaseModel, BeforeValidator, Field
from sshecret_admin.admin_backend import AdminBackend
from sshecret_admin.types import UserTokenDep, AdminDep
from sshecret_admin.auth_models import User
LOG = logging.getLogger(__name__)
def split_clients(clients: Any) -> Any:
"""Split clients."""
if isinstance(clients, list):
return clients
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:
"""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_secrets_view(
templates: Jinja2Blocks,
get_current_user_from_token: UserTokenDep,
get_admin_backend: AdminDep,
) -> APIRouter:
"""Create secrets view."""
app = APIRouter()
@app.get("/secrets/")
async def get_secrets(
request: Request,
current_user: Annotated[User, Depends(get_current_user_from_token)],
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
):
"""Get secrets index page."""
secrets = await admin.get_detailed_secrets()
clients = await admin.get_clients()
return templates.TemplateResponse(
request,
"secrets/index.html.j2",
{
"page_title": "Secrets",
"secrets": secrets,
"user": current_user.username,
"clients": clients,
},
)
@app.post("/secrets/")
async def add_secret(
request: Request,
_current_user: Annotated[User, Depends(get_current_user_from_token)],
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
secret: Annotated[CreateSecret, Form()],
):
"""Add secret."""
LOG.info("secret: %s", secret.model_dump_json(indent=2))
clients = await admin.get_clients()
if secret.value:
value = secret.value
else:
value = pysecrets.token_urlsafe(32)
await admin.add_secret(secret.name, value, secret.clients)
secrets = await admin.get_detailed_secrets()
return templates.TemplateResponse(
request,
"secrets/inner.html.j2",
{
"secrets": secrets,
"clients": clients,
},
)
@app.delete("/secrets/{name}/clients/{id}")
async def remove_client_secret_access(
request: Request,
name: str,
id: str,
_current_user: Annotated[User, Depends(get_current_user_from_token)],
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
):
"""Remove a client's access to a secret."""
await admin.delete_client_secret(id, name)
clients = await admin.get_clients()
secrets = await admin.get_detailed_secrets()
headers = {"Hx-Refresh": "true"}
return templates.TemplateResponse(
request,
"secrets/inner.html.j2",
{"clients": clients, "secret": secrets},
headers=headers,
)
@app.post("/secrets/{name}/clients/")
async def add_secret_to_client(
request: Request,
name: str,
client: Annotated[str, Form()],
_current_user: Annotated[User, Depends(get_current_user_from_token)],
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
):
"""Add a secret to a client."""
await admin.create_client_secret(client, name)
clients = await admin.get_clients()
secrets = await admin.get_detailed_secrets()
headers = {"Hx-Refresh": "true"}
return templates.TemplateResponse(
request,
"secrets/inner.html.j2",
{
"clients": clients,
"secrets": secrets,
},
headers=headers,
)
@app.delete("/secrets/{name}")
async def delete_secret(
request: Request,
name: str,
_current_user: Annotated[User, Depends(get_current_user_from_token)],
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
):
"""Delete a secret."""
await admin.delete_secret(name)
clients = await admin.get_clients()
secrets = await admin.get_detailed_secrets()
headers = {"Hx-Refresh": "true"}
return templates.TemplateResponse(
request,
"secrets/inner.html.j2",
{
"clients": clients,
"secrets": secrets,
},
headers=headers,
)
# --------------#
# END OF ROUTES #
# --------------#
return app

View File

@ -0,0 +1,24 @@
<!doctype html>
<html class="no-js" lang="">
<head>
<meta charset="utf-8" />
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<title>Untitled</title>
<meta name="description" content="" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<!-- Place favicon.ico in the root directory -->
</head>
<body>
<!--[if lt IE 8]>
<p class="browserupgrade">
You are using an <strong>outdated</strong> browser. Please
<a href="http://browsehappy.com/">upgrade your browser</a> to improve
your experience.
</p>
<![endif]-->
<p>I am outside of the package</p>
</body>
</html>

View File

@ -0,0 +1,93 @@
module.exports = {
content: [
"./src/sshecret_admin/templates/**/*.html",
"./src/sshecret_admin/static/**/*.js",
],
safelist: [
"w-64",
"w-1/2",
"rounded-l-lg",
"rounded-r-lg",
"bg-gray-200",
"grid-cols-4",
"grid-cols-7",
"h-6",
"leading-6",
"h-9",
"leading-9",
"shadow-lg",
"bg-opacity-50",
"dark:bg-opacity-80",
],
darkMode: "class",
theme: {
extend: {
colors: {
primary: {
"50": "#eff6ff",
"100": "#dbeafe",
"200": "#bfdbfe",
"300": "#93c5fd",
"400": "#60a5fa",
"500": "#3b82f6",
"600": "#2563eb",
"700": "#1d4ed8",
"800": "#1e40af",
"900": "#1e3a8a",
},
},
fontFamily: {
sans: [
"Inter",
"ui-sans-serif",
"system-ui",
"-apple-system",
"system-ui",
"Segoe UI",
"Roboto",
"Helvetica Neue",
"Arial",
"Noto Sans",
"sans-serif",
"Apple Color Emoji",
"Segoe UI Emoji",
"Segoe UI Symbol",
"Noto Color Emoji",
],
body: [
"Inter",
"ui-sans-serif",
"system-ui",
"-apple-system",
"system-ui",
"Segoe UI",
"Roboto",
"Helvetica Neue",
"Arial",
"Noto Sans",
"sans-serif",
"Apple Color Emoji",
"Segoe UI Emoji",
"Segoe UI Symbol",
"Noto Color Emoji",
],
mono: [
"ui-monospace",
"SFMono-Regular",
"Menlo",
"Monaco",
"Consolas",
"Liberation Mono",
"Courier New",
"monospace",
],
},
transitionProperty: {
width: "width",
},
textDecoration: ["active"],
},
},
plugins: [],
};