From 76ef97d9c4b10d83844eb0b05c088a168ed8f53e Mon Sep 17 00:00:00 2001 From: Allan Eising Date: Wed, 30 Apr 2025 08:22:29 +0200 Subject: [PATCH] Check in admin page in working state --- packages/sshecret-admin/pyproject.toml | 10 + .../src/sshecret_admin/__init__.py | 4 - .../src/sshecret_admin/admin_api.py | 571 +-- .../src/sshecret_admin/admin_backend.py | 402 ++ .../sshecret-admin/src/sshecret_admin/app.py | 117 +- .../src/sshecret_admin/auth_models.py | 95 +- .../src/sshecret_admin/backend.py | 156 - .../sshecret-admin/src/sshecret_admin/cli.py | 143 + .../src/sshecret_admin/constants.py | 2 - .../src/sshecret_admin/crypto.py | 125 - .../sshecret-admin/src/sshecret_admin/db.py | 22 + .../src/sshecret_admin/frontend.py | 240 + .../src/sshecret_admin/keepass.py | 27 +- .../sshecret-admin/src/sshecret_admin/main.py | 18 + .../src/sshecret_admin/master_password.py | 36 +- .../src/sshecret_admin/py.typed | 0 .../src/sshecret_admin/settings.py | 12 +- .../src/sshecret_admin/static/css/input.css | 30 + .../src/sshecret_admin/static/css/main.css | 3935 +++++++++++++++++ .../src/sshecret_admin/static/css/prism.css | 3 + .../src/sshecret_admin/static/index.html | 23 + .../src/sshecret_admin/static/js/prism.js | 6 + .../src/sshecret_admin/static/js/sidebar.js | 54 + .../src/sshecret_admin/static/logo.svg | 20 + .../templates/audit/entry.html.j2 | 31 + .../templates/audit/index.html.j2 | 61 + .../templates/audit/inner.html.j2 | 55 + .../templates/audit/inner_save.html.j2 | 55 + .../templates/audit/pagination.html.j2 | 67 + .../templates/clients/client.html.j2 | 82 + .../clients/drawer_client_create.html.j2 | 145 + .../clients/drawer_client_delete.html.j2 | 67 + .../clients/drawer_client_update.html.j2 | 173 + .../templates/clients/dynamic.html.j2 | 3 + .../templates/clients/field_invalid.html.j2 | 1 + .../templates/clients/field_valid.html.j2 | 1 + .../templates/clients/index.html.j2 | 45 + .../templates/clients/inner.html.j2 | 48 + .../sshecret_admin/templates/dashboard.html | 10 + .../templates/dashboard/_base.html | 24 + .../templates/dashboard/_favicons.html | 1 + .../templates/dashboard/_header.html | 21 + .../templates/dashboard/_scripts.html | 14 + .../templates/dashboard/_stylesheet.html | 21 + .../templates/dashboard/navbar.html | 47 + .../templates/dashboard/sidebar.html | 112 + .../templates/dashboard_old.html | 71 + .../templates/fragments/error.html | 3 + .../templates/fragments/ok.html | 3 + .../src/sshecret_admin/templates/login.html | 55 + .../templates/secrets/client_options.html.j2 | 3 + .../secrets/drawer_secret_create.html.j2 | 155 + .../templates/secrets/index.html.j2 | 45 + .../templates/secrets/inner.html.j2 | 59 + .../secrets/modal_client_secret.html.j2 | 119 + .../templates/secrets/secret.html.j2 | 71 + .../templates/shared/_base.html | 25 + .../templates/shared/_dashboard.html | 95 + .../templates/shared/_dashboard_save.html | 128 + .../src/sshecret_admin/templates/success.html | 6 + .../templates/widgets/clients.html | 0 .../src/sshecret_admin/testing.py | 45 + .../src/sshecret_admin/types.py | 21 + .../src/sshecret_admin/view_models.py | 66 +- .../src/sshecret_admin/views/__init__.py | 5 + .../src/sshecret_admin/views/audit.py | 113 + .../src/sshecret_admin/views/clients.py | 229 + .../src/sshecret_admin/views/secrets.py | 178 + packages/sshecret-admin/static/index.html | 24 + packages/sshecret-admin/tailwind.config.js | 93 + 70 files changed, 8058 insertions(+), 689 deletions(-) create mode 100644 packages/sshecret-admin/src/sshecret_admin/admin_backend.py delete mode 100644 packages/sshecret-admin/src/sshecret_admin/backend.py create mode 100644 packages/sshecret-admin/src/sshecret_admin/cli.py delete mode 100644 packages/sshecret-admin/src/sshecret_admin/constants.py delete mode 100644 packages/sshecret-admin/src/sshecret_admin/crypto.py create mode 100644 packages/sshecret-admin/src/sshecret_admin/db.py create mode 100644 packages/sshecret-admin/src/sshecret_admin/frontend.py create mode 100644 packages/sshecret-admin/src/sshecret_admin/main.py create mode 100644 packages/sshecret-admin/src/sshecret_admin/py.typed create mode 100644 packages/sshecret-admin/src/sshecret_admin/static/css/input.css create mode 100644 packages/sshecret-admin/src/sshecret_admin/static/css/main.css create mode 100644 packages/sshecret-admin/src/sshecret_admin/static/css/prism.css create mode 100644 packages/sshecret-admin/src/sshecret_admin/static/index.html create mode 100644 packages/sshecret-admin/src/sshecret_admin/static/js/prism.js create mode 100644 packages/sshecret-admin/src/sshecret_admin/static/js/sidebar.js create mode 100644 packages/sshecret-admin/src/sshecret_admin/static/logo.svg create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/audit/entry.html.j2 create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/audit/index.html.j2 create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/audit/inner.html.j2 create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/audit/inner_save.html.j2 create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/audit/pagination.html.j2 create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/clients/client.html.j2 create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/clients/drawer_client_create.html.j2 create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/clients/drawer_client_delete.html.j2 create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/clients/drawer_client_update.html.j2 create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/clients/dynamic.html.j2 create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/clients/field_invalid.html.j2 create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/clients/field_valid.html.j2 create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/clients/index.html.j2 create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/clients/inner.html.j2 create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/dashboard.html create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/dashboard/_base.html create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/dashboard/_favicons.html create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/dashboard/_header.html create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/dashboard/_scripts.html create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/dashboard/_stylesheet.html create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/dashboard/navbar.html create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/dashboard/sidebar.html create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/dashboard_old.html create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/fragments/error.html create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/fragments/ok.html create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/login.html create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/secrets/client_options.html.j2 create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/secrets/drawer_secret_create.html.j2 create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/secrets/index.html.j2 create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/secrets/inner.html.j2 create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/secrets/modal_client_secret.html.j2 create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/secrets/secret.html.j2 create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/shared/_base.html create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/shared/_dashboard.html create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/shared/_dashboard_save.html create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/success.html create mode 100644 packages/sshecret-admin/src/sshecret_admin/templates/widgets/clients.html create mode 100644 packages/sshecret-admin/src/sshecret_admin/testing.py create mode 100644 packages/sshecret-admin/src/sshecret_admin/types.py create mode 100644 packages/sshecret-admin/src/sshecret_admin/views/__init__.py create mode 100644 packages/sshecret-admin/src/sshecret_admin/views/audit.py create mode 100644 packages/sshecret-admin/src/sshecret_admin/views/clients.py create mode 100644 packages/sshecret-admin/src/sshecret_admin/views/secrets.py create mode 100644 packages/sshecret-admin/static/index.html create mode 100644 packages/sshecret-admin/tailwind.config.js diff --git a/packages/sshecret-admin/pyproject.toml b/packages/sshecret-admin/pyproject.toml index 0de01a3..73c1c3c 100644 --- a/packages/sshecret-admin/pyproject.toml +++ b/packages/sshecret-admin/pyproject.toml @@ -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", +] diff --git a/packages/sshecret-admin/src/sshecret_admin/__init__.py b/packages/sshecret-admin/src/sshecret_admin/__init__.py index 8903142..c592c47 100644 --- a/packages/sshecret-admin/src/sshecret_admin/__init__.py +++ b/packages/sshecret-admin/src/sshecret_admin/__init__.py @@ -1,5 +1 @@ """Sshecret Admin API.""" - -from .app import app - -__all__ = ["app"] diff --git a/packages/sshecret-admin/src/sshecret_admin/admin_api.py b/packages/sshecret-admin/src/sshecret_admin/admin_api.py index cc4d663..1cf9d9a 100644 --- a/packages/sshecret-admin/src/sshecret_admin/admin_api.py +++ b/packages/sshecret-admin/src/sshecret_admin/admin_api.py @@ -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,62 +53,9 @@ def authenticate_user(session: Session, username: str, password: str) -> User | return user -async def get_current_user( - token: Annotated[str, Depends(oauth2_scheme)], - session: Annotated[Session, Depends(get_session)], -) -> User: - """Get current user from token.""" - credentials_exception = HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) - try: - payload = jwt.decode(token, settings.secret_key, algorithms=[JWT_ALGORITHM]) - username = payload.get("sub") - if not username: - raise credentials_exception - token_data = TokenData(username=username) - except jwt.InvalidTokenError: - raise credentials_exception - - user = session.exec( - select(User).where(User.username == token_data.username) - ).first() - if not user: - raise credentials_exception - return 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 - - -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]]: +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) @@ -172,246 +65,220 @@ async def map_secrets_to_clients(backend: BackendClient) -> defaultdict[str, lis return client_secret_map -@asynccontextmanager -async def lifespan(_app: FastAPI): - """Create lifespan context for the app.""" - init_db() - setup_password_manager() - yield +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 -api = APIRouter( - prefix=f"/api/{API_VERSION}", - lifespan=lifespan, -) - - -@api.post("/token") -async def login_for_access_token( - session: Annotated[Session, Depends(get_session)], - form_data: Annotated[OAuth2PasswordRequestForm, Depends()], -) -> Token: - """Login user and generate token.""" - user = authenticate_user(session, form_data.username, form_data.password) - if not user: - raise HTTPException( + async def get_current_user( + token: Annotated[str, Depends(oauth2_scheme)], + session: Annotated[Session, Depends(get_db_session)], + ) -> User: + """Get current user from token.""" + credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect username or password", + detail="Could not validate credentials", headers={"WWW-Authenticate": "Bearer"}, ) - access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) - access_token = create_access_token( - data={"sub": user.username}, expires_delta=access_token_expires + try: + payload = jwt.decode(token, settings.secret_key, algorithms=[JWT_ALGORITHM]) + username = payload.get("sub") + if not username: + raise credentials_exception + token_data = TokenData(username=username) + except jwt.InvalidTokenError: + raise credentials_exception + + user = session.exec( + select(User).where(User.username == token_data.username) + ).first() + if not user: + raise credentials_exception + return 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)] ) - 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." + @app.post("/token") + async def login_for_access_token( + session: Annotated[Session, Depends(get_db_session)], + form_data: Annotated[OAuth2PasswordRequestForm, Depends()], + ) -> Token: + """Login user and generate token.""" + user = authenticate_user(session, form_data.username, form_data.password) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + settings, + data={"sub": user.username}, + expires_delta=access_token_expires, ) - clients = client_secret_map[secret] - return SecretView(name=name, secret=secret, clients=clients) + return Token(access_token=access_token, token_type="bearer") + @app.get("/clients/") + async def get_clients( + admin: Annotated[AdminBackend, Depends(get_admin_backend)] + ) -> list[Client]: + """Get clients.""" + clients = await admin.get_clients() + return 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]: - """Get clients.""" - LOG.info("User %s requested get_clients", current_user.username) - clients = await backend.get_clients() - return clients - - -@api.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: - """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) - 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", + @app.post("/clients/") + async def create_client( + new_client: ClientCreate, + admin: Annotated[AdminBackend, Depends(get_admin_backend)], + ) -> Client: + """Create a new client.""" + sources: list[str] | None = None + if new_client.sources: + sources = [str(source) for source in new_client.sources] + client = await admin.create_client( + new_client.name, new_client.public_key, sources ) - return client + return client + @app.delete("/clients/{name}") + async def delete_client( + name: str, admin: Annotated[AdminBackend, Depends(get_admin_backend)] + ) -> None: + """Delete a client.""" + await admin.delete_client(name) -@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: - """Delete a client.""" - LOG.info("User %s requested delete_client %s", current_user.username, name) - await backend.delete_client(name) + @app.delete("/clients/{name}/secrets/{secret_name}") + async def delete_secret_from_client( + name: str, + secret_name: str, + admin: Annotated[AdminBackend, Depends(get_admin_backend)], + ) -> None: + """Delete a secret from a client.""" + client = await admin.get_client(name) + if not client: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" + ) + if secret_name not in client.secrets: + LOG.debug("Client does not have requested secret. No action to perform.") + return None -@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. + await admin.delete_client_secret(name, secret_name) - 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." - ) + @app.put("/clients/{name}/policies") + async def update_client_policies( + name: str, + updated: UpdatePoliciesRequest, + admin: Annotated[AdminBackend, Depends(get_admin_backend)], + ) -> Client: + """Update the client access policies.""" + client = await admin.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: + LOG.debug("Old policies: %r. New: %r", client.policies, updated.sources) + + addresses: list[str] = [str(source) for source in updated.sources] + 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, - 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( - 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: - """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) - if not client: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" + public_key=updated.public_key, updated_secrets=updated_secrets ) - if secret_name not in client.secrets: - LOG.debug("Client does not have requested secret. No action to perform.") - return None + @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) - await backend.delete_client_secret(name, secret_name) - - -@api.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: - """Update the client access policies.""" - LOG.info("User %s requested update_client_policies.", current_user.username) - client = await backend.get_client(name) - if not client: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" - ) - - 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) - - assert client is not None, "Critical: The client disappeared after update!" - - return client + return app diff --git a/packages/sshecret-admin/src/sshecret_admin/admin_backend.py b/packages/sshecret-admin/src/sshecret_admin/admin_backend.py new file mode 100644 index 0000000..7a7c5cc --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/admin_backend.py @@ -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() diff --git a/packages/sshecret-admin/src/sshecret_admin/app.py b/packages/sshecret-admin/src/sshecret_admin/app.py index a02a624..79cbfc3 100644 --- a/packages/sshecret-admin/src/sshecret_admin/app.py +++ b/packages/sshecret-admin/src/sshecret_admin/app.py @@ -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): - return JSONResponse( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - content=jsonable_encoder({"detail": exc.errors(), "body": exc.body}), - ) +def create_admin_app( + settings: AdminServerSettings, with_frontend: bool = True +) -> FastAPI: + """Create admin app.""" + engine, get_db_session = setup_database(settings.admin_db) -app.include_router(api) + 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.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 diff --git a/packages/sshecret-admin/src/sshecret_admin/auth_models.py b/packages/sshecret-admin/src/sshecret_admin/auth_models.py index d55b934..87f861b 100644 --- a/packages/sshecret-admin/src/sshecret_admin/auth_models.py +++ b/packages/sshecret-admin/src/sshecret_admin/auth_models.py @@ -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) diff --git a/packages/sshecret-admin/src/sshecret_admin/backend.py b/packages/sshecret-admin/src/sshecret_admin/backend.py deleted file mode 100644 index 201f684..0000000 --- a/packages/sshecret-admin/src/sshecret_admin/backend.py +++ /dev/null @@ -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() diff --git a/packages/sshecret-admin/src/sshecret_admin/cli.py b/packages/sshecret-admin/src/sshecret_admin/cli.py new file mode 100644 index 0000000..b45bcc4 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/cli.py @@ -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!") diff --git a/packages/sshecret-admin/src/sshecret_admin/constants.py b/packages/sshecret-admin/src/sshecret_admin/constants.py deleted file mode 100644 index 5b9dd20..0000000 --- a/packages/sshecret-admin/src/sshecret_admin/constants.py +++ /dev/null @@ -1,2 +0,0 @@ -RSA_PUBLIC_EXPONENT = 65537 -RSA_KEY_SIZE = 2048 diff --git a/packages/sshecret-admin/src/sshecret_admin/crypto.py b/packages/sshecret-admin/src/sshecret_admin/crypto.py deleted file mode 100644 index bc7f48b..0000000 --- a/packages/sshecret-admin/src/sshecret_admin/crypto.py +++ /dev/null @@ -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() diff --git a/packages/sshecret-admin/src/sshecret_admin/db.py b/packages/sshecret-admin/src/sshecret_admin/db.py new file mode 100644 index 0000000..7a74b0d --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/db.py @@ -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 diff --git a/packages/sshecret-admin/src/sshecret_admin/frontend.py b/packages/sshecret-admin/src/sshecret_admin/frontend.py new file mode 100644 index 0000000..40296aa --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/frontend.py @@ -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 diff --git a/packages/sshecret-admin/src/sshecret_admin/keepass.py b/packages/sshecret-admin/src/sshecret_admin/keepass.py index 860a186..a60ca15 100644 --- a/packages/sshecret-admin/src/sshecret_admin/keepass.py +++ b/packages/sshecret-admin/src/sshecret_admin/keepass.py @@ -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) diff --git a/packages/sshecret-admin/src/sshecret_admin/main.py b/packages/sshecret-admin/src/sshecret_admin/main.py new file mode 100644 index 0000000..c610df4 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/main.py @@ -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) diff --git a/packages/sshecret-admin/src/sshecret_admin/master_password.py b/packages/sshecret-admin/src/sshecret_admin/master_password.py index de10bfc..68f3e41 100644 --- a/packages/sshecret-admin/src/sshecret_admin/master_password.py +++ b/packages/sshecret-admin/src/sshecret_admin/master_password.py @@ -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. diff --git a/packages/sshecret-admin/src/sshecret_admin/py.typed b/packages/sshecret-admin/src/sshecret_admin/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/sshecret-admin/src/sshecret_admin/settings.py b/packages/sshecret-admin/src/sshecret_admin/settings.py index 7039f3a..571288f 100644 --- a/packages/sshecret-admin/src/sshecret_admin/settings.py +++ b/packages/sshecret-admin/src/sshecret_admin/settings.py @@ -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 diff --git a/packages/sshecret-admin/src/sshecret_admin/static/css/input.css b/packages/sshecret-admin/src/sshecret_admin/static/css/input.css new file mode 100644 index 0000000..4bca8b2 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/static/css/input.css @@ -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"; +} diff --git a/packages/sshecret-admin/src/sshecret_admin/static/css/main.css b/packages/sshecret-admin/src/sshecret_admin/static/css/main.css new file mode 100644 index 0000000..37c870a --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/static/css/main.css @@ -0,0 +1,3935 @@ +/*! tailwindcss v4.1.4 | MIT License | https://tailwindcss.com */ +@layer properties; +@layer theme, base, components, utilities; +@layer theme { + :root, :host { + --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-mono: "ui-monospace", "SFMono-Regular", "Menlo", "Monaco", + "Consolas", "Liberation Mono", "Courier New", "monospace"; + --color-red-50: oklch(97.1% 0.013 17.38); + --color-red-100: oklch(93.6% 0.032 17.717); + --color-red-200: oklch(88.5% 0.062 18.334); + --color-red-300: oklch(80.8% 0.114 19.571); + --color-red-400: oklch(70.4% 0.191 22.216); + --color-red-500: oklch(63.7% 0.237 25.331); + --color-red-600: oklch(57.7% 0.245 27.325); + --color-red-700: oklch(50.5% 0.213 27.518); + --color-red-800: oklch(44.4% 0.177 26.899); + --color-red-900: oklch(39.6% 0.141 25.723); + --color-orange-100: oklch(95.4% 0.038 75.164); + --color-orange-300: oklch(83.7% 0.128 66.29); + --color-orange-400: oklch(75% 0.183 55.934); + --color-orange-800: oklch(47% 0.157 37.304); + --color-lime-400: oklch(84.1% 0.238 128.85); + --color-lime-500: oklch(76.8% 0.233 130.85); + --color-green-100: oklch(96.2% 0.044 156.743); + --color-green-200: oklch(92.5% 0.084 155.995); + --color-green-400: oklch(79.2% 0.209 151.711); + --color-green-500: oklch(72.3% 0.219 149.579); + --color-green-600: oklch(62.7% 0.194 149.214); + --color-green-800: oklch(44.8% 0.119 151.328); + --color-emerald-500: oklch(69.6% 0.17 162.48); + --color-teal-100: oklch(95.3% 0.051 180.801); + --color-teal-300: oklch(85.5% 0.138 181.071); + --color-teal-500: oklch(70.4% 0.14 182.503); + --color-teal-600: oklch(60% 0.118 184.704); + --color-teal-900: oklch(38.6% 0.063 188.416); + --color-blue-200: oklch(88.2% 0.059 254.128); + --color-blue-300: oklch(80.9% 0.105 251.813); + --color-blue-500: oklch(62.3% 0.214 259.815); + --color-blue-600: oklch(54.6% 0.245 262.881); + --color-blue-700: oklch(48.8% 0.243 264.376); + --color-blue-800: oklch(42.4% 0.199 265.638); + --color-indigo-500: oklch(58.5% 0.233 277.117); + --color-indigo-600: oklch(51.1% 0.262 276.966); + --color-indigo-700: oklch(45.7% 0.24 277.023); + --color-purple-100: oklch(94.6% 0.033 307.174); + --color-purple-400: oklch(71.4% 0.203 305.504); + --color-purple-500: oklch(62.7% 0.265 303.9); + --color-purple-600: oklch(55.8% 0.288 302.321); + --color-purple-800: oklch(43.8% 0.218 303.724); + --color-pink-200: oklch(89.9% 0.061 343.231); + --color-pink-500: oklch(65.6% 0.241 354.308); + --color-rose-500: oklch(64.5% 0.246 16.439); + --color-slate-50: oklch(98.4% 0.003 247.858); + --color-slate-200: oklch(92.9% 0.013 255.508); + --color-slate-400: oklch(70.4% 0.04 256.788); + --color-slate-500: oklch(55.4% 0.046 257.417); + --color-slate-600: oklch(44.6% 0.043 257.281); + --color-slate-800: oklch(27.9% 0.041 260.031); + --color-gray-50: oklch(98.5% 0.002 247.839); + --color-gray-100: oklch(96.7% 0.003 264.542); + --color-gray-200: oklch(92.8% 0.006 264.531); + --color-gray-300: oklch(87.2% 0.01 258.338); + --color-gray-400: oklch(70.7% 0.022 261.325); + --color-gray-500: oklch(55.1% 0.027 264.364); + --color-gray-600: oklch(44.6% 0.03 256.802); + --color-gray-700: oklch(37.3% 0.034 259.733); + --color-gray-800: oklch(27.8% 0.033 256.848); + --color-gray-900: oklch(21% 0.034 264.665); + --color-gray-950: oklch(13% 0.028 261.692); + --color-black: #000; + --color-white: #fff; + --spacing: 0.25rem; + --breakpoint-xl: 80rem; + --breakpoint-2xl: 96rem; + --container-xs: 20rem; + --container-sm: 24rem; + --container-md: 28rem; + --container-lg: 32rem; + --container-xl: 36rem; + --container-2xl: 42rem; + --container-4xl: 56rem; + --text-xs: 0.75rem; + --text-xs--line-height: calc(1 / 0.75); + --text-sm: 0.875rem; + --text-sm--line-height: calc(1.25 / 0.875); + --text-base: 1rem; + --text-base--line-height: calc(1.5 / 1); + --text-lg: 1.125rem; + --text-lg--line-height: calc(1.75 / 1.125); + --text-xl: 1.25rem; + --text-xl--line-height: calc(1.75 / 1.25); + --text-2xl: 1.5rem; + --text-2xl--line-height: calc(2 / 1.5); + --text-3xl: 1.875rem; + --text-3xl--line-height: calc(2.25 / 1.875); + --text-4xl: 2.25rem; + --text-4xl--line-height: calc(2.5 / 2.25); + --text-5xl: 3rem; + --text-5xl--line-height: 1; + --text-6xl: 3.75rem; + --text-6xl--line-height: 1; + --font-weight-light: 300; + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + --font-weight-extrabold: 800; + --tracking-tight: -0.025em; + --tracking-wide: 0.025em; + --tracking-wider: 0.05em; + --leading-tight: 1.25; + --radius-xs: 0.125rem; + --radius-sm: 0.25rem; + --radius-md: 0.375rem; + --radius-lg: 0.5rem; + --radius-xl: 0.75rem; + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + --animate-spin: spin 1s linear infinite; + --animate-ping: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite; + --default-transition-duration: 150ms; + --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + --default-font-family: var(--font-sans); + --default-mono-font-family: var(--font-mono); + --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; + } +} +@layer base { + *, ::after, ::before, ::backdrop, ::file-selector-button { + box-sizing: border-box; + margin: 0; + padding: 0; + border: 0 solid; + } + html, :host { + line-height: 1.5; + -webkit-text-size-adjust: 100%; + tab-size: 4; + font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'); + font-feature-settings: var(--default-font-feature-settings, normal); + font-variation-settings: var(--default-font-variation-settings, normal); + -webkit-tap-highlight-color: transparent; + } + hr { + height: 0; + color: inherit; + border-top-width: 1px; + } + abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; + } + h1, h2, h3, h4, h5, h6 { + font-size: inherit; + font-weight: inherit; + } + a { + color: inherit; + -webkit-text-decoration: inherit; + text-decoration: inherit; + } + b, strong { + font-weight: bolder; + } + code, kbd, samp, pre { + font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace); + font-feature-settings: var(--default-mono-font-feature-settings, normal); + font-variation-settings: var(--default-mono-font-variation-settings, normal); + font-size: 1em; + } + small { + font-size: 80%; + } + sub, sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; + } + sub { + bottom: -0.25em; + } + sup { + top: -0.5em; + } + table { + text-indent: 0; + border-color: inherit; + border-collapse: collapse; + } + :-moz-focusring { + outline: auto; + } + progress { + vertical-align: baseline; + } + summary { + display: list-item; + } + ol, ul, menu { + list-style: none; + } + img, svg, video, canvas, audio, iframe, embed, object { + display: block; + vertical-align: middle; + } + img, video { + max-width: 100%; + height: auto; + } + button, input, select, optgroup, textarea, ::file-selector-button { + font: inherit; + font-feature-settings: inherit; + font-variation-settings: inherit; + letter-spacing: inherit; + color: inherit; + border-radius: 0; + background-color: transparent; + opacity: 1; + } + :where(select:is([multiple], [size])) optgroup { + font-weight: bolder; + } + :where(select:is([multiple], [size])) optgroup option { + padding-inline-start: 20px; + } + ::file-selector-button { + margin-inline-end: 4px; + } + ::placeholder { + opacity: 1; + } + @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { + ::placeholder { + color: currentcolor; + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, currentcolor 50%, transparent); + } + } + } + textarea { + resize: vertical; + } + ::-webkit-search-decoration { + -webkit-appearance: none; + } + ::-webkit-date-and-time-value { + min-height: 1lh; + text-align: inherit; + } + ::-webkit-datetime-edit { + display: inline-flex; + } + ::-webkit-datetime-edit-fields-wrapper { + padding: 0; + } + ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { + padding-block: 0; + } + :-moz-ui-invalid { + box-shadow: none; + } + button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-button { + appearance: button; + } + ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { + height: auto; + } + [hidden]:where(:not([hidden='until-found'])) { + display: none !important; + } +} +@layer utilities { + .pointer-events-none { + pointer-events: none; + } + .invisible { + visibility: hidden; + } + .visible { + visibility: visible; + } + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; + } + .absolute { + position: absolute; + } + .fixed { + position: fixed; + } + .relative { + position: relative; + } + .static { + position: static; + } + .sticky { + position: sticky; + } + .inset-0 { + inset: calc(var(--spacing) * 0); + } + .inset-y-0 { + inset-block: calc(var(--spacing) * 0); + } + .start-0 { + inset-inline-start: calc(var(--spacing) * 0); + } + .end-2\.5 { + inset-inline-end: calc(var(--spacing) * 2.5); + } + .top-0 { + top: calc(var(--spacing) * 0); + } + .top-1 { + top: calc(var(--spacing) * 1); + } + .top-1\/2 { + top: calc(1/2 * 100%); + } + .top-2\.5 { + top: calc(var(--spacing) * 2.5); + } + .top-4 { + top: calc(var(--spacing) * 4); + } + .right-0 { + right: calc(var(--spacing) * 0); + } + .right-2\.5 { + right: calc(var(--spacing) * 2.5); + } + .bottom-0 { + bottom: calc(var(--spacing) * 0); + } + .-left-1\.5 { + left: calc(var(--spacing) * -1.5); + } + .left-0 { + left: calc(var(--spacing) * 0); + } + .left-4 { + left: calc(var(--spacing) * 4); + } + .left-7 { + left: calc(var(--spacing) * 7); + } + .z-10 { + z-index: 10; + } + .z-20 { + z-index: 20; + } + .z-30 { + z-index: 30; + } + .z-40 { + z-index: 40; + } + .z-50 { + z-index: 50; + } + .order-1 { + order: 1; + } + .col-span-2 { + grid-column: span 2 / span 2; + } + .col-span-6 { + grid-column: span 6 / span 6; + } + .col-span-full { + grid-column: 1 / -1; + } + .container { + width: 100%; + @media (width >= 40rem) { + max-width: 40rem; + } + @media (width >= 48rem) { + max-width: 48rem; + } + @media (width >= 64rem) { + max-width: 64rem; + } + @media (width >= 80rem) { + max-width: 80rem; + } + @media (width >= 96rem) { + max-width: 96rem; + } + } + .m-1 { + margin: calc(var(--spacing) * 1); + } + .m-361 { + margin: calc(var(--spacing) * 361); + } + .mx-3 { + margin-inline: calc(var(--spacing) * 3); + } + .mx-4 { + margin-inline: calc(var(--spacing) * 4); + } + .mx-auto { + margin-inline: auto; + } + .my-2 { + margin-block: calc(var(--spacing) * 2); + } + .my-4 { + margin-block: calc(var(--spacing) * 4); + } + .my-5 { + margin-block: calc(var(--spacing) * 5); + } + .my-6 { + margin-block: calc(var(--spacing) * 6); + } + .my-8 { + margin-block: calc(var(--spacing) * 8); + } + .my-10 { + margin-block: calc(var(--spacing) * 10); + } + .my-auto { + margin-block: auto; + } + .ms-2 { + margin-inline-start: calc(var(--spacing) * 2); + } + .ms-3 { + margin-inline-start: calc(var(--spacing) * 3); + } + .ms-auto { + margin-inline-start: auto; + } + .me-2 { + margin-inline-end: calc(var(--spacing) * 2); + } + .me-3 { + margin-inline-end: calc(var(--spacing) * 3); + } + .-mt-5 { + margin-top: calc(var(--spacing) * -5); + } + .mt-0 { + margin-top: calc(var(--spacing) * 0); + } + .mt-0\.5 { + margin-top: calc(var(--spacing) * 0.5); + } + .mt-1 { + margin-top: calc(var(--spacing) * 1); + } + .mt-1\.5 { + margin-top: calc(var(--spacing) * 1.5); + } + .mt-2 { + margin-top: calc(var(--spacing) * 2); + } + .mt-3 { + margin-top: calc(var(--spacing) * 3); + } + .mt-4 { + margin-top: calc(var(--spacing) * 4); + } + .mt-5 { + margin-top: calc(var(--spacing) * 5); + } + .mt-6 { + margin-top: calc(var(--spacing) * 6); + } + .mt-8 { + margin-top: calc(var(--spacing) * 8); + } + .-mr-1 { + margin-right: calc(var(--spacing) * -1); + } + .mr-1 { + margin-right: calc(var(--spacing) * 1); + } + .mr-1\.5 { + margin-right: calc(var(--spacing) * 1.5); + } + .mr-2 { + margin-right: calc(var(--spacing) * 2); + } + .mr-2\.5 { + margin-right: calc(var(--spacing) * 2.5); + } + .mr-3 { + margin-right: calc(var(--spacing) * 3); + } + .mr-4 { + margin-right: calc(var(--spacing) * 4); + } + .mr-12 { + margin-right: calc(var(--spacing) * 12); + } + .mr-14 { + margin-right: calc(var(--spacing) * 14); + } + .-mb-1 { + margin-bottom: calc(var(--spacing) * -1); + } + .mb-0 { + margin-bottom: calc(var(--spacing) * 0); + } + .mb-0\.5 { + margin-bottom: calc(var(--spacing) * 0.5); + } + .mb-1 { + margin-bottom: calc(var(--spacing) * 1); + } + .mb-1\.5 { + margin-bottom: calc(var(--spacing) * 1.5); + } + .mb-2 { + margin-bottom: calc(var(--spacing) * 2); + } + .mb-3 { + margin-bottom: calc(var(--spacing) * 3); + } + .mb-3\.5 { + margin-bottom: calc(var(--spacing) * 3.5); + } + .mb-4 { + margin-bottom: calc(var(--spacing) * 4); + } + .mb-5 { + margin-bottom: calc(var(--spacing) * 5); + } + .mb-6 { + margin-bottom: calc(var(--spacing) * 6); + } + .mb-8 { + margin-bottom: calc(var(--spacing) * 8); + } + .mb-10 { + margin-bottom: calc(var(--spacing) * 10); + } + .-ml-1 { + margin-left: calc(var(--spacing) * -1); + } + .-ml-3 { + margin-left: calc(var(--spacing) * -3); + } + .ml-1 { + margin-left: calc(var(--spacing) * 1); + } + .ml-2 { + margin-left: calc(var(--spacing) * 2); + } + .ml-3 { + margin-left: calc(var(--spacing) * 3); + } + .ml-4 { + margin-left: calc(var(--spacing) * 4); + } + .ml-6 { + margin-left: calc(var(--spacing) * 6); + } + .ml-auto { + margin-left: auto; + } + .block { + display: block; + } + .contents { + display: contents; + } + .flex { + display: flex; + } + .flow-root { + display: flow-root; + } + .grid { + display: grid; + } + .hidden { + display: none; + } + .inline { + display: inline; + } + .inline-block { + display: inline-block; + } + .inline-flex { + display: inline-flex; + } + .table { + display: table; + } + .h-1 { + height: calc(var(--spacing) * 1); + } + .h-2 { + height: calc(var(--spacing) * 2); + } + .h-2\.5 { + height: calc(var(--spacing) * 2.5); + } + .h-3 { + height: calc(var(--spacing) * 3); + } + .h-3\.5 { + height: calc(var(--spacing) * 3.5); + } + .h-4 { + height: calc(var(--spacing) * 4); + } + .h-5 { + height: calc(var(--spacing) * 5); + } + .h-6 { + height: calc(var(--spacing) * 6); + } + .h-7 { + height: calc(var(--spacing) * 7); + } + .h-8 { + height: calc(var(--spacing) * 8); + } + .h-9 { + height: calc(var(--spacing) * 9); + } + .h-10 { + height: calc(var(--spacing) * 10); + } + .h-11 { + height: calc(var(--spacing) * 11); + } + .h-16 { + height: calc(var(--spacing) * 16); + } + .h-28 { + height: calc(var(--spacing) * 28); + } + .h-32 { + height: calc(var(--spacing) * 32); + } + .h-\[36rem\] { + height: 36rem; + } + .h-\[calc\(100\%-1rem\)\] { + height: calc(100% - 1rem); + } + .h-full { + height: 100%; + } + .h-screen { + height: 100vh; + } + .max-h-64 { + max-height: calc(var(--spacing) * 64); + } + .max-h-full { + max-height: 100%; + } + .min-h-0 { + min-height: calc(var(--spacing) * 0); + } + .min-h-9 { + min-height: calc(var(--spacing) * 9); + } + .min-h-screen { + min-height: 100vh; + } + .w-1\/2 { + width: calc(1/2 * 100%); + } + .w-2 { + width: calc(var(--spacing) * 2); + } + .w-2\.5 { + width: calc(var(--spacing) * 2.5); + } + .w-3 { + width: calc(var(--spacing) * 3); + } + .w-3\.5 { + width: calc(var(--spacing) * 3.5); + } + .w-4 { + width: calc(var(--spacing) * 4); + } + .w-5 { + width: calc(var(--spacing) * 5); + } + .w-6 { + width: calc(var(--spacing) * 6); + } + .w-7 { + width: calc(var(--spacing) * 7); + } + .w-8 { + width: calc(var(--spacing) * 8); + } + .w-10 { + width: calc(var(--spacing) * 10); + } + .w-11 { + width: calc(var(--spacing) * 11); + } + .w-16 { + width: calc(var(--spacing) * 16); + } + .w-28 { + width: calc(var(--spacing) * 28); + } + .w-36 { + width: calc(var(--spacing) * 36); + } + .w-44 { + width: calc(var(--spacing) * 44); + } + .w-48 { + width: calc(var(--spacing) * 48); + } + .w-56 { + width: calc(var(--spacing) * 56); + } + .w-64 { + width: calc(var(--spacing) * 64); + } + .w-72 { + width: calc(var(--spacing) * 72); + } + .w-80 { + width: calc(var(--spacing) * 80); + } + .w-auto { + width: auto; + } + .w-full { + width: 100%; + } + .max-w-2xl { + max-width: var(--container-2xl); + } + .max-w-\[140px\] { + max-width: 140px; + } + .max-w-full { + max-width: 100%; + } + .max-w-lg { + max-width: var(--container-lg); + } + .max-w-md { + max-width: var(--container-md); + } + .max-w-screen-2xl { + max-width: var(--breakpoint-2xl); + } + .max-w-screen-xl { + max-width: var(--breakpoint-xl); + } + .max-w-sm { + max-width: var(--container-sm); + } + .max-w-xl { + max-width: var(--container-xl); + } + .max-w-xs { + max-width: var(--container-xs); + } + .min-w-0 { + min-width: calc(var(--spacing) * 0); + } + .min-w-9 { + min-width: calc(var(--spacing) * 9); + } + .min-w-\[460px\] { + min-width: 460px; + } + .min-w-\[540px\] { + min-width: 540px; + } + .min-w-full { + min-width: 100%; + } + .flex-1 { + flex: 1; + } + .flex-shrink-0 { + flex-shrink: 0; + } + .shrink-0 { + flex-shrink: 0; + } + .flex-grow { + flex-grow: 1; + } + .grow { + flex-grow: 1; + } + .table-fixed { + table-layout: fixed; + } + .border-collapse { + border-collapse: collapse; + } + .-translate-x-full { + --tw-translate-x: -100%; + translate: var(--tw-translate-x) var(--tw-translate-y); + } + .translate-x-full { + --tw-translate-x: 100%; + translate: var(--tw-translate-x) var(--tw-translate-y); + } + .-translate-y-1 { + --tw-translate-y: calc(var(--spacing) * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + .-translate-y-1\/2 { + --tw-translate-y: calc(calc(1/2 * 100%) * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + .rotate-90 { + rotate: 90deg; + } + .transform { + transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); + } + .animate-ping { + animation: var(--animate-ping); + } + .animate-spin { + animation: var(--animate-spin); + } + .cursor-pointer { + cursor: pointer; + } + .resize { + resize: both; + } + .list-disc { + list-style-type: disc; + } + .list-none { + list-style-type: none; + } + .appearance-none { + appearance: none; + } + .grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } + .grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .grid-cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + .grid-cols-6 { + grid-template-columns: repeat(6, minmax(0, 1fr)); + } + .grid-cols-7 { + grid-template-columns: repeat(7, minmax(0, 1fr)); + } + .flex-col { + flex-direction: column; + } + .flex-wrap { + flex-wrap: wrap; + } + .items-baseline { + align-items: baseline; + } + .items-center { + align-items: center; + } + .items-start { + align-items: flex-start; + } + .justify-between { + justify-content: space-between; + } + .justify-center { + justify-content: center; + } + .justify-end { + justify-content: flex-end; + } + .justify-start { + justify-content: flex-start; + } + .gap-2 { + gap: calc(var(--spacing) * 2); + } + .gap-4 { + gap: calc(var(--spacing) * 4); + } + .gap-6 { + gap: calc(var(--spacing) * 6); + } + .gap-8 { + gap: calc(var(--spacing) * 8); + } + .gap-12 { + gap: calc(var(--spacing) * 12); + } + .space-y-1 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse))); + } + } + .space-y-2 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse))); + } + } + .space-y-3 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse))); + } + } + .space-y-4 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse))); + } + } + .space-y-6 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse))); + } + } + .space-y-8 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse))); + } + } + .space-y-12 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 12) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 12) * calc(1 - var(--tw-space-y-reverse))); + } + } + .space-x-1 { + :where(& > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 1) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-x-reverse))); + } + } + .space-x-2 { + :where(& > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse))); + } + } + .space-x-3 { + :where(& > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 3) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-x-reverse))); + } + } + .space-x-4 { + :where(& > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 4) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse))); + } + } + .space-x-6 { + :where(& > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 6) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-x-reverse))); + } + } + .space-x-8 { + :where(& > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 8) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-x-reverse))); + } + } + .divide-x { + :where(& > :not(:last-child)) { + --tw-divide-x-reverse: 0; + border-inline-style: var(--tw-border-style); + border-inline-start-width: calc(1px * var(--tw-divide-x-reverse)); + border-inline-end-width: calc(1px * calc(1 - var(--tw-divide-x-reverse))); + } + } + .divide-y { + :where(& > :not(:last-child)) { + --tw-divide-y-reverse: 0; + border-bottom-style: var(--tw-border-style); + border-top-style: var(--tw-border-style); + border-top-width: calc(1px * var(--tw-divide-y-reverse)); + border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); + } + } + .divide-gray-100 { + :where(& > :not(:last-child)) { + border-color: var(--color-gray-100); + } + } + .divide-gray-200 { + :where(& > :not(:last-child)) { + border-color: var(--color-gray-200); + } + } + .self-center { + align-self: center; + } + .truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .overflow-hidden { + overflow: hidden; + } + .overflow-x-auto { + overflow-x: auto; + } + .overflow-x-hidden { + overflow-x: hidden; + } + .overflow-y-auto { + overflow-y: auto; + } + .rounded { + border-radius: 0.25rem; + } + .rounded-full { + border-radius: calc(infinity * 1px); + } + .rounded-lg { + border-radius: var(--radius-lg); + } + .rounded-md { + border-radius: var(--radius-md); + } + .rounded-sm { + border-radius: var(--radius-sm); + } + .rounded-xl { + border-radius: var(--radius-xl); + } + .rounded-xs { + border-radius: var(--radius-xs); + } + .rounded-t { + border-top-left-radius: 0.25rem; + border-top-right-radius: 0.25rem; + } + .rounded-t-lg { + border-top-left-radius: var(--radius-lg); + border-top-right-radius: var(--radius-lg); + } + .rounded-l-lg { + border-top-left-radius: var(--radius-lg); + border-bottom-left-radius: var(--radius-lg); + } + .rounded-tl-lg { + border-top-left-radius: var(--radius-lg); + } + .rounded-tl-md { + border-top-left-radius: var(--radius-md); + } + .rounded-r-lg { + border-top-right-radius: var(--radius-lg); + border-bottom-right-radius: var(--radius-lg); + } + .rounded-tr-lg { + border-top-right-radius: var(--radius-lg); + } + .rounded-tr-md { + border-top-right-radius: var(--radius-md); + } + .rounded-b { + border-bottom-right-radius: 0.25rem; + border-bottom-left-radius: 0.25rem; + } + .rounded-br-md { + border-bottom-right-radius: var(--radius-md); + } + .rounded-bl-md { + border-bottom-left-radius: var(--radius-md); + } + .border { + border-style: var(--tw-border-style); + border-width: 1px; + } + .border-0 { + border-style: var(--tw-border-style); + border-width: 0px; + } + .border-2 { + border-style: var(--tw-border-style); + border-width: 2px; + } + .border-t { + border-top-style: var(--tw-border-style); + border-top-width: 1px; + } + .border-t-0 { + border-top-style: var(--tw-border-style); + border-top-width: 0px; + } + .border-r { + border-right-style: var(--tw-border-style); + border-right-width: 1px; + } + .border-r-0 { + border-right-style: var(--tw-border-style); + border-right-width: 0px; + } + .border-b { + border-bottom-style: var(--tw-border-style); + border-bottom-width: 1px; + } + .border-b-2 { + border-bottom-style: var(--tw-border-style); + border-bottom-width: 2px; + } + .border-l { + border-left-style: var(--tw-border-style); + border-left-width: 1px; + } + .border-l-0 { + border-left-style: var(--tw-border-style); + border-left-width: 0px; + } + .border-dashed { + --tw-border-style: dashed; + border-style: dashed; + } + .border-solid { + --tw-border-style: solid; + border-style: solid; + } + .border-gray-100 { + border-color: var(--color-gray-100); + } + .border-gray-200 { + border-color: var(--color-gray-200); + } + .border-gray-300 { + border-color: var(--color-gray-300); + } + .border-gray-500 { + border-color: var(--color-gray-500); + } + .border-green-100 { + border-color: var(--color-green-100); + } + .border-orange-100 { + border-color: var(--color-orange-100); + } + .border-purple-100 { + border-color: var(--color-purple-100); + } + .border-red-100 { + border-color: var(--color-red-100); + } + .border-red-300 { + border-color: var(--color-red-300); + } + .border-red-600 { + border-color: var(--color-red-600); + } + .border-slate-200 { + border-color: var(--color-slate-200); + } + .border-slate-800 { + border-color: var(--color-slate-800); + } + .border-white { + border-color: var(--color-white); + } + .border-b-gray-50 { + border-bottom-color: var(--color-gray-50); + } + .border-b-gray-100 { + border-bottom-color: var(--color-gray-100); + } + .border-b-gray-800 { + border-bottom-color: var(--color-gray-800); + } + .border-b-transparent { + border-bottom-color: transparent; + } + .bg-\[\#f8f4f3\] { + background-color: #f8f4f3; + } + .bg-\[\#f84525\] { + background-color: #f84525; + } + .bg-black { + background-color: var(--color-black); + } + .bg-black\/50 { + background-color: color-mix(in srgb, #000 50%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-black) 50%, transparent); + } + } + .bg-blue-200 { + background-color: var(--color-blue-200); + } + .bg-blue-500 { + background-color: var(--color-blue-500); + } + .bg-blue-500\/10 { + background-color: color-mix(in srgb, oklch(62.3% 0.214 259.815) 10%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-blue-500) 10%, transparent); + } + } + .bg-blue-600 { + background-color: var(--color-blue-600); + } + .bg-emerald-500 { + background-color: var(--color-emerald-500); + } + .bg-emerald-500\/10 { + background-color: color-mix(in srgb, oklch(69.6% 0.17 162.48) 10%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-emerald-500) 10%, transparent); + } + } + .bg-gray-50 { + background-color: var(--color-gray-50); + } + .bg-gray-100 { + background-color: var(--color-gray-100); + } + .bg-gray-200 { + background-color: var(--color-gray-200); + } + .bg-gray-800 { + background-color: var(--color-gray-800); + } + .bg-gray-900 { + background-color: var(--color-gray-900); + } + .bg-gray-900\/50 { + background-color: color-mix(in srgb, oklch(21% 0.034 264.665) 50%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-gray-900) 50%, transparent); + } + } + .bg-green-100 { + background-color: var(--color-green-100); + } + .bg-green-200 { + background-color: var(--color-green-200); + } + .bg-green-400 { + background-color: var(--color-green-400); + } + .bg-indigo-600 { + background-color: var(--color-indigo-600); + } + .bg-lime-400 { + background-color: var(--color-lime-400); + } + .bg-lime-500 { + background-color: var(--color-lime-500); + } + .bg-orange-100 { + background-color: var(--color-orange-100); + } + .bg-orange-300 { + background-color: var(--color-orange-300); + } + .bg-pink-200 { + background-color: var(--color-pink-200); + } + .bg-pink-500 { + background-color: var(--color-pink-500); + } + .bg-primary-100 { + background-color: var(--color-primary-100); + } + .bg-primary-600 { + background-color: var(--color-primary-600); + } + .bg-primary-700 { + background-color: var(--color-primary-700); + } + .bg-purple-100 { + background-color: var(--color-purple-100); + } + .bg-purple-500 { + background-color: var(--color-purple-500); + } + .bg-red-50 { + background-color: var(--color-red-50); + } + .bg-red-100 { + background-color: var(--color-red-100); + } + .bg-red-200 { + background-color: var(--color-red-200); + } + .bg-red-500 { + background-color: var(--color-red-500); + } + .bg-red-600 { + background-color: var(--color-red-600); + } + .bg-red-700 { + background-color: var(--color-red-700); + } + .bg-rose-500 { + background-color: var(--color-rose-500); + } + .bg-rose-500\/10 { + background-color: color-mix(in srgb, oklch(64.5% 0.246 16.439) 10%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-rose-500) 10%, transparent); + } + } + .bg-slate-800 { + background-color: var(--color-slate-800); + } + .bg-teal-100 { + background-color: var(--color-teal-100); + } + .bg-transparent { + background-color: transparent; + } + .bg-white { + background-color: var(--color-white); + } + .bg-no-repeat { + background-repeat: no-repeat; + } + .fill-blue-600 { + fill: var(--color-blue-600); + } + .object-cover { + object-fit: cover; + } + .p-1 { + padding: calc(var(--spacing) * 1); + } + .p-1\.5 { + padding: calc(var(--spacing) * 1.5); + } + .p-2 { + padding: calc(var(--spacing) * 2); + } + .p-2\.5 { + padding: calc(var(--spacing) * 2.5); + } + .p-3 { + padding: calc(var(--spacing) * 3); + } + .p-4 { + padding: calc(var(--spacing) * 4); + } + .p-5 { + padding: calc(var(--spacing) * 5); + } + .p-6 { + padding: calc(var(--spacing) * 6); + } + .p-8 { + padding: calc(var(--spacing) * 8); + } + .px-0 { + padding-inline: calc(var(--spacing) * 0); + } + .px-2 { + padding-inline: calc(var(--spacing) * 2); + } + .px-2\.5 { + padding-inline: calc(var(--spacing) * 2.5); + } + .px-3 { + padding-inline: calc(var(--spacing) * 3); + } + .px-4 { + padding-inline: calc(var(--spacing) * 4); + } + .px-5 { + padding-inline: calc(var(--spacing) * 5); + } + .px-6 { + padding-inline: calc(var(--spacing) * 6); + } + .py-0\.5 { + padding-block: calc(var(--spacing) * 0.5); + } + .py-1 { + padding-block: calc(var(--spacing) * 1); + } + .py-1\.5 { + padding-block: calc(var(--spacing) * 1.5); + } + .py-2 { + padding-block: calc(var(--spacing) * 2); + } + .py-2\.5 { + padding-block: calc(var(--spacing) * 2.5); + } + .py-3 { + padding-block: calc(var(--spacing) * 3); + } + .py-4 { + padding-block: calc(var(--spacing) * 4); + } + .py-5 { + padding-block: calc(var(--spacing) * 5); + } + .py-6 { + padding-block: calc(var(--spacing) * 6); + } + .py-12 { + padding-block: calc(var(--spacing) * 12); + } + .ps-2\.5 { + padding-inline-start: calc(var(--spacing) * 2.5); + } + .ps-3 { + padding-inline-start: calc(var(--spacing) * 3); + } + .ps-10 { + padding-inline-start: calc(var(--spacing) * 10); + } + .pt-0 { + padding-top: calc(var(--spacing) * 0); + } + .pt-2 { + padding-top: calc(var(--spacing) * 2); + } + .pt-3 { + padding-top: calc(var(--spacing) * 3); + } + .pt-4 { + padding-top: calc(var(--spacing) * 4); + } + .pt-5 { + padding-top: calc(var(--spacing) * 5); + } + .pt-6 { + padding-top: calc(var(--spacing) * 6); + } + .pt-8 { + padding-top: calc(var(--spacing) * 8); + } + .pt-9 { + padding-top: calc(var(--spacing) * 9); + } + .pt-10 { + padding-top: calc(var(--spacing) * 10); + } + .pt-16 { + padding-top: calc(var(--spacing) * 16); + } + .pt-20 { + padding-top: calc(var(--spacing) * 20); + } + .pt-24 { + padding-top: calc(var(--spacing) * 24); + } + .pr-4 { + padding-right: calc(var(--spacing) * 4); + } + .pr-10 { + padding-right: calc(var(--spacing) * 10); + } + .pb-1 { + padding-bottom: calc(var(--spacing) * 1); + } + .pb-2 { + padding-bottom: calc(var(--spacing) * 2); + } + .pb-4 { + padding-bottom: calc(var(--spacing) * 4); + } + .pb-6 { + padding-bottom: calc(var(--spacing) * 6); + } + .pl-0 { + padding-left: calc(var(--spacing) * 0); + } + .pl-2 { + padding-left: calc(var(--spacing) * 2); + } + .pl-3 { + padding-left: calc(var(--spacing) * 3); + } + .pl-4 { + padding-left: calc(var(--spacing) * 4); + } + .pl-7 { + padding-left: calc(var(--spacing) * 7); + } + .pl-10 { + padding-left: calc(var(--spacing) * 10); + } + .pl-11 { + padding-left: calc(var(--spacing) * 11); + } + .pl-12 { + padding-left: calc(var(--spacing) * 12); + } + .text-center { + text-align: center; + } + .text-left { + text-align: left; + } + .text-right { + text-align: right; + } + .align-middle { + vertical-align: middle; + } + .align-top { + vertical-align: top; + } + .text-2xl { + font-size: var(--text-2xl); + line-height: var(--tw-leading, var(--text-2xl--line-height)); + } + .text-3xl { + font-size: var(--text-3xl); + line-height: var(--tw-leading, var(--text-3xl--line-height)); + } + .text-4xl { + font-size: var(--text-4xl); + line-height: var(--tw-leading, var(--text-4xl--line-height)); + } + .text-5xl { + font-size: var(--text-5xl); + line-height: var(--tw-leading, var(--text-5xl--line-height)); + } + .text-base { + font-size: var(--text-base); + line-height: var(--tw-leading, var(--text-base--line-height)); + } + .text-lg { + font-size: var(--text-lg); + line-height: var(--tw-leading, var(--text-lg--line-height)); + } + .text-sm { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + } + .text-xl { + font-size: var(--text-xl); + line-height: var(--tw-leading, var(--text-xl--line-height)); + } + .text-xs { + font-size: var(--text-xs); + line-height: var(--tw-leading, var(--text-xs--line-height)); + } + .text-\[11px\] { + font-size: 11px; + } + .text-\[12px\] { + font-size: 12px; + } + .text-\[13px\] { + font-size: 13px; + } + .leading-6 { + --tw-leading: calc(var(--spacing) * 6); + line-height: calc(var(--spacing) * 6); + } + .leading-9 { + --tw-leading: calc(var(--spacing) * 9); + line-height: calc(var(--spacing) * 9); + } + .leading-none { + --tw-leading: 1; + line-height: 1; + } + .leading-tight { + --tw-leading: var(--leading-tight); + line-height: var(--leading-tight); + } + .font-bold { + --tw-font-weight: var(--font-weight-bold); + font-weight: var(--font-weight-bold); + } + .font-extrabold { + --tw-font-weight: var(--font-weight-extrabold); + font-weight: var(--font-weight-extrabold); + } + .font-light { + --tw-font-weight: var(--font-weight-light); + font-weight: var(--font-weight-light); + } + .font-medium { + --tw-font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-medium); + } + .font-normal { + --tw-font-weight: var(--font-weight-normal); + font-weight: var(--font-weight-normal); + } + .font-semibold { + --tw-font-weight: var(--font-weight-semibold); + font-weight: var(--font-weight-semibold); + } + .tracking-tight { + --tw-tracking: var(--tracking-tight); + letter-spacing: var(--tracking-tight); + } + .tracking-wide { + --tw-tracking: var(--tracking-wide); + letter-spacing: var(--tracking-wide); + } + .tracking-wider { + --tw-tracking: var(--tracking-wider); + letter-spacing: var(--tracking-wider); + } + .break-words { + overflow-wrap: break-word; + } + .whitespace-nowrap { + white-space: nowrap; + } + .text-\[\#f84525\] { + color: #f84525; + } + .text-blue-500 { + color: var(--color-blue-500); + } + .text-blue-600 { + color: var(--color-blue-600); + } + .text-emerald-500 { + color: var(--color-emerald-500); + } + .text-gray-200 { + color: var(--color-gray-200); + } + .text-gray-300 { + color: var(--color-gray-300); + } + .text-gray-400 { + color: var(--color-gray-400); + } + .text-gray-500 { + color: var(--color-gray-500); + } + .text-gray-600 { + color: var(--color-gray-600); + } + .text-gray-700 { + color: var(--color-gray-700); + } + .text-gray-800 { + color: var(--color-gray-800); + } + .text-gray-900 { + color: var(--color-gray-900); + } + .text-green-400 { + color: var(--color-green-400); + } + .text-green-500 { + color: var(--color-green-500); + } + .text-green-600 { + color: var(--color-green-600); + } + .text-green-800 { + color: var(--color-green-800); + } + .text-orange-800 { + color: var(--color-orange-800); + } + .text-primary-600 { + color: var(--color-primary-600); + } + .text-primary-700 { + color: var(--color-primary-700); + } + .text-purple-600 { + color: var(--color-purple-600); + } + .text-purple-800 { + color: var(--color-purple-800); + } + .text-red-500 { + color: var(--color-red-500); + } + .text-red-600 { + color: var(--color-red-600); + } + .text-red-800 { + color: var(--color-red-800); + } + .text-rose-500 { + color: var(--color-rose-500); + } + .text-slate-500 { + color: var(--color-slate-500); + } + .text-teal-500 { + color: var(--color-teal-500); + } + .text-teal-600 { + color: var(--color-teal-600); + } + .text-white { + color: var(--color-white); + } + .uppercase { + text-transform: uppercase; + } + .italic { + font-style: italic; + } + .underline { + text-decoration-line: underline; + } + .opacity-0 { + opacity: 0%; + } + .shadow { + --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .shadow-lg { + --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .shadow-md { + --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .shadow-none { + --tw-shadow: 0 0 #0000; + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .shadow-sm { + --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .shadow-black { + --tw-shadow-color: #000; + @supports (color: color-mix(in lab, red, red)) { + --tw-shadow-color: color-mix(in oklab, var(--color-black) var(--tw-shadow-alpha), transparent); + } + } + .shadow-black\/5 { + --tw-shadow-color: color-mix(in srgb, #000 5%, transparent); + @supports (color: color-mix(in lab, red, red)) { + --tw-shadow-color: color-mix(in oklab, color-mix(in oklab, var(--color-black) 5%, transparent) var(--tw-shadow-alpha), transparent); + } + } + .outline { + outline-style: var(--tw-outline-style); + outline-width: 1px; + } + .filter { + filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); + } + .transition { + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .transition-all { + transition-property: all; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .transition-colors { + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .transition-opacity { + transition-property: opacity; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .transition-transform { + transition-property: transform, translate, scale, rotate; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .duration-75 { + --tw-duration: 75ms; + transition-duration: 75ms; + } + .duration-200 { + --tw-duration: 200ms; + transition-duration: 200ms; + } + .duration-300 { + --tw-duration: 300ms; + transition-duration: 300ms; + } + .duration-700 { + --tw-duration: 700ms; + transition-duration: 700ms; + } + .ease-in-out { + --tw-ease: var(--ease-in-out); + transition-timing-function: var(--ease-in-out); + } + .outline-none { + --tw-outline-style: none; + outline-style: none; + } + .group-hover\:text-blue-500 { + &:is(:where(.group):hover *) { + @media (hover: hover) { + color: var(--color-blue-500); + } + } + } + .group-hover\:text-gray-500 { + &:is(:where(.group):hover *) { + @media (hover: hover) { + color: var(--color-gray-500); + } + } + } + .group-hover\:text-gray-900 { + &:is(:where(.group):hover *) { + @media (hover: hover) { + color: var(--color-gray-900); + } + } + } + .group-\[\.active\]\:bg-gray-800 { + &:is(:where(.group):is(.active) *) { + background-color: var(--color-gray-800); + } + } + .group-\[\.active\]\:text-white { + &:is(:where(.group):is(.active) *) { + color: var(--color-white); + } + } + .group-\[\.selected\]\:block { + &:is(:where(.group):is(.selected) *) { + display: block; + } + } + .group-\[\.selected\]\:rotate-90 { + &:is(:where(.group):is(.selected) *) { + rotate: 90deg; + } + } + .group-\[\.selected\]\:bg-gray-950 { + &:is(:where(.group):is(.selected) *) { + background-color: var(--color-gray-950); + } + } + .group-\[\.selected\]\:text-gray-100 { + &:is(:where(.group):is(.selected) *) { + color: var(--color-gray-100); + } + } + .peer-checked\:bg-blue-600 { + &:is(:where(.peer):checked ~ *) { + background-color: var(--color-blue-600); + } + } + .peer-focus\:ring-4 { + &:is(:where(.peer):focus ~ *) { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } + .peer-focus\:ring-blue-300 { + &:is(:where(.peer):focus ~ *) { + --tw-ring-color: var(--color-blue-300); + } + } + .peer-focus\:outline-none { + &:is(:where(.peer):focus ~ *) { + --tw-outline-style: none; + outline-style: none; + } + } + .before\:mr-3 { + &::before { + content: var(--tw-content); + margin-right: calc(var(--spacing) * 3); + } + } + .before\:h-1 { + &::before { + content: var(--tw-content); + height: calc(var(--spacing) * 1); + } + } + .before\:w-1 { + &::before { + content: var(--tw-content); + width: calc(var(--spacing) * 1); + } + } + .before\:rounded-full { + &::before { + content: var(--tw-content); + border-radius: calc(infinity * 1px); + } + } + .before\:bg-gray-300 { + &::before { + content: var(--tw-content); + background-color: var(--color-gray-300); + } + } + .after\:absolute { + &::after { + content: var(--tw-content); + position: absolute; + } + } + .after\:start-\[2px\] { + &::after { + content: var(--tw-content); + inset-inline-start: 2px; + } + } + .after\:top-\[2px\] { + &::after { + content: var(--tw-content); + top: 2px; + } + } + .after\:h-5 { + &::after { + content: var(--tw-content); + height: calc(var(--spacing) * 5); + } + } + .after\:w-5 { + &::after { + content: var(--tw-content); + width: calc(var(--spacing) * 5); + } + } + .after\:rounded-full { + &::after { + content: var(--tw-content); + border-radius: calc(infinity * 1px); + } + } + .after\:border { + &::after { + content: var(--tw-content); + border-style: var(--tw-border-style); + border-width: 1px; + } + } + .after\:border-gray-300 { + &::after { + content: var(--tw-content); + border-color: var(--color-gray-300); + } + } + .after\:bg-white { + &::after { + content: var(--tw-content); + background-color: var(--color-white); + } + } + .after\:transition-all { + &::after { + content: var(--tw-content); + transition-property: all; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + } + .after\:content-\[\'\'\] { + &::after { + content: var(--tw-content); + --tw-content: ''; + content: var(--tw-content); + } + } + .peer-checked\:after\:translate-x-full { + &:is(:where(.peer):checked ~ *) { + &::after { + content: var(--tw-content); + --tw-translate-x: 100%; + translate: var(--tw-translate-x) var(--tw-translate-y); + } + } + } + .peer-checked\:after\:border-white { + &:is(:where(.peer):checked ~ *) { + &::after { + content: var(--tw-content); + border-color: var(--color-white); + } + } + } + .hover\:border-gray-300 { + &:hover { + @media (hover: hover) { + border-color: var(--color-gray-300); + } + } + } + .hover\:border-slate-400 { + &:hover { + @media (hover: hover) { + border-color: var(--color-slate-400); + } + } + } + .hover\:border-slate-600 { + &:hover { + @media (hover: hover) { + border-color: var(--color-slate-600); + } + } + } + .hover\:bg-gray-50 { + &:hover { + @media (hover: hover) { + background-color: var(--color-gray-50); + } + } + } + .hover\:bg-gray-100 { + &:hover { + @media (hover: hover) { + background-color: var(--color-gray-100); + } + } + } + .hover\:bg-gray-200 { + &:hover { + @media (hover: hover) { + background-color: var(--color-gray-200); + } + } + } + .hover\:bg-gray-950 { + &:hover { + @media (hover: hover) { + background-color: var(--color-gray-950); + } + } + } + .hover\:bg-indigo-700 { + &:hover { + @media (hover: hover) { + background-color: var(--color-indigo-700); + } + } + } + .hover\:bg-primary-100 { + &:hover { + @media (hover: hover) { + background-color: var(--color-primary-100); + } + } + } + .hover\:bg-primary-700 { + &:hover { + @media (hover: hover) { + background-color: var(--color-primary-700); + } + } + } + .hover\:bg-primary-800 { + &:hover { + @media (hover: hover) { + background-color: var(--color-primary-800); + } + } + } + .hover\:bg-red-600 { + &:hover { + @media (hover: hover) { + background-color: var(--color-red-600); + } + } + } + .hover\:bg-red-800 { + &:hover { + @media (hover: hover) { + background-color: var(--color-red-800); + } + } + } + .hover\:bg-slate-50 { + &:hover { + @media (hover: hover) { + background-color: var(--color-slate-50); + } + } + } + .hover\:bg-slate-600 { + &:hover { + @media (hover: hover) { + background-color: var(--color-slate-600); + } + } + } + .hover\:text-\[\#f84525\] { + &:hover { + @media (hover: hover) { + color: #f84525; + } + } + } + .hover\:text-blue-500 { + &:hover { + @media (hover: hover) { + color: var(--color-blue-500); + } + } + } + .hover\:text-blue-700 { + &:hover { + @media (hover: hover) { + color: var(--color-blue-700); + } + } + } + .hover\:text-gray-100 { + &:hover { + @media (hover: hover) { + color: var(--color-gray-100); + } + } + } + .hover\:text-gray-500 { + &:hover { + @media (hover: hover) { + color: var(--color-gray-500); + } + } + } + .hover\:text-gray-600 { + &:hover { + @media (hover: hover) { + color: var(--color-gray-600); + } + } + } + .hover\:text-gray-700 { + &:hover { + @media (hover: hover) { + color: var(--color-gray-700); + } + } + } + .hover\:text-gray-900 { + &:hover { + @media (hover: hover) { + color: var(--color-gray-900); + } + } + } + .hover\:text-primary-600 { + &:hover { + @media (hover: hover) { + color: var(--color-primary-600); + } + } + } + .hover\:text-primary-700 { + &:hover { + @media (hover: hover) { + color: var(--color-primary-700); + } + } + } + .hover\:text-red-800 { + &:hover { + @media (hover: hover) { + color: var(--color-red-800); + } + } + } + .hover\:text-white { + &:hover { + @media (hover: hover) { + color: var(--color-white); + } + } + } + .hover\:no-underline { + &:hover { + @media (hover: hover) { + text-decoration-line: none; + } + } + } + .hover\:underline { + &:hover { + @media (hover: hover) { + text-decoration-line: underline; + } + } + } + .focus\:z-10 { + &:focus { + z-index: 10; + } + } + .focus\:border-blue-500 { + &:focus { + border-color: var(--color-blue-500); + } + } + .focus\:border-indigo-500 { + &:focus { + border-color: var(--color-indigo-500); + } + } + .focus\:border-primary-500 { + &:focus { + border-color: var(--color-primary-500); + } + } + .focus\:border-primary-600 { + &:focus { + border-color: var(--color-primary-600); + } + } + .focus\:bg-gray-100 { + &:focus { + background-color: var(--color-gray-100); + } + } + .focus\:text-primary-700 { + &:focus { + color: var(--color-primary-700); + } + } + .focus\:ring { + &:focus { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } + .focus\:ring-0 { + &:focus { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } + .focus\:ring-2 { + &:focus { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } + .focus\:ring-3 { + &:focus { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } + .focus\:ring-4 { + &:focus { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } + .focus\:ring-blue-500 { + &:focus { + --tw-ring-color: var(--color-blue-500); + } + } + .focus\:ring-gray-50 { + &:focus { + --tw-ring-color: var(--color-gray-50); + } + } + .focus\:ring-gray-100 { + &:focus { + --tw-ring-color: var(--color-gray-100); + } + } + .focus\:ring-gray-200 { + &:focus { + --tw-ring-color: var(--color-gray-200); + } + } + .focus\:ring-gray-300 { + &:focus { + --tw-ring-color: var(--color-gray-300); + } + } + .focus\:ring-indigo-500 { + &:focus { + --tw-ring-color: var(--color-indigo-500); + } + } + .focus\:ring-primary-200 { + &:focus { + --tw-ring-color: var(--color-primary-200); + } + } + .focus\:ring-primary-300 { + &:focus { + --tw-ring-color: var(--color-primary-300); + } + } + .focus\:ring-primary-500 { + &:focus { + --tw-ring-color: var(--color-primary-500); + } + } + .focus\:ring-primary-600 { + &:focus { + --tw-ring-color: var(--color-primary-600); + } + } + .focus\:ring-red-300 { + &:focus { + --tw-ring-color: var(--color-red-300); + } + } + .focus\:outline-none { + &:focus { + --tw-outline-style: none; + outline-style: none; + } + } + .sm\:absolute { + @media (width >= 40rem) { + position: absolute; + } + } + .sm\:col-span-3 { + @media (width >= 40rem) { + grid-column: span 3 / span 3; + } + } + .sm\:mt-0 { + @media (width >= 40rem) { + margin-top: calc(var(--spacing) * 0); + } + } + .sm\:mr-1 { + @media (width >= 40rem) { + margin-right: calc(var(--spacing) * 1); + } + } + .sm\:mb-0 { + @media (width >= 40rem) { + margin-bottom: calc(var(--spacing) * 0); + } + } + .sm\:ml-6 { + @media (width >= 40rem) { + margin-left: calc(var(--spacing) * 6); + } + } + .sm\:ml-64 { + @media (width >= 40rem) { + margin-left: calc(var(--spacing) * 64); + } + } + .sm\:block { + @media (width >= 40rem) { + display: block; + } + } + .sm\:flex { + @media (width >= 40rem) { + display: flex; + } + } + .sm\:hidden { + @media (width >= 40rem) { + display: none; + } + } + .sm\:h-5 { + @media (width >= 40rem) { + height: calc(var(--spacing) * 5); + } + } + .sm\:h-7 { + @media (width >= 40rem) { + height: calc(var(--spacing) * 7); + } + } + .sm\:h-full { + @media (width >= 40rem) { + height: 100%; + } + } + .sm\:w-5 { + @media (width >= 40rem) { + width: calc(var(--spacing) * 5); + } + } + .sm\:w-64 { + @media (width >= 40rem) { + width: calc(var(--spacing) * 64); + } + } + .sm\:w-auto { + @media (width >= 40rem) { + width: auto; + } + } + .sm\:max-w-md { + @media (width >= 40rem) { + max-width: var(--container-md); + } + } + .sm\:translate-x-0 { + @media (width >= 40rem) { + --tw-translate-x: calc(var(--spacing) * 0); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + } + .sm\:justify-between { + @media (width >= 40rem) { + justify-content: space-between; + } + } + .sm\:justify-center { + @media (width >= 40rem) { + justify-content: center; + } + } + .sm\:justify-end { + @media (width >= 40rem) { + justify-content: flex-end; + } + } + .sm\:space-x-3 { + @media (width >= 40rem) { + :where(& > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 3) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-x-reverse))); + } + } + } + .sm\:space-x-4 { + @media (width >= 40rem) { + :where(& > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 4) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse))); + } + } + } + .sm\:divide-x { + @media (width >= 40rem) { + :where(& > :not(:last-child)) { + --tw-divide-x-reverse: 0; + border-inline-style: var(--tw-border-style); + border-inline-start-width: calc(1px * var(--tw-divide-x-reverse)); + border-inline-end-width: calc(1px * calc(1 - var(--tw-divide-x-reverse))); + } + } + } + .sm\:divide-gray-100 { + @media (width >= 40rem) { + :where(& > :not(:last-child)) { + border-color: var(--color-gray-100); + } + } + } + .sm\:rounded-lg { + @media (width >= 40rem) { + border-radius: var(--radius-lg); + } + } + .sm\:p-6 { + @media (width >= 40rem) { + padding: calc(var(--spacing) * 6); + } + } + .sm\:p-8 { + @media (width >= 40rem) { + padding: calc(var(--spacing) * 8); + } + } + .sm\:px-4 { + @media (width >= 40rem) { + padding-inline: calc(var(--spacing) * 4); + } + } + .sm\:py-2 { + @media (width >= 40rem) { + padding-block: calc(var(--spacing) * 2); + } + } + .sm\:py-4 { + @media (width >= 40rem) { + padding-block: calc(var(--spacing) * 4); + } + } + .sm\:pt-6 { + @media (width >= 40rem) { + padding-top: calc(var(--spacing) * 6); + } + } + .sm\:pr-3 { + @media (width >= 40rem) { + padding-right: calc(var(--spacing) * 3); + } + } + .sm\:pl-2 { + @media (width >= 40rem) { + padding-left: calc(var(--spacing) * 2); + } + } + .sm\:text-2xl { + @media (width >= 40rem) { + font-size: var(--text-2xl); + line-height: var(--tw-leading, var(--text-2xl--line-height)); + } + } + .sm\:text-3xl { + @media (width >= 40rem) { + font-size: var(--text-3xl); + line-height: var(--tw-leading, var(--text-3xl--line-height)); + } + } + .sm\:text-4xl { + @media (width >= 40rem) { + font-size: var(--text-4xl); + line-height: var(--tw-leading, var(--text-4xl--line-height)); + } + } + .sm\:text-lg { + @media (width >= 40rem) { + font-size: var(--text-lg); + line-height: var(--tw-leading, var(--text-lg--line-height)); + } + } + .sm\:text-sm { + @media (width >= 40rem) { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + } + } + .sm\:text-xl { + @media (width >= 40rem) { + font-size: var(--text-xl); + line-height: var(--tw-leading, var(--text-xl--line-height)); + } + } + .sm\:leading-none { + @media (width >= 40rem) { + --tw-leading: 1; + line-height: 1; + } + } + .sm\:tracking-tight { + @media (width >= 40rem) { + --tw-tracking: var(--tracking-tight); + letter-spacing: var(--tracking-tight); + } + } + .md\:absolute { + @media (width >= 48rem) { + position: absolute; + } + } + .md\:inset-0 { + @media (width >= 48rem) { + inset: calc(var(--spacing) * 0); + } + } + .md\:order-2 { + @media (width >= 48rem) { + order: 2; + } + } + .md\:my-12 { + @media (width >= 48rem) { + margin-block: calc(var(--spacing) * 12); + } + } + .md\:me-0 { + @media (width >= 48rem) { + margin-inline-end: calc(var(--spacing) * 0); + } + } + .md\:mt-0 { + @media (width >= 48rem) { + margin-top: calc(var(--spacing) * 0); + } + } + .md\:mr-0 { + @media (width >= 48rem) { + margin-right: calc(var(--spacing) * 0); + } + } + .md\:mr-6 { + @media (width >= 48rem) { + margin-right: calc(var(--spacing) * 6); + } + } + .md\:mr-24 { + @media (width >= 48rem) { + margin-right: calc(var(--spacing) * 24); + } + } + .md\:mb-0 { + @media (width >= 48rem) { + margin-bottom: calc(var(--spacing) * 0); + } + } + .md\:ml-2 { + @media (width >= 48rem) { + margin-left: calc(var(--spacing) * 2); + } + } + .md\:ml-64 { + @media (width >= 48rem) { + margin-left: calc(var(--spacing) * 64); + } + } + .md\:block { + @media (width >= 48rem) { + display: block; + } + } + .md\:flex { + @media (width >= 48rem) { + display: flex; + } + } + .md\:hidden { + @media (width >= 48rem) { + display: none; + } + } + .md\:h-auto { + @media (width >= 48rem) { + height: auto; + } + } + .md\:h-screen { + @media (width >= 48rem) { + height: 100vh; + } + } + .md\:w-\[calc\(100\%-256px\)\] { + @media (width >= 48rem) { + width: calc(100% - 256px); + } + } + .md\:w-auto { + @media (width >= 48rem) { + width: auto; + } + } + .md\:max-w-lg { + @media (width >= 48rem) { + max-width: var(--container-lg); + } + } + .md\:max-w-md { + @media (width >= 48rem) { + max-width: var(--container-md); + } + } + .md\:grid-cols-2 { + @media (width >= 48rem) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + .md\:items-center { + @media (width >= 48rem) { + align-items: center; + } + } + .md\:justify-between { + @media (width >= 48rem) { + justify-content: space-between; + } + } + .md\:gap-6 { + @media (width >= 48rem) { + gap: calc(var(--spacing) * 6); + } + } + .md\:space-y-0 { + @media (width >= 48rem) { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 0) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 0) * calc(1 - var(--tw-space-y-reverse))); + } + } + } + .md\:gap-x-6 { + @media (width >= 48rem) { + column-gap: calc(var(--spacing) * 6); + } + } + .md\:space-x-0 { + @media (width >= 48rem) { + :where(& > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 0) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 0) * calc(1 - var(--tw-space-x-reverse))); + } + } + } + .md\:space-x-2 { + @media (width >= 48rem) { + :where(& > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse))); + } + } + } + .md\:border-0 { + @media (width >= 48rem) { + border-style: var(--tw-border-style); + border-width: 0px; + } + } + .md\:p-0 { + @media (width >= 48rem) { + padding: calc(var(--spacing) * 0); + } + } + .md\:p-5 { + @media (width >= 48rem) { + padding: calc(var(--spacing) * 5); + } + } + .md\:p-6 { + @media (width >= 48rem) { + padding: calc(var(--spacing) * 6); + } + } + .md\:px-4 { + @media (width >= 48rem) { + padding-inline: calc(var(--spacing) * 4); + } + } + .md\:py-10 { + @media (width >= 48rem) { + padding-block: calc(var(--spacing) * 10); + } + } + .md\:pt-20 { + @media (width >= 48rem) { + padding-top: calc(var(--spacing) * 20); + } + } + .md\:pt-32 { + @media (width >= 48rem) { + padding-top: calc(var(--spacing) * 32); + } + } + .md\:text-5xl { + @media (width >= 48rem) { + font-size: var(--text-5xl); + line-height: var(--tw-leading, var(--text-5xl--line-height)); + } + } + .md\:text-base { + @media (width >= 48rem) { + font-size: var(--text-base); + line-height: var(--tw-leading, var(--text-base--line-height)); + } + } + .md\:text-lg { + @media (width >= 48rem) { + font-size: var(--text-lg); + line-height: var(--tw-leading, var(--text-lg--line-height)); + } + } + .md\:hover\:bg-transparent { + @media (width >= 48rem) { + &:hover { + @media (hover: hover) { + background-color: transparent; + } + } + } + } + .md\:hover\:text-primary-700 { + @media (width >= 48rem) { + &:hover { + @media (hover: hover) { + color: var(--color-primary-700); + } + } + } + } + .lg\:order-1 { + @media (width >= 64rem) { + order: 1; + } + } + .lg\:order-2 { + @media (width >= 64rem) { + order: 2; + } + } + .lg\:col-span-2 { + @media (width >= 64rem) { + grid-column: span 2 / span 2; + } + } + .lg\:my-12 { + @media (width >= 64rem) { + margin-block: calc(var(--spacing) * 12); + } + } + .lg\:mt-0 { + @media (width >= 64rem) { + margin-top: calc(var(--spacing) * 0); + } + } + .lg\:mt-1\.5 { + @media (width >= 64rem) { + margin-top: calc(var(--spacing) * 1.5); + } + } + .lg\:mb-0 { + @media (width >= 64rem) { + margin-bottom: calc(var(--spacing) * 0); + } + } + .lg\:mb-10 { + @media (width >= 64rem) { + margin-bottom: calc(var(--spacing) * 10); + } + } + .lg\:ml-64 { + @media (width >= 64rem) { + margin-left: calc(var(--spacing) * 64); + } + } + .lg\:block { + @media (width >= 64rem) { + display: block; + } + } + .lg\:flex { + @media (width >= 64rem) { + display: flex; + } + } + .lg\:hidden { + @media (width >= 64rem) { + display: none; + } + } + .lg\:h-6 { + @media (width >= 64rem) { + height: calc(var(--spacing) * 6); + } + } + .lg\:h-\[24rem\] { + @media (width >= 64rem) { + height: 24rem; + } + } + .lg\:max-h-\[60rem\] { + @media (width >= 64rem) { + max-height: 60rem; + } + } + .lg\:w-6 { + @media (width >= 64rem) { + width: calc(var(--spacing) * 6); + } + } + .lg\:w-64 { + @media (width >= 64rem) { + width: calc(var(--spacing) * 64); + } + } + .lg\:w-96 { + @media (width >= 64rem) { + width: calc(var(--spacing) * 96); + } + } + .lg\:w-auto { + @media (width >= 64rem) { + width: auto; + } + } + .lg\:grid-cols-2 { + @media (width >= 64rem) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + .lg\:grid-cols-3 { + @media (width >= 64rem) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } + .lg\:flex-row { + @media (width >= 64rem) { + flex-direction: row; + } + } + .lg\:justify-evenly { + @media (width >= 64rem) { + justify-content: space-evenly; + } + } + .lg\:px-0 { + @media (width >= 64rem) { + padding-inline: calc(var(--spacing) * 0); + } + } + .lg\:px-5 { + @media (width >= 64rem) { + padding-inline: calc(var(--spacing) * 5); + } + } + .lg\:py-0 { + @media (width >= 64rem) { + padding-block: calc(var(--spacing) * 0); + } + } + .lg\:pr-3 { + @media (width >= 64rem) { + padding-right: calc(var(--spacing) * 3); + } + } + .lg\:pl-3 { + @media (width >= 64rem) { + padding-left: calc(var(--spacing) * 3); + } + } + .lg\:pl-3\.5 { + @media (width >= 64rem) { + padding-left: calc(var(--spacing) * 3.5); + } + } + .lg\:text-5xl { + @media (width >= 64rem) { + font-size: var(--text-5xl); + line-height: var(--tw-leading, var(--text-5xl--line-height)); + } + } + .lg\:text-6xl { + @media (width >= 64rem) { + font-size: var(--text-6xl); + line-height: var(--tw-leading, var(--text-6xl--line-height)); + } + } + .lg\:hover\:underline { + @media (width >= 64rem) { + &:hover { + @media (hover: hover) { + text-decoration-line: underline; + } + } + } + } + .xl\:col-auto { + @media (width >= 80rem) { + grid-column: auto; + } + } + .xl\:mb-0 { + @media (width >= 80rem) { + margin-bottom: calc(var(--spacing) * 0); + } + } + .xl\:mb-2 { + @media (width >= 80rem) { + margin-bottom: calc(var(--spacing) * 2); + } + } + .xl\:mb-4 { + @media (width >= 80rem) { + margin-bottom: calc(var(--spacing) * 4); + } + } + .xl\:block { + @media (width >= 80rem) { + display: block; + } + } + .xl\:w-96 { + @media (width >= 80rem) { + width: calc(var(--spacing) * 96); + } + } + .xl\:w-full { + @media (width >= 80rem) { + width: 100%; + } + } + .xl\:max-w-4xl { + @media (width >= 80rem) { + max-width: var(--container-4xl); + } + } + .xl\:max-w-xs { + @media (width >= 80rem) { + max-width: var(--container-xs); + } + } + .xl\:grid-cols-2 { + @media (width >= 80rem) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + .xl\:grid-cols-3 { + @media (width >= 80rem) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } + .xl\:grid-cols-4 { + @media (width >= 80rem) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + } + .xl\:grid-cols-6 { + @media (width >= 80rem) { + grid-template-columns: repeat(6, minmax(0, 1fr)); + } + } + .xl\:gap-4 { + @media (width >= 80rem) { + gap: calc(var(--spacing) * 4); + } + } + .xl\:gap-24 { + @media (width >= 80rem) { + gap: calc(var(--spacing) * 24); + } + } + .xl\:space-x-0 { + @media (width >= 80rem) { + :where(& > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 0) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 0) * calc(1 - var(--tw-space-x-reverse))); + } + } + } + .xl\:space-x-8 { + @media (width >= 80rem) { + :where(& > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 8) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-x-reverse))); + } + } + } + .xl\:p-0 { + @media (width >= 80rem) { + padding: calc(var(--spacing) * 0); + } + } + .xl\:p-8 { + @media (width >= 80rem) { + padding: calc(var(--spacing) * 8); + } + } + .xl\:px-0 { + @media (width >= 80rem) { + padding-inline: calc(var(--spacing) * 0); + } + } + .xl\:py-24 { + @media (width >= 80rem) { + padding-block: calc(var(--spacing) * 24); + } + } + .\32 xl\:col-span-2 { + @media (width >= 96rem) { + grid-column: span 2 / span 2; + } + } + .\32 xl\:mb-0 { + @media (width >= 96rem) { + margin-bottom: calc(var(--spacing) * 0); + } + } + .\32 xl\:flex { + @media (width >= 96rem) { + display: flex; + } + } + .\32 xl\:max-h-fit { + @media (width >= 96rem) { + max-height: fit-content; + } + } + .\32 xl\:w-auto { + @media (width >= 96rem) { + width: auto; + } + } + .\32 xl\:grid-cols-3 { + @media (width >= 96rem) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } + .\32 xl\:space-x-4 { + @media (width >= 96rem) { + :where(& > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 4) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse))); + } + } + } + .\32 xl\:px-0 { + @media (width >= 96rem) { + padding-inline: calc(var(--spacing) * 0); + } + } + .rtl\:space-x-reverse { + &:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) { + :where(& > :not(:last-child)) { + --tw-space-x-reverse: 1; + } + } + } + .rtl\:text-right { + &:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) { + text-align: right; + } + } + .rtl\:peer-checked\:after\:-translate-x-full { + &:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) { + &:is(:where(.peer):checked ~ *) { + &::after { + content: var(--tw-content); + --tw-translate-x: -100%; + translate: var(--tw-translate-x) var(--tw-translate-y); + } + } + } + } + .dark\:divide-gray-600 { + &:where(.dark, .dark *) { + :where(& > :not(:last-child)) { + border-color: var(--color-gray-600); + } + } + } + .dark\:divide-gray-700 { + &:where(.dark, .dark *) { + :where(& > :not(:last-child)) { + border-color: var(--color-gray-700); + } + } + } + .dark\:border-gray-500 { + &:where(.dark, .dark *) { + border-color: var(--color-gray-500); + } + } + .dark\:border-gray-600 { + &:where(.dark, .dark *) { + border-color: var(--color-gray-600); + } + } + .dark\:border-gray-700 { + &:where(.dark, .dark *) { + border-color: var(--color-gray-700); + } + } + .dark\:border-gray-800 { + &:where(.dark, .dark *) { + border-color: var(--color-gray-800); + } + } + .dark\:border-green-500 { + &:where(.dark, .dark *) { + border-color: var(--color-green-500); + } + } + .dark\:border-orange-300 { + &:where(.dark, .dark *) { + border-color: var(--color-orange-300); + } + } + .dark\:border-purple-500 { + &:where(.dark, .dark *) { + border-color: var(--color-purple-500); + } + } + .dark\:border-red-400 { + &:where(.dark, .dark *) { + border-color: var(--color-red-400); + } + } + .dark\:border-red-500 { + &:where(.dark, .dark *) { + border-color: var(--color-red-500); + } + } + .dark\:border-red-800 { + &:where(.dark, .dark *) { + border-color: var(--color-red-800); + } + } + .dark\:bg-gray-600 { + &:where(.dark, .dark *) { + background-color: var(--color-gray-600); + } + } + .dark\:bg-gray-700 { + &:where(.dark, .dark *) { + background-color: var(--color-gray-700); + } + } + .dark\:bg-gray-800 { + &:where(.dark, .dark *) { + background-color: var(--color-gray-800); + } + } + .dark\:bg-gray-900 { + &:where(.dark, .dark *) { + background-color: var(--color-gray-900); + } + } + .dark\:bg-gray-900\/90 { + &:where(.dark, .dark *) { + background-color: color-mix(in srgb, oklch(21% 0.034 264.665) 90%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-gray-900) 90%, transparent); + } + } + } + .dark\:bg-orange-400 { + &:where(.dark, .dark *) { + background-color: var(--color-orange-400); + } + } + .dark\:bg-primary-500 { + &:where(.dark, .dark *) { + background-color: var(--color-primary-500); + } + } + .dark\:bg-primary-600 { + &:where(.dark, .dark *) { + background-color: var(--color-primary-600); + } + } + .dark\:bg-primary-900 { + &:where(.dark, .dark *) { + background-color: var(--color-primary-900); + } + } + .dark\:bg-red-900 { + &:where(.dark, .dark *) { + background-color: var(--color-red-900); + } + } + .dark\:bg-teal-900 { + &:where(.dark, .dark *) { + background-color: var(--color-teal-900); + } + } + .dark\:text-blue-500 { + &:where(.dark, .dark *) { + color: var(--color-blue-500); + } + } + .dark\:text-gray-50 { + &:where(.dark, .dark *) { + color: var(--color-gray-50); + } + } + .dark\:text-gray-100 { + &:where(.dark, .dark *) { + color: var(--color-gray-100); + } + } + .dark\:text-gray-200 { + &:where(.dark, .dark *) { + color: var(--color-gray-200); + } + } + .dark\:text-gray-300 { + &:where(.dark, .dark *) { + color: var(--color-gray-300); + } + } + .dark\:text-gray-400 { + &:where(.dark, .dark *) { + color: var(--color-gray-400); + } + } + .dark\:text-gray-500 { + &:where(.dark, .dark *) { + color: var(--color-gray-500); + } + } + .dark\:text-gray-600 { + &:where(.dark, .dark *) { + color: var(--color-gray-600); + } + } + .dark\:text-green-400 { + &:where(.dark, .dark *) { + color: var(--color-green-400); + } + } + .dark\:text-green-500 { + &:where(.dark, .dark *) { + color: var(--color-green-500); + } + } + .dark\:text-orange-300 { + &:where(.dark, .dark *) { + color: var(--color-orange-300); + } + } + .dark\:text-primary-300 { + &:where(.dark, .dark *) { + color: var(--color-primary-300); + } + } + .dark\:text-primary-400 { + &:where(.dark, .dark *) { + color: var(--color-primary-400); + } + } + .dark\:text-primary-500 { + &:where(.dark, .dark *) { + color: var(--color-primary-500); + } + } + .dark\:text-purple-400 { + &:where(.dark, .dark *) { + color: var(--color-purple-400); + } + } + .dark\:text-purple-500 { + &:where(.dark, .dark *) { + color: var(--color-purple-500); + } + } + .dark\:text-red-300 { + &:where(.dark, .dark *) { + color: var(--color-red-300); + } + } + .dark\:text-red-400 { + &:where(.dark, .dark *) { + color: var(--color-red-400); + } + } + .dark\:text-red-500 { + &:where(.dark, .dark *) { + color: var(--color-red-500); + } + } + .dark\:text-teal-300 { + &:where(.dark, .dark *) { + color: var(--color-teal-300); + } + } + .dark\:text-white { + &:where(.dark, .dark *) { + color: var(--color-white); + } + } + .dark\:placeholder-gray-400 { + &:where(.dark, .dark *) { + &::placeholder { + color: var(--color-gray-400); + } + } + } + .dark\:ring-offset-gray-700 { + &:where(.dark, .dark *) { + --tw-ring-offset-color: var(--color-gray-700); + } + } + .dark\:ring-offset-gray-800 { + &:where(.dark, .dark *) { + --tw-ring-offset-color: var(--color-gray-800); + } + } + .dark\:group-hover\:text-gray-400 { + &:where(.dark, .dark *) { + &:is(:where(.group):hover *) { + @media (hover: hover) { + color: var(--color-gray-400); + } + } + } + } + .dark\:group-hover\:text-white { + &:where(.dark, .dark *) { + &:is(:where(.group):hover *) { + @media (hover: hover) { + color: var(--color-white); + } + } + } + } + .dark\:peer-checked\:bg-blue-600 { + &:where(.dark, .dark *) { + &:is(:where(.peer):checked ~ *) { + background-color: var(--color-blue-600); + } + } + } + .dark\:peer-focus\:ring-blue-800 { + &:where(.dark, .dark *) { + &:is(:where(.peer):focus ~ *) { + --tw-ring-color: var(--color-blue-800); + } + } + } + .dark\:hover\:border-gray-600 { + &:where(.dark, .dark *) { + &:hover { + @media (hover: hover) { + border-color: var(--color-gray-600); + } + } + } + } + .dark\:hover\:bg-gray-600 { + &:where(.dark, .dark *) { + &:hover { + @media (hover: hover) { + background-color: var(--color-gray-600); + } + } + } + } + .dark\:hover\:bg-gray-700 { + &:where(.dark, .dark *) { + &:hover { + @media (hover: hover) { + background-color: var(--color-gray-700); + } + } + } + } + .dark\:hover\:bg-primary-700 { + &:where(.dark, .dark *) { + &:hover { + @media (hover: hover) { + background-color: var(--color-primary-700); + } + } + } + } + .dark\:hover\:bg-red-600 { + &:where(.dark, .dark *) { + &:hover { + @media (hover: hover) { + background-color: var(--color-red-600); + } + } + } + } + .dark\:hover\:text-gray-200 { + &:where(.dark, .dark *) { + &:hover { + @media (hover: hover) { + color: var(--color-gray-200); + } + } + } + } + .dark\:hover\:text-gray-300 { + &:where(.dark, .dark *) { + &:hover { + @media (hover: hover) { + color: var(--color-gray-300); + } + } + } + } + .dark\:hover\:text-primary-500 { + &:where(.dark, .dark *) { + &:hover { + @media (hover: hover) { + color: var(--color-primary-500); + } + } + } + } + .dark\:hover\:text-primary-600 { + &:where(.dark, .dark *) { + &:hover { + @media (hover: hover) { + color: var(--color-primary-600); + } + } + } + } + .dark\:hover\:text-white { + &:where(.dark, .dark *) { + &:hover { + @media (hover: hover) { + color: var(--color-white); + } + } + } + } + .dark\:hover\:underline { + &:where(.dark, .dark *) { + &:hover { + @media (hover: hover) { + text-decoration-line: underline; + } + } + } + } + .dark\:focus\:border-blue-500 { + &:where(.dark, .dark *) { + &:focus { + border-color: var(--color-blue-500); + } + } + } + .dark\:focus\:border-primary-500 { + &:where(.dark, .dark *) { + &:focus { + border-color: var(--color-primary-500); + } + } + } + .dark\:focus\:bg-gray-700 { + &:where(.dark, .dark *) { + &:focus { + background-color: var(--color-gray-700); + } + } + } + .dark\:focus\:text-white { + &:where(.dark, .dark *) { + &:focus { + color: var(--color-white); + } + } + } + .dark\:focus\:ring-blue-500 { + &:where(.dark, .dark *) { + &:focus { + --tw-ring-color: var(--color-blue-500); + } + } + } + .dark\:focus\:ring-gray-600 { + &:where(.dark, .dark *) { + &:focus { + --tw-ring-color: var(--color-gray-600); + } + } + } + .dark\:focus\:ring-gray-700 { + &:where(.dark, .dark *) { + &:focus { + --tw-ring-color: var(--color-gray-700); + } + } + } + .dark\:focus\:ring-primary-500 { + &:where(.dark, .dark *) { + &:focus { + --tw-ring-color: var(--color-primary-500); + } + } + } + .dark\:focus\:ring-primary-600 { + &:where(.dark, .dark *) { + &:focus { + --tw-ring-color: var(--color-primary-600); + } + } + } + .dark\:focus\:ring-primary-800 { + &:where(.dark, .dark *) { + &:focus { + --tw-ring-color: var(--color-primary-800); + } + } + } + .dark\:focus\:ring-primary-900 { + &:where(.dark, .dark *) { + &:focus { + --tw-ring-color: var(--color-primary-900); + } + } + } + .dark\:focus\:ring-red-800 { + &:where(.dark, .dark *) { + &:focus { + --tw-ring-color: var(--color-red-800); + } + } + } + .dark\:focus\:ring-red-900 { + &:where(.dark, .dark *) { + &:focus { + --tw-ring-color: var(--color-red-900); + } + } + } + .md\:dark\:hover\:bg-transparent { + @media (width >= 48rem) { + &:where(.dark, .dark *) { + &:hover { + @media (hover: hover) { + background-color: transparent; + } + } + } + } + } +} +@property --tw-translate-x { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-translate-y { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-translate-z { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-rotate-x { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-y { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-z { + syntax: "*"; + inherits: false; +} +@property --tw-skew-x { + syntax: "*"; + inherits: false; +} +@property --tw-skew-y { + syntax: "*"; + inherits: false; +} +@property --tw-space-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-space-x-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-divide-x-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-border-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} +@property --tw-divide-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-leading { + syntax: "*"; + inherits: false; +} +@property --tw-font-weight { + syntax: "*"; + inherits: false; +} +@property --tw-tracking { + syntax: "*"; + inherits: false; +} +@property --tw-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-inset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-ring-inset { + syntax: "*"; + inherits: false; +} +@property --tw-ring-offset-width { + syntax: ""; + inherits: false; + initial-value: 0px; +} +@property --tw-ring-offset-color { + syntax: "*"; + inherits: false; + initial-value: #fff; +} +@property --tw-ring-offset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-outline-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} +@property --tw-blur { + syntax: "*"; + inherits: false; +} +@property --tw-brightness { + syntax: "*"; + inherits: false; +} +@property --tw-contrast { + syntax: "*"; + inherits: false; +} +@property --tw-grayscale { + syntax: "*"; + inherits: false; +} +@property --tw-hue-rotate { + syntax: "*"; + inherits: false; +} +@property --tw-invert { + syntax: "*"; + inherits: false; +} +@property --tw-opacity { + syntax: "*"; + inherits: false; +} +@property --tw-saturate { + syntax: "*"; + inherits: false; +} +@property --tw-sepia { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-drop-shadow-size { + syntax: "*"; + inherits: false; +} +@property --tw-duration { + syntax: "*"; + inherits: false; +} +@property --tw-ease { + syntax: "*"; + inherits: false; +} +@property --tw-content { + syntax: "*"; + initial-value: ""; + inherits: false; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} +@keyframes ping { + 75%, 100% { + transform: scale(2); + opacity: 0; + } +} +@layer properties { + @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { + *, ::before, ::after, ::backdrop { + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-translate-z: 0; + --tw-rotate-x: initial; + --tw-rotate-y: initial; + --tw-rotate-z: initial; + --tw-skew-x: initial; + --tw-skew-y: initial; + --tw-space-y-reverse: 0; + --tw-space-x-reverse: 0; + --tw-divide-x-reverse: 0; + --tw-border-style: solid; + --tw-divide-y-reverse: 0; + --tw-leading: initial; + --tw-font-weight: initial; + --tw-tracking: initial; + --tw-shadow: 0 0 #0000; + --tw-shadow-color: initial; + --tw-shadow-alpha: 100%; + --tw-inset-shadow: 0 0 #0000; + --tw-inset-shadow-color: initial; + --tw-inset-shadow-alpha: 100%; + --tw-ring-color: initial; + --tw-ring-shadow: 0 0 #0000; + --tw-inset-ring-color: initial; + --tw-inset-ring-shadow: 0 0 #0000; + --tw-ring-inset: initial; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-offset-shadow: 0 0 #0000; + --tw-outline-style: solid; + --tw-blur: initial; + --tw-brightness: initial; + --tw-contrast: initial; + --tw-grayscale: initial; + --tw-hue-rotate: initial; + --tw-invert: initial; + --tw-opacity: initial; + --tw-saturate: initial; + --tw-sepia: initial; + --tw-drop-shadow: initial; + --tw-drop-shadow-color: initial; + --tw-drop-shadow-alpha: 100%; + --tw-drop-shadow-size: initial; + --tw-duration: initial; + --tw-ease: initial; + --tw-content: ""; + } + } +} diff --git a/packages/sshecret-admin/src/sshecret_admin/static/css/prism.css b/packages/sshecret-admin/src/sshecret_admin/static/css/prism.css new file mode 100644 index 0000000..1d703b3 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/static/css/prism.css @@ -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} diff --git a/packages/sshecret-admin/src/sshecret_admin/static/index.html b/packages/sshecret-admin/src/sshecret_admin/static/index.html new file mode 100644 index 0000000..4803059 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/static/index.html @@ -0,0 +1,23 @@ + + + + + + Static in lib + + + + + + + + +

I'm inside the package

+ + diff --git a/packages/sshecret-admin/src/sshecret_admin/static/js/prism.js b/packages/sshecret-admin/src/sshecret_admin/static/js/prism.js new file mode 100644 index 0000000..0b047a0 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/static/js/prism.js @@ -0,0 +1,6 @@ +/* PrismJS 1.30.0 +https://prismjs.com/download.html#themes=prism&languages=markup+css+json */ +var _self="undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{},Prism=function(e){var n=/(?:^|\s)lang(?:uage)?-([\w-]+)(?=\s|$)/i,t=0,r={},a={manual:e.Prism&&e.Prism.manual,disableWorkerMessageHandler:e.Prism&&e.Prism.disableWorkerMessageHandler,util:{encode:function e(n){return n instanceof i?new i(n.type,e(n.content),n.alias):Array.isArray(n)?n.map(e):n.replace(/&/g,"&").replace(/=g.reach);A+=w.value.length,w=w.next){var P=w.value;if(n.length>e.length)return;if(!(P instanceof i)){var E,S=1;if(y){if(!(E=l(b,A,e,m))||E.index>=e.length)break;var L=E.index,O=E.index+E[0].length,C=A;for(C+=w.value.length;L>=C;)C+=(w=w.next).value.length;if(A=C-=w.value.length,w.value instanceof i)continue;for(var j=w;j!==n.tail&&(Cg.reach&&(g.reach=W);var I=w.prev;if(_&&(I=u(n,I,_),A+=_.length),c(n,I,S),w=u(n,I,new i(f,p?a.tokenize(N,p):N,k,N)),M&&u(n,w,M),S>1){var T={cause:f+","+d,reach:W};o(e,n,t,w.prev,A,T),g&&T.reach>g.reach&&(g.reach=T.reach)}}}}}}function s(){var e={value:null,prev:null,next:null},n={value:null,prev:e,next:null};e.next=n,this.head=e,this.tail=n,this.length=0}function u(e,n,t){var r=n.next,a={value:t,prev:n,next:r};return n.next=a,r.prev=a,e.length++,a}function c(e,n,t){for(var r=n.next,a=0;a"+i.content+""},!e.document)return e.addEventListener?(a.disableWorkerMessageHandler||e.addEventListener("message",(function(n){var t=JSON.parse(n.data),r=t.language,i=t.code,l=t.immediateClose;e.postMessage(a.highlight(i,a.languages[r],r)),l&&e.close()}),!1),a):a;var g=a.util.currentScript();function f(){a.manual||a.highlightAll()}if(g&&(a.filename=g.src,g.hasAttribute("data-manual")&&(a.manual=!0)),!a.manual){var h=document.readyState;"loading"===h||"interactive"===h&&g&&g.defer?document.addEventListener("DOMContentLoaded",f):window.requestAnimationFrame?window.requestAnimationFrame(f):window.setTimeout(f,16)}return a}(_self);"undefined"!=typeof module&&module.exports&&(module.exports=Prism),"undefined"!=typeof global&&(global.Prism=Prism); +Prism.languages.markup={comment:{pattern://,greedy:!0},prolog:{pattern:/<\?[\s\S]+?\?>/,greedy:!0},doctype:{pattern:/"'[\]]|"[^"]*"|'[^']*')+(?:\[(?:[^<"'\]]|"[^"]*"|'[^']*'|<(?!!--)|)*\]\s*)?>/i,greedy:!0,inside:{"internal-subset":{pattern:/(^[^\[]*\[)[\s\S]+(?=\]>$)/,lookbehind:!0,greedy:!0,inside:null},string:{pattern:/"[^"]*"|'[^']*'/,greedy:!0},punctuation:/^$|[[\]]/,"doctype-tag":/^DOCTYPE/i,name:/[^\s<>'"]+/}},cdata:{pattern://i,greedy:!0},tag:{pattern:/<\/?(?!\d)[^\s>\/=$<%]+(?:\s(?:\s*[^\s>\/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+(?=[\s>]))|(?=[\s/>])))+)?\s*\/?>/,greedy:!0,inside:{tag:{pattern:/^<\/?[^\s>\/]+/,inside:{punctuation:/^<\/?/,namespace:/^[^\s>\/:]+:/}},"special-attr":[],"attr-value":{pattern:/=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+)/,inside:{punctuation:[{pattern:/^=/,alias:"attr-equals"},{pattern:/^(\s*)["']|["']$/,lookbehind:!0}]}},punctuation:/\/?>/,"attr-name":{pattern:/[^\s>\/]+/,inside:{namespace:/^[^\s>\/:]+:/}}}},entity:[{pattern:/&[\da-z]{1,8};/i,alias:"named-entity"},/&#x?[\da-f]{1,8};/i]},Prism.languages.markup.tag.inside["attr-value"].inside.entity=Prism.languages.markup.entity,Prism.languages.markup.doctype.inside["internal-subset"].inside=Prism.languages.markup,Prism.hooks.add("wrap",(function(a){"entity"===a.type&&(a.attributes.title=a.content.replace(/&/,"&"))})),Object.defineProperty(Prism.languages.markup.tag,"addInlined",{value:function(a,e){var s={};s["language-"+e]={pattern:/(^$)/i,lookbehind:!0,inside:Prism.languages[e]},s.cdata=/^$/i;var t={"included-cdata":{pattern://i,inside:s}};t["language-"+e]={pattern:/[\s\S]+/,inside:Prism.languages[e]};var n={};n[a]={pattern:RegExp("(<__[^>]*>)(?:))*\\]\\]>|(?!)".replace(/__/g,(function(){return a})),"i"),lookbehind:!0,greedy:!0,inside:t},Prism.languages.insertBefore("markup","cdata",n)}}),Object.defineProperty(Prism.languages.markup.tag,"addAttribute",{value:function(a,e){Prism.languages.markup.tag.inside["special-attr"].push({pattern:RegExp("(^|[\"'\\s])(?:"+a+")\\s*=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s'\">=]+(?=[\\s>]))","i"),lookbehind:!0,inside:{"attr-name":/^[^\s=]+/,"attr-value":{pattern:/=[\s\S]+/,inside:{value:{pattern:/(^=\s*(["']|(?!["'])))\S[\s\S]*(?=\2$)/,lookbehind:!0,alias:[e,"language-"+e],inside:Prism.languages[e]},punctuation:[{pattern:/^=/,alias:"attr-equals"},/"|'/]}}}})}}),Prism.languages.html=Prism.languages.markup,Prism.languages.mathml=Prism.languages.markup,Prism.languages.svg=Prism.languages.markup,Prism.languages.xml=Prism.languages.extend("markup",{}),Prism.languages.ssml=Prism.languages.xml,Prism.languages.atom=Prism.languages.xml,Prism.languages.rss=Prism.languages.xml; +!function(s){var e=/(?:"(?:\\(?:\r\n|[\s\S])|[^"\\\r\n])*"|'(?:\\(?:\r\n|[\s\S])|[^'\\\r\n])*')/;s.languages.css={comment:/\/\*[\s\S]*?\*\//,atrule:{pattern:RegExp("@[\\w-](?:[^;{\\s\"']|\\s+(?!\\s)|"+e.source+")*?(?:;|(?=\\s*\\{))"),inside:{rule:/^@[\w-]+/,"selector-function-argument":{pattern:/(\bselector\s*\(\s*(?![\s)]))(?:[^()\s]|\s+(?![\s)])|\((?:[^()]|\([^()]*\))*\))+(?=\s*\))/,lookbehind:!0,alias:"selector"},keyword:{pattern:/(^|[^\w-])(?:and|not|only|or)(?![\w-])/,lookbehind:!0}}},url:{pattern:RegExp("\\burl\\((?:"+e.source+"|(?:[^\\\\\r\n()\"']|\\\\[^])*)\\)","i"),greedy:!0,inside:{function:/^url/i,punctuation:/^\(|\)$/,string:{pattern:RegExp("^"+e.source+"$"),alias:"url"}}},selector:{pattern:RegExp("(^|[{}\\s])[^{}\\s](?:[^{};\"'\\s]|\\s+(?![\\s{])|"+e.source+")*(?=\\s*\\{)"),lookbehind:!0},string:{pattern:e,greedy:!0},property:{pattern:/(^|[^-\w\xA0-\uFFFF])(?!\s)[-_a-z\xA0-\uFFFF](?:(?!\s)[-\w\xA0-\uFFFF])*(?=\s*:)/i,lookbehind:!0},important:/!important\b/i,function:{pattern:/(^|[^-a-z0-9])[-a-z0-9]+(?=\()/i,lookbehind:!0},punctuation:/[(){};:,]/},s.languages.css.atrule.inside.rest=s.languages.css;var t=s.languages.markup;t&&(t.tag.addInlined("style","css"),t.tag.addAttribute("style","css"))}(Prism); +Prism.languages.json={property:{pattern:/(^|[^\\])"(?:\\.|[^\\"\r\n])*"(?=\s*:)/,lookbehind:!0,greedy:!0},string:{pattern:/(^|[^\\])"(?:\\.|[^\\"\r\n])*"(?!\s*:)/,lookbehind:!0,greedy:!0},comment:{pattern:/\/\/.*|\/\*[\s\S]*?(?:\*\/|$)/,greedy:!0},number:/-?\b\d+(?:\.\d+)?(?:e[+-]?\d+)?\b/i,punctuation:/[{}[\],]/,operator:/:/,boolean:/\b(?:false|true)\b/,null:{pattern:/\bnull\b/,alias:"keyword"}},Prism.languages.webmanifest=Prism.languages.json; diff --git a/packages/sshecret-admin/src/sshecret_admin/static/js/sidebar.js b/packages/sshecret-admin/src/sshecret_admin/static/js/sidebar.js new file mode 100644 index 0000000..df52738 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/static/js/sidebar.js @@ -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, + // ); + // }); +} diff --git a/packages/sshecret-admin/src/sshecret_admin/static/logo.svg b/packages/sshecret-admin/src/sshecret_admin/static/logo.svg new file mode 100644 index 0000000..abb095c --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/static/logo.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/audit/entry.html.j2 b/packages/sshecret-admin/src/sshecret_admin/templates/audit/entry.html.j2 new file mode 100644 index 0000000..96ce727 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/audit/entry.html.j2 @@ -0,0 +1,31 @@ + + + {{ entry.timestamp }} + + + {{ entry.subsystem }} + + +

+    {%- 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) -}}
+ + + {{ entry.message }} + + + {{ entry.origin }} + diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/audit/index.html.j2 b/packages/sshecret-admin/src/sshecret_admin/templates/audit/index.html.j2 new file mode 100644 index 0000000..7822b27 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/audit/index.html.j2 @@ -0,0 +1,61 @@ +{% extends "/dashboard/_base.html" %} {% block content %} +
+
+
+ +

Audit Log

+
+
+
+
+{% include 'audit/inner.html.j2' %} +
+{% endblock %} diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/audit/inner.html.j2 b/packages/sshecret-admin/src/sshecret_admin/templates/audit/inner.html.j2 new file mode 100644 index 0000000..9450a95 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/audit/inner.html.j2 @@ -0,0 +1,55 @@ +
+
+
+
+
+ + + + + + + + + + + + {% for entry in entries %} {% include 'audit/entry.html.j2' %} {% + endfor %} + +
+ Timestamp + + Subsystem + + Object + + Message + + Origin +
+
+
+
+
+ {% include 'audit/pagination.html.j2' %} +
diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/audit/inner_save.html.j2 b/packages/sshecret-admin/src/sshecret_admin/templates/audit/inner_save.html.j2 new file mode 100644 index 0000000..1198452 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/audit/inner_save.html.j2 @@ -0,0 +1,55 @@ +
+
+
+
+
+ + + + + + + + + + + + {% for entry in entries %} {% include 'audit/entry.html.j2' %} {% + endfor %} + +
+ ID + + Operation + + Client Name + + Message + + Origin +
+
+
+
+
+ {% include 'audit/pagination.html.j2' %} +
diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/audit/pagination.html.j2 b/packages/sshecret-admin/src/sshecret_admin/templates/audit/pagination.html.j2 new file mode 100644 index 0000000..79a9a58 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/audit/pagination.html.j2 @@ -0,0 +1,67 @@ + +
+
+ + Showing + {{page_info.first }}-{{ page_info.last}} of + {{ page_info.total }} +
+
+
+ + + {% for n in range(page_info.total_pages) %} + {% set p = n + 1 %} + {% if p == page_info.page %} + + {% else %} + + {% endif %} + {% endfor %} + +
+
diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/clients/client.html.j2 b/packages/sshecret-admin/src/sshecret_admin/templates/clients/client.html.j2 new file mode 100644 index 0000000..cbc59a8 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/clients/client.html.j2 @@ -0,0 +1,82 @@ + + + {{ client.name }} + + + {{ client.id }} + + + {{ client.description }} + + + {{ client.secrets|length }} + + + {{ client.policies|join(', ') }} + + + + + + + diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/clients/drawer_client_create.html.j2 b/packages/sshecret-admin/src/sshecret_admin/templates/clients/drawer_client_create.html.j2 new file mode 100644 index 0000000..1ccd2e7 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/clients/drawer_client_create.html.j2 @@ -0,0 +1,145 @@ + diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/clients/drawer_client_delete.html.j2 b/packages/sshecret-admin/src/sshecret_admin/templates/clients/drawer_client_delete.html.j2 new file mode 100644 index 0000000..eea7411 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/clients/drawer_client_delete.html.j2 @@ -0,0 +1,67 @@ + diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/clients/drawer_client_update.html.j2 b/packages/sshecret-admin/src/sshecret_admin/templates/clients/drawer_client_update.html.j2 new file mode 100644 index 0000000..84212b7 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/clients/drawer_client_update.html.j2 @@ -0,0 +1,173 @@ + diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/clients/dynamic.html.j2 b/packages/sshecret-admin/src/sshecret_admin/templates/clients/dynamic.html.j2 new file mode 100644 index 0000000..fba24be --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/clients/dynamic.html.j2 @@ -0,0 +1,3 @@ + diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/clients/field_invalid.html.j2 b/packages/sshecret-admin/src/sshecret_admin/templates/clients/field_invalid.html.j2 new file mode 100644 index 0000000..7eef6b6 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/clients/field_invalid.html.j2 @@ -0,0 +1 @@ +

Invalid value. {{explanation}}.

diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/clients/field_valid.html.j2 b/packages/sshecret-admin/src/sshecret_admin/templates/clients/field_valid.html.j2 new file mode 100644 index 0000000..dc1c977 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/clients/field_valid.html.j2 @@ -0,0 +1 @@ + diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/clients/index.html.j2 b/packages/sshecret-admin/src/sshecret_admin/templates/clients/index.html.j2 new file mode 100644 index 0000000..c659292 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/clients/index.html.j2 @@ -0,0 +1,45 @@ +{% extends "/dashboard/_base.html" %} {% block content %} +
+
+
+ +

Clients

+
+ +
+
+ +
+ +
+
+ +
+ +
+
+
+ {% include '/clients/inner.html.j2' %} +
+ +{% include '/clients/drawer_client_create.html.j2' %} +{% endblock %} diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/clients/inner.html.j2 b/packages/sshecret-admin/src/sshecret_admin/templates/clients/inner.html.j2 new file mode 100644 index 0000000..dfbc50b --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/clients/inner.html.j2 @@ -0,0 +1,48 @@ +
+ +
+
+
+
+ + + + + + + + + + + + + + {% for client in clients %} + {% include '/clients/client.html.j2'%} + {% endfor %} + + +
+ Name + + ID + + Description + + Number of secrets allocated + + Allowed Sources + + Actions +
+
+
+
+
+ + {% for client in clients %} + {% include '/clients/drawer_client_update.html.j2' %} + {% include '/clients/drawer_client_delete.html.j2' %} + {% endfor %} + +
diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/dashboard.html b/packages/sshecret-admin/src/sshecret_admin/templates/dashboard.html new file mode 100644 index 0000000..0a19283 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/dashboard.html @@ -0,0 +1,10 @@ +{% extends "/dashboard/_base.html" %} {% block content %} + +
+

Welcome to Sshecret

+
+ + +{% endblock %} diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/dashboard/_base.html b/packages/sshecret-admin/src/sshecret_admin/templates/dashboard/_base.html new file mode 100644 index 0000000..7ed4e24 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/dashboard/_base.html @@ -0,0 +1,24 @@ + + + + {% include '/dashboard/_header.html' %} + + + {% if not hide_elements %} + {% include '/dashboard/navbar.html' %} + {% endif %} +
+ {% if not hide_elements %} + {% include '/dashboard/sidebar.html' %} + {% endif %} + +
+
+ {% block content %} + {% endblock %} +
+
+
+ {% include '/dashboard/_scripts.html' %} + + diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/dashboard/_favicons.html b/packages/sshecret-admin/src/sshecret_admin/templates/dashboard/_favicons.html new file mode 100644 index 0000000..98c5559 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/dashboard/_favicons.html @@ -0,0 +1 @@ + diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/dashboard/_header.html b/packages/sshecret-admin/src/sshecret_admin/templates/dashboard/_header.html new file mode 100644 index 0000000..e1052ff --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/dashboard/_header.html @@ -0,0 +1,21 @@ + + + + +{{page_title}} + +{% include '/dashboard/_stylesheet.html' %} {% include +'/dashboard/_favicons.html' %} + + diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/dashboard/_scripts.html b/packages/sshecret-admin/src/sshecret_admin/templates/dashboard/_scripts.html new file mode 100644 index 0000000..892381c --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/dashboard/_scripts.html @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/dashboard/_stylesheet.html b/packages/sshecret-admin/src/sshecret_admin/templates/dashboard/_stylesheet.html new file mode 100644 index 0000000..a66e472 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/dashboard/_stylesheet.html @@ -0,0 +1,21 @@ + + + + + + + diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/dashboard/navbar.html b/packages/sshecret-admin/src/sshecret_admin/templates/dashboard/navbar.html new file mode 100644 index 0000000..8497ec0 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/dashboard/navbar.html @@ -0,0 +1,47 @@ + diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/dashboard/sidebar.html b/packages/sshecret-admin/src/sshecret_admin/templates/dashboard/sidebar.html new file mode 100644 index 0000000..5f93c05 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/dashboard/sidebar.html @@ -0,0 +1,112 @@ + diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/dashboard_old.html b/packages/sshecret-admin/src/sshecret_admin/templates/dashboard_old.html new file mode 100644 index 0000000..d103cb6 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/dashboard_old.html @@ -0,0 +1,71 @@ +{% extends "/dashboard/_base.html" %} {% block content %} + +
+
+ +
+
+ +
+ +
+
+ + + + + + + + + + {% for client in clients %} + + + + + + {% endfor %} + +
Client NameDescriptionAction
+ {{ client.name }} + {{ client.description }} + Edit +
+
+ +{% endblock %} diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/fragments/error.html b/packages/sshecret-admin/src/sshecret_admin/templates/fragments/error.html new file mode 100644 index 0000000..963ec88 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/fragments/error.html @@ -0,0 +1,3 @@ +

+ {{ message }} +

diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/fragments/ok.html b/packages/sshecret-admin/src/sshecret_admin/templates/fragments/ok.html new file mode 100644 index 0000000..f98c54b --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/fragments/ok.html @@ -0,0 +1,3 @@ +

+ {{ message }} +

diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/login.html b/packages/sshecret-admin/src/sshecret_admin/templates/login.html new file mode 100644 index 0000000..758090b --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/login.html @@ -0,0 +1,55 @@ +{% extends "/shared/_base.html" %} {% block content %} + {% if login_error %} + +
+ +
+ {% endif %} + +
+
+

Sign In

+
+
+ + +
+ +
+ + +
+ + +
+
+
+ +{% endblock %} diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/secrets/client_options.html.j2 b/packages/sshecret-admin/src/sshecret_admin/templates/secrets/client_options.html.j2 new file mode 100644 index 0000000..b4c27fe --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/secrets/client_options.html.j2 @@ -0,0 +1,3 @@ +{% for client in clients %} + +{% endfor %} diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/secrets/drawer_secret_create.html.j2 b/packages/sshecret-admin/src/sshecret_admin/templates/secrets/drawer_secret_create.html.j2 new file mode 100644 index 0000000..389ef37 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/secrets/drawer_secret_create.html.j2 @@ -0,0 +1,155 @@ + diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/secrets/index.html.j2 b/packages/sshecret-admin/src/sshecret_admin/templates/secrets/index.html.j2 new file mode 100644 index 0000000..0fff981 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/secrets/index.html.j2 @@ -0,0 +1,45 @@ +{% extends "/dashboard/_base.html" %} {% block content %} +
+
+
+ +

Secrets

+
+ +
+
+ +
+ +
+
+ +
+ +
+
+
+ {% include '/secrets/inner.html.j2' %} +
+ +{% include '/secrets/drawer_secret_create.html.j2' %} + +{% endblock %} diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/secrets/inner.html.j2 b/packages/sshecret-admin/src/sshecret_admin/templates/secrets/inner.html.j2 new file mode 100644 index 0000000..b94ff77 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/secrets/inner.html.j2 @@ -0,0 +1,59 @@ +
+ +
+
+
+
+ + + + + + + + + + + {% for secret in secrets %} + {% include '/secrets/secret.html.j2'%} + {% endfor %} + +
+ Name + + Clients associated + + Actions +
+
+
+
+
+ +
+
+ + + + + + + Showing 1-20 of 2290 +
+ +
+ +
+ +{% for secret in secrets %} + {% include '/secrets/modal_client_secret.html.j2' %} +{% endfor %} diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/secrets/modal_client_secret.html.j2 b/packages/sshecret-admin/src/sshecret_admin/templates/secrets/modal_client_secret.html.j2 new file mode 100644 index 0000000..1879999 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/secrets/modal_client_secret.html.j2 @@ -0,0 +1,119 @@ + diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/secrets/secret.html.j2 b/packages/sshecret-admin/src/sshecret_admin/templates/secrets/secret.html.j2 new file mode 100644 index 0000000..4e502f3 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/secrets/secret.html.j2 @@ -0,0 +1,71 @@ + + + {{ secret.name }} + + + {% if secret.clients %} + {% for client in secret.clients %} + + + {{ client.name }} + + {% endfor %} + {% else %} +

No clients

+ {% endif %} + + + + + + diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/shared/_base.html b/packages/sshecret-admin/src/sshecret_admin/templates/shared/_base.html new file mode 100644 index 0000000..c2aef08 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/shared/_base.html @@ -0,0 +1,25 @@ + + + + + {{ page_title }} + + + + + + + + {% block content %}{% endblock %} + + diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/shared/_dashboard.html b/packages/sshecret-admin/src/sshecret_admin/templates/shared/_dashboard.html new file mode 100644 index 0000000..90bca67 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/shared/_dashboard.html @@ -0,0 +1,95 @@ + + + + + {{ page_title }} + + + + + + + + + + + + + + + +
+ {% block content %}{% endblock %} +
+ + + diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/shared/_dashboard_save.html b/packages/sshecret-admin/src/sshecret_admin/templates/shared/_dashboard_save.html new file mode 100644 index 0000000..7f009f8 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/shared/_dashboard_save.html @@ -0,0 +1,128 @@ + + + + + {{ page_title }} + + + + + + + + + +
+ +
+ + +
+
{% block content %}{% endblock %}
+
+ + diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/success.html b/packages/sshecret-admin/src/sshecret_admin/templates/success.html new file mode 100644 index 0000000..106da3c --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/templates/success.html @@ -0,0 +1,6 @@ +{% extends "/shared/_base.html" %} {% block content %} + +

Hooray!

+

It worked!

+

Welcome, {{ user.username }}

+{% endblock %} diff --git a/packages/sshecret-admin/src/sshecret_admin/templates/widgets/clients.html b/packages/sshecret-admin/src/sshecret_admin/templates/widgets/clients.html new file mode 100644 index 0000000..e69de29 diff --git a/packages/sshecret-admin/src/sshecret_admin/testing.py b/packages/sshecret-admin/src/sshecret_admin/testing.py new file mode 100644 index 0000000..d180e1c --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/testing.py @@ -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 + diff --git a/packages/sshecret-admin/src/sshecret_admin/types.py b/packages/sshecret-admin/src/sshecret_admin/types.py new file mode 100644 index 0000000..15973fd --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/types.py @@ -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]] diff --git a/packages/sshecret-admin/src/sshecret_admin/view_models.py b/packages/sshecret-admin/src/sshecret_admin/view_models.py index 9eeff29..21e40b8 100644 --- a/packages/sshecret-admin/src/sshecret_admin/view_models.py +++ b/packages/sshecret-admin/src/sshecret_admin/view_models.py @@ -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", + } + ] + } + ) diff --git a/packages/sshecret-admin/src/sshecret_admin/views/__init__.py b/packages/sshecret-admin/src/sshecret_admin/views/__init__.py new file mode 100644 index 0000000..7b27f66 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/views/__init__.py @@ -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"] diff --git a/packages/sshecret-admin/src/sshecret_admin/views/audit.py b/packages/sshecret-admin/src/sshecret_admin/views/audit.py new file mode 100644 index 0000000..a9333de --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/views/audit.py @@ -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 diff --git a/packages/sshecret-admin/src/sshecret_admin/views/clients.py b/packages/sshecret-admin/src/sshecret_admin/views/clients.py new file mode 100644 index 0000000..431f183 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/views/clients.py @@ -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 diff --git a/packages/sshecret-admin/src/sshecret_admin/views/secrets.py b/packages/sshecret-admin/src/sshecret_admin/views/secrets.py new file mode 100644 index 0000000..4d75729 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/views/secrets.py @@ -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 diff --git a/packages/sshecret-admin/static/index.html b/packages/sshecret-admin/static/index.html new file mode 100644 index 0000000..5cdf42a --- /dev/null +++ b/packages/sshecret-admin/static/index.html @@ -0,0 +1,24 @@ + + + + + + Untitled + + + + + + + + + +

I am outside of the package

+ + diff --git a/packages/sshecret-admin/tailwind.config.js b/packages/sshecret-admin/tailwind.config.js new file mode 100644 index 0000000..09992d0 --- /dev/null +++ b/packages/sshecret-admin/tailwind.config.js @@ -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: [], +};