diff --git a/packages/sshecret-admin/src/sshecret_admin/api/endpoints/secrets.py b/packages/sshecret-admin/src/sshecret_admin/api/endpoints/secrets.py index 42aaf39..809a80f 100644 --- a/packages/sshecret-admin/src/sshecret_admin/api/endpoints/secrets.py +++ b/packages/sshecret-admin/src/sshecret_admin/api/endpoints/secrets.py @@ -10,6 +10,7 @@ from sshecret_admin.core.dependencies import AdminDependencies from sshecret_admin.services import AdminBackend from sshecret_admin.services.models import ( ClientSecretGroup, + ClientSecretGroupList, SecretCreate, SecretGroupCreate, SecretUpdate, @@ -78,7 +79,7 @@ def create_router(dependencies: AdminDependencies) -> APIRouter: async def get_secret_groups( admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], filter_regex: Annotated[str | None, Query()] = None, - ) -> list[ClientSecretGroup]: + ) -> ClientSecretGroupList: """Get secret groups.""" return await admin.get_secret_groups(filter_regex) diff --git a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/dashboard/_stylesheet.html b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/dashboard/_stylesheet.html index 6793ee7..7164315 100644 --- a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/dashboard/_stylesheet.html +++ b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/dashboard/_stylesheet.html @@ -14,6 +14,11 @@ href="{{ url_for('static', path='css/prism.css') }}" type="text/css" /> + Open user menu - + diff --git a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/client_options.html.j2 b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/client_options.html.j2 deleted file mode 100644 index b4c27fe..0000000 --- a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/client_options.html.j2 +++ /dev/null @@ -1,3 +0,0 @@ -{% for client in clients %} - -{% endfor %} diff --git a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/drawer_secret_create.html.j2 b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/drawer_secret_create.html.j2 deleted file mode 100644 index 43b9c45..0000000 --- a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/drawer_secret_create.html.j2 +++ /dev/null @@ -1,38 +0,0 @@ - diff --git a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/index.html.j2 b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/index.html.j2 index 0fff981..e721eaa 100644 --- a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/index.html.j2 +++ b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/index.html.j2 @@ -1,4 +1,36 @@ +{% macro display_entry(entry) %} + + + {{ entry.name }} + +{% endmacro %} + + +{% macro display_group(group) %} + + + {{ group.group_name }} + {% for entry in group.entries %} + {{ display_entry(entry) }} + {% endfor %} + {% for child in group.children %} + {{ display_group(child) }} + {% endfor %} + +{% endmacro %} + + {% extends "/dashboard/_base.html" %} {% block content %} +
@@ -14,7 +46,6 @@
Secrets -
@@ -22,24 +53,73 @@

Secrets

-
-
- -
- -
-
- -
+
+
+ +
+
+ + + + Ungrouped + {% for entry in groups.ungrouped %} + {{ display_entry(entry) }} + {% endfor %} + + {% for child in groups.groups %} + {{ display_group(child) }} + {% endfor %} + +
+
+ +
+
+ {% include '/secrets/partials/default_detail.html.j2' %} +
+
-
- {% include '/secrets/inner.html.j2' %} -
+ {% endblock %} diff --git a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/inner.html.j2 b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/inner.html.j2 deleted file mode 100644 index b7aea9d..0000000 --- a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/inner.html.j2 +++ /dev/null @@ -1,36 +0,0 @@ -
- -
-
-
-
- - - - - - - - - - - {% for secret in secrets %} - {% include '/secrets/secret.html.j2'%} - {% endfor %} - -
- Name - - Clients associated - - Actions -
-
-
-
-
-
- -{% for secret in secrets %} - {% include '/secrets/modal_client_secret.html.j2' %} -{% endfor %} diff --git a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/modal_client_secret.html.j2 b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/modal_client_secret.html.j2 deleted file mode 100644 index cec6a21..0000000 --- a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/modal_client_secret.html.j2 +++ /dev/null @@ -1,122 +0,0 @@ - diff --git a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/partials/client_assign.html.j2 b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/partials/client_assign.html.j2 new file mode 100644 index 0000000..f96c771 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/partials/client_assign.html.j2 @@ -0,0 +1,22 @@ +
+
+ +
+
+ + {% for client in clients %} + {{ client.name }} + {% endfor %} + +
+
+ +
diff --git a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/partials/client_assign_button.html.j2 b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/partials/client_assign_button.html.j2 new file mode 100644 index 0000000..64bc3e0 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/partials/client_assign_button.html.j2 @@ -0,0 +1,7 @@ + diff --git a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/partials/client_list_inner.html.j2 b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/partials/client_list_inner.html.j2 new file mode 100644 index 0000000..62aaf9c --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/partials/client_list_inner.html.j2 @@ -0,0 +1,19 @@ +{% for client in secret.clients %} +
  • + + {{ client }} + + +
  • +{% endfor %} diff --git a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/partials/client_secret_details.html.j2 b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/partials/client_secret_details.html.j2 new file mode 100644 index 0000000..54ce681 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/partials/client_secret_details.html.j2 @@ -0,0 +1,8 @@ +
    + +
    +
    + {% include '/secrets/partials/client_assign_button.html.j2' %} +
    diff --git a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/drawer_secret_create_inner.html.j2 b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/partials/create_secret.html.j2 similarity index 78% rename from packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/drawer_secret_create_inner.html.j2 rename to packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/partials/create_secret.html.j2 index 591ba1a..984a015 100644 --- a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/drawer_secret_create_inner.html.j2 +++ b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/partials/create_secret.html.j2 @@ -83,7 +83,6 @@
    -
    diff --git a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/partials/default_detail.html.j2 b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/partials/default_detail.html.j2 new file mode 100644 index 0000000..127e3ac --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/partials/default_detail.html.j2 @@ -0,0 +1,6 @@ +
    +

    Click an item to view details

    +
    + {% include '/secrets/partials/skeleton.html.j2' %} +
    +
    diff --git a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/partials/edit_root.html.j2 b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/partials/edit_root.html.j2 new file mode 100644 index 0000000..01f1006 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/partials/edit_root.html.j2 @@ -0,0 +1,53 @@ +
    + + + {% include '/secrets/partials/create_secret.html.j2' %} + + + + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    + +
    +
    + +
    diff --git a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/partials/group_detail.html.j2 b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/partials/group_detail.html.j2 new file mode 100644 index 0000000..58e0b57 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/partials/group_detail.html.j2 @@ -0,0 +1,127 @@ +
    +
    +

    Group {{name}}

    + {{ description }} +
    + + +
    + {% include '/secrets/partials/create_secret.html.j2' %} +
    +
    + + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    + + +
    +
    + +
    +
    + +
    +
    +
    + +
    + +
    + +
    +
    +
    +
    + + +
    +
    + +
    +
    + + Loading... +
    +
    + + + +
    diff --git a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/partials/secret_value.html.j2 b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/partials/secret_value.html.j2 new file mode 100644 index 0000000..fa02d52 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/partials/secret_value.html.j2 @@ -0,0 +1,18 @@ +
    +
    + +
    +
    +
    + +
    +
    + +
    +
    + {% if updated %} +

    Secret updated.

    + {% endif %} +
    diff --git a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/partials/skeleton.html.j2 b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/partials/skeleton.html.j2 new file mode 100644 index 0000000..fbd87a6 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/partials/skeleton.html.j2 @@ -0,0 +1,38 @@ +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + Loading... +
    diff --git a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/partials/tree_detail.html.j2 b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/partials/tree_detail.html.j2 new file mode 100644 index 0000000..e106f1a --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/partials/tree_detail.html.j2 @@ -0,0 +1,159 @@ +
    +

    {{secret.name}}

    +
    +
    + + Loading... +
    +
    + + +
    + {% include '/secrets/partials/client_secret_details.html.j2' %} +
    +
    + +
    +
    + +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    + + Loading... +
    +
    +
    + {% if groups.groups %} + +
    +
    +
    + +
    +
    + +
    +
    +
    +
    + {% endif %} + + + + + + + + + + + + {% for entry in events.results | list %} + + + + + + + + + + {% endfor %} + +
    TimestampSubsystemMessageOrigin
    +

    {{ entry.timestamp }}

    + + +
    + {{ entry.subsystem }} + + {{ entry.message }} + + {{ entry.origin }} +
    +
    +
    diff --git a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/secret.html.j2 b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/secret.html.j2 deleted file mode 100644 index 9b6ac2e..0000000 --- a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/secrets/secret.html.j2 +++ /dev/null @@ -1,73 +0,0 @@ - - {{- 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/frontend/views/secrets.py b/packages/sshecret-admin/src/sshecret_admin/frontend/views/secrets.py index 896dea6..3a52446 100644 --- a/packages/sshecret-admin/src/sshecret_admin/frontend/views/secrets.py +++ b/packages/sshecret-admin/src/sshecret_admin/frontend/views/secrets.py @@ -5,11 +5,14 @@ import logging import secrets as pysecrets from typing import Annotated, Any -from fastapi import APIRouter, Depends, Form, Request +from fastapi import APIRouter, Depends, Form, HTTPException, Request, status from pydantic import BaseModel, BeforeValidator, Field from sshecret_admin.auth import LocalUserInfo from sshecret_admin.services import AdminBackend +from sshecret_admin.services.models import SecretGroupCreate + +from sshecret.backend.models import Operation from ..dependencies import FrontendDependencies @@ -55,70 +58,356 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter: templates = dependencies.templates @app.get("/secrets/") - async def get_secrets( + async def get_secrets_tree( request: Request, - current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)], admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], + current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)], ): - """Get secrets index page.""" - secrets = await admin.get_detailed_secrets() - clients = await admin.get_clients() + groups = await admin.get_secret_groups() + LOG.info("Groups: %r", groups) return templates.TemplateResponse( request, "secrets/index.html.j2", { - "page_title": "Secrets", - "secrets": secrets, + "groups": groups, "user": current_user, + }, + ) + + @app.get("/secrets/partial/root_group") + async def get_root_group( + request: Request, + admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], + ): + """Get root group.""" + clients = await admin.get_clients() + return templates.TemplateResponse( + request, + "secrets/partials/edit_root.html.j2", + { "clients": clients, }, ) - @app.post("/secrets/") - async def add_secret( + @app.get("/secrets/partial/secret/{name}") + async def get_secret_tree_detail( request: Request, + name: str, + admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], + ): + """Get partial secret detail.""" + secret = await admin.get_secret(name) + groups = await admin.get_secret_groups() + events = await admin.get_audit_log_detailed(limit=10, secret_name=name) + + if not secret: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found" + ) + return templates.TemplateResponse( + request, + "secrets/partials/tree_detail.html.j2", + { + "secret": secret, + "groups": groups, + "events": events, + }, + ) + + @app.get("/secrets/partial/group/{name}") + async def get_group_details( + request: Request, + name: str, + admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], + ): + """Get group details partial.""" + group = await admin.get_secret_group(name) + if not group: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Group not found" + ) + + clients = await admin.get_clients() + return templates.TemplateResponse( + request, + "secrets/partials/group_detail.html.j2", + { + "name": group.group_name, + "description": group.description, + "clients": clients, + }, + ) + + @app.delete("/secrets/group/{name}") + async def delete_secret_group( + request: Request, + name: str, + admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], + ): + """Delete a secret group.""" + group = await admin.get_secret_group(name) + if not group: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Group not found" + ) + + await admin.delete_secret_group(name) + + headers = {"Hx-Refresh": "true"} + + return templates.TemplateResponse( + request, + "secrets/partials/default_detail.html.j2", + headers=headers, + ) + + @app.post("/secrets/group/") + async def create_group( + request: Request, + admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], + group: Annotated[SecretGroupCreate, Form()], + ): + """Create group.""" + + LOG.info("Creating secret group: %r", group) + await admin.add_secret_group( + group_name=group.name, + description=group.description, + parent_group=group.parent_group, + ) + headers = {"Hx-Refresh": "true"} + + return templates.TemplateResponse( + request, + "secrets/partials/default_detail.html.j2", + headers=headers, + ) + + @app.put("/secrets/partial/group/{name}/description") + async def update_group_description( + request: Request, + name: str, + admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], + description: Annotated[str, Form()], + ): + """Update group description.""" + group = await admin.get_secret_group(name) + + if not group: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Group not found" + ) + await admin.set_group_description(group_name=name, description=description) + clients = await admin.get_clients() + headers = {"Hx-Refresh": "true"} + return templates.TemplateResponse( + request, + "secrets/partials/group_detail.html.j2", + { + "name": group.group_name, + "description": group.description, + "clients": clients, + }, + headers=headers, + ) + + @app.put("/secrets/partial/secret/{name}/value") + async def update_secret_value_inline( + request: Request, + name: str, + secret_value: Annotated[str, Form()], + current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)], + admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], + ): + """Update secret value.""" + secret = await admin.get_secret(name) + if not secret: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found" + ) + + origin = "UNKNOWN" + if request.client: + origin = request.client.host + + await admin.write_audit_message( + operation=Operation.UPDATE, + message="Secret was updated via admin interface", + secret_name=name, + origin=origin, + username=current_user.display_name, + ) + + await admin.update_secret(name, secret_value) + + secret = await admin.get_secret(name) + + return templates.TemplateResponse( + request, + "secrets/partials/secret_value.html.j2", + { + "secret": secret, + "updated": True, + }, + ) + + @app.get("/secrets/partial/{name}/viewsecret") + async def view_secret_in_tree( + request: Request, + name: str, + admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], + current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)], + ): + """View secret inline partial.""" + secret = await admin.get_secret(name) + + if not secret: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found" + ) + origin = "UNKNOWN" + if request.client: + origin = request.client.host + await admin.write_audit_message( + operation=Operation.READ, + message="Secret viewed", + secret_name=name, + origin=origin, + username=current_user.display_name, + ) + + return templates.TemplateResponse( + request, + "secrets/partials/secret_value.html.j2", + { + "secret": secret, + "updated": False, + }, + ) + + @app.post("/secrets/create/group/{name}") + async def add_secret_in_group( + request: Request, + name: str, admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], secret: Annotated[CreateSecret, Form()], ): - """Add secret.""" + """Create secret in group.""" 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() + await admin.add_secret(secret.name, value, secret.clients, group=name) + + headers = {"Hx-Refresh": "true"} + new_secret = await admin.get_secret(secret.name) + groups = await admin.get_secret_groups() + events = await admin.get_audit_log_detailed(limit=10, secret_name=secret.name) + + if not new_secret: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found" + ) + return templates.TemplateResponse( request, - "secrets/inner.html.j2", + "secrets/partials/tree_detail.html.j2", { - "secrets": secrets, - "clients": clients, + "secret": new_secret, + "groups": groups, + "events": events, }, + headers=headers, ) - @app.delete("/secrets/{name}/clients/{id}") + @app.post("/secrets/create/root") + async def add_secret_in_root( + request: Request, + admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], + secret: Annotated[CreateSecret, Form()], + ): + """Create secret in the root.""" + LOG.info("secret: %s", secret.model_dump_json(indent=2)) + if secret.value: + value = secret.value + else: + value = pysecrets.token_urlsafe(32) + + await admin.add_secret(secret.name, value, secret.clients, group=None) + + headers = {"Hx-Refresh": "true"} + new_secret = await admin.get_secret(secret.name) + groups = await admin.get_secret_groups() + events = await admin.get_audit_log_detailed(limit=10, secret_name=secret.name) + + if not new_secret: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found" + ) + + return templates.TemplateResponse( + request, + "secrets/partials/tree_detail.html.j2", + { + "secret": new_secret, + "groups": groups, + "events": events, + }, + headers=headers, + ) + + @app.delete("/secrets/{name}/clients/{client_name}") async def remove_client_secret_access( request: Request, name: str, - id: str, + client_name: str, admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], ): """Remove a client's access to a secret.""" - await admin.delete_client_secret(id, name) + client = await admin.get_client(client_name) + if not client: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Client not found." + ) + + await admin.delete_client_secret(str(client.id), name) clients = await admin.get_clients() - secrets = await admin.get_detailed_secrets() - headers = {"Hx-Refresh": "true"} + secret = await admin.get_secret(name) + if not secret: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found." + ) return templates.TemplateResponse( request, - "secrets/inner.html.j2", - {"clients": clients, "secret": secrets}, - headers=headers, + "secrets/partials/client_list_inner.html.j2", + {"clients": clients, "secret": secret}, + ) + + @app.get("/secrets/{name}/clients/") + async def show_secret_client_add( + request: Request, + name: str, + admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], + ): + """Show partial to add new client to a secret.""" + clients = await admin.get_clients() + secret = await admin.get_secret(name) + if not secret: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found." + ) + + return templates.TemplateResponse( + request, + "secrets/partials/client_assign.html.j2", + { + "clients": clients, + "secret": secret, + }, ) @app.post("/secrets/{name}/clients/") @@ -130,40 +419,42 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter: ): """Add a secret to a client.""" await admin.create_client_secret(client, name) + secret = await admin.get_secret(name) + if not secret: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found." + ) clients = await admin.get_clients() - secrets = await admin.get_detailed_secrets() - headers = {"Hx-Refresh": "true"} return templates.TemplateResponse( request, - "secrets/inner.html.j2", + "secrets/partials/client_secret_details.html.j2", { + "secret": secret, "clients": clients, - "secrets": secrets, }, - headers=headers, ) - @app.delete("/secrets/{name}") - async def delete_secret( - request: Request, - name: str, - admin: Annotated[AdminBackend, Depends(dependencies.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"} + # @app.delete("/secrets/{name}") + # async def delete_secret( + # request: Request, + # name: str, + # admin: Annotated[AdminBackend, Depends(dependencies.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, - ) + # return templates.TemplateResponse( + # request, + # "secrets/inner.html.j2", + # { + # "clients": clients, + # "secrets": secrets, + # }, + # headers=headers, + # ) return app diff --git a/packages/sshecret-admin/src/sshecret_admin/services/admin_backend.py b/packages/sshecret-admin/src/sshecret_admin/services/admin_backend.py index 053e617..d1371ae 100644 --- a/packages/sshecret-admin/src/sshecret_admin/services/admin_backend.py +++ b/packages/sshecret-admin/src/sshecret_admin/services/admin_backend.py @@ -23,7 +23,13 @@ from sshecret.crypto import encrypt_string, load_public_key from .keepass import PasswordContext, load_password_manager from sshecret_admin.core.settings import AdminServerSettings -from .models import ClientSecretGroup, SecretClientMapping, SecretGroup, SecretView +from .models import ( + ClientSecretGroup, + ClientSecretGroupList, + SecretClientMapping, + SecretGroup, + SecretView, +) class ClientManagementError(Exception): @@ -353,7 +359,7 @@ class AdminBackend: self, group_filter: str | None = None, regex: bool = True, - ) -> list[ClientSecretGroup]: + ) -> ClientSecretGroupList: """Get secret groups. The starting group can be filtered with the group_name argument, which @@ -363,19 +369,33 @@ class AdminBackend: secrets_mapping = {secret.name: secret for secret in all_secrets} with self.password_manager() as password_manager: all_groups = password_manager.get_secret_groups(group_filter, regex=regex) + ungrouped = password_manager.get_ungrouped_secrets() - result: list[ClientSecretGroup] = [] + group_result: list[ClientSecretGroup] = [] for group in all_groups: # We have to do this recursively. - result.append(add_clients_to_secret_group(group, secrets_mapping)) + group_result.append(add_clients_to_secret_group(group, secrets_mapping)) + + result = ClientSecretGroupList(groups=group_result) + if group_filter: + return result + + ungrouped_clients: list[SecretClientMapping] = [] + for name in ungrouped: + mapping = SecretClientMapping(name=name) + if client_mapping := secrets_mapping.get(name): + mapping.clients = client_mapping.clients + ungrouped_clients.append(mapping) + + result.ungrouped = ungrouped_clients return result async def get_secret_group(self, name: str) -> ClientSecretGroup | None: """Get a single secret group by name.""" matches = await self.get_secret_groups(group_filter=name, regex=False) - if not matches: - return None - return matches[0] + if matches.groups: + return matches.groups[0] + return None async def get_secret(self, name: str) -> SecretView | None: """Get secrets from backend.""" @@ -390,10 +410,11 @@ class AdminBackend: """Get a secret, including the actual unencrypted value and clients.""" with self.password_manager() as password_manager: secret = password_manager.get_secret(name) + secret_group = password_manager.get_entry_group(name) if not secret: return None - secret_view = SecretView(name=name, secret=secret) + secret_view = SecretView(name=name, secret=secret, group=secret_group) secret_mapping = await self.backend.get_secret(name) if secret_mapping: secret_view.clients = secret_mapping.clients diff --git a/packages/sshecret-admin/src/sshecret_admin/services/keepass.py b/packages/sshecret-admin/src/sshecret_admin/services/keepass.py index 4d11208..845a523 100644 --- a/packages/sshecret-admin/src/sshecret_admin/services/keepass.py +++ b/packages/sshecret-admin/src/sshecret_admin/services/keepass.py @@ -134,6 +134,16 @@ class PasswordContext: raise RuntimeError(f"Cannot get password for entry {entry_name}") + def get_entry_group(self, entry_name: str) -> str | None: + """Get the group for an entry.""" + entry = self._get_entry(entry_name) + if not entry: + return None + if entry.group.is_root_group: + return None + return str(entry.group.name) + + def get_secret_groups(self, pattern: str | None = None, regex: bool = True) -> list[SecretGroup]: """Get secret groups. @@ -145,13 +155,19 @@ class PasswordContext: self.keepass.find_groups(name=pattern, regex=regex), ) else: - all_groups = cast(list[pykeepass.group.Group], self.keepass.groups) - # We skip the root group - groups = [group for group in all_groups if not group.is_root_group] + groups = self._root_group.subgroups secret_groups = [_kp_group_to_secret_group(group) for group in groups] return secret_groups + def get_ungrouped_secrets(self) -> list[str]: + """Get secrets without groups.""" + entries: list[str] = [] + for entry in self._root_group.entries: + entries.append(str(entry.title)) + + return entries + def add_group( self, name: str, description: str | None = None, parent_group: str | None = None ) -> None: diff --git a/packages/sshecret-admin/src/sshecret_admin/services/models.py b/packages/sshecret-admin/src/sshecret_admin/services/models.py index 62e7378..6799397 100644 --- a/packages/sshecret-admin/src/sshecret_admin/services/models.py +++ b/packages/sshecret-admin/src/sshecret_admin/services/models.py @@ -33,6 +33,7 @@ class SecretView(BaseModel): name: str secret: str + group: str | None = None clients: list[str] = Field(default_factory=list) # Clients that have access to it. @@ -105,6 +106,7 @@ class SecretCreate(SecretUpdate): { "name": "MySecret", "clients": ["client-1", "client-2"], + "group": None, "value": {"auto_generate": True, "length": 32}, }, { @@ -152,3 +154,10 @@ class SecretGroupCreate(BaseModel): name: str description: str | None = None parent_group: str | None = None + + +class ClientSecretGroupList(BaseModel): + """Secret group list.""" + + ungrouped: list[SecretClientMapping] = Field(default_factory=list) + groups: list[ClientSecretGroup] = Field(default_factory=list) diff --git a/packages/sshecret-admin/src/sshecret_admin/static/css/main.css b/packages/sshecret-admin/src/sshecret_admin/static/css/main.css index 05fd108..cecbf58 100644 --- a/packages/sshecret-admin/src/sshecret_admin/static/css/main.css +++ b/packages/sshecret-admin/src/sshecret_admin/static/css/main.css @@ -39,15 +39,16 @@ --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-700: oklch(51.1% 0.096 186.391); --color-teal-900: oklch(38.6% 0.063 188.416); + --color-blue-100: oklch(93.2% 0.032 255.585); --color-blue-200: oklch(88.2% 0.059 254.128); --color-blue-300: oklch(80.9% 0.105 251.813); + --color-blue-400: oklch(70.7% 0.165 254.624); --color-blue-500: oklch(62.3% 0.214 259.815); --color-blue-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-200: oklch(87% 0.065 274.039); + --color-blue-900: oklch(37.9% 0.146 265.522); --color-indigo-500: oklch(58.5% 0.233 277.117); --color-indigo-600: oklch(51.1% 0.262 276.966); --color-indigo-700: oklch(45.7% 0.24 277.023); @@ -120,6 +121,7 @@ --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); --animate-spin: spin 1s linear infinite; --animate-ping: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite; + --animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; --default-transition-duration: 150ms; --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); --default-font-family: var(--font-sans); @@ -415,12 +417,18 @@ .m-361 { margin: calc(var(--spacing) * 361); } + .mx-2\.5 { + margin-inline: calc(var(--spacing) * 2.5); + } .mx-3 { margin-inline: calc(var(--spacing) * 3); } .mx-4 { margin-inline: calc(var(--spacing) * 4); } + .mx-\[1rem\] { + margin-inline: 1rem; + } .mx-auto { margin-inline: auto; } @@ -478,6 +486,9 @@ .mt-2 { margin-top: calc(var(--spacing) * 2); } + .mt-2\.5 { + margin-top: calc(var(--spacing) * 2.5); + } .mt-3 { margin-top: calc(var(--spacing) * 3); } @@ -541,6 +552,9 @@ .mb-2 { margin-bottom: calc(var(--spacing) * 2); } + .mb-2\.5 { + margin-bottom: calc(var(--spacing) * 2.5); + } .mb-3 { margin-bottom: calc(var(--spacing) * 3); } @@ -583,6 +597,9 @@ .ml-6 { margin-left: calc(var(--spacing) * 6); } + .ml-\[1rem\] { + margin-left: 1rem; + } .ml-auto { margin-left: auto; } @@ -667,6 +684,9 @@ .h-32 { height: calc(var(--spacing) * 32); } + .h-48 { + height: calc(var(--spacing) * 48); + } .h-\[0\.125rem\] { height: 0.125rem; } @@ -736,12 +756,21 @@ .w-11 { width: calc(var(--spacing) * 11); } + .w-12 { + width: calc(var(--spacing) * 12); + } .w-16 { width: calc(var(--spacing) * 16); } + .w-24 { + width: calc(var(--spacing) * 24); + } .w-28 { width: calc(var(--spacing) * 28); } + .w-32 { + width: calc(var(--spacing) * 32); + } .w-36 { width: calc(var(--spacing) * 36); } @@ -863,6 +892,9 @@ .animate-ping { animation: var(--animate-ping); } + .animate-pulse { + animation: var(--animate-pulse); + } .animate-spin { animation: var(--animate-spin); } @@ -1196,6 +1228,9 @@ --tw-border-style: solid; border-style: solid; } + .border-blue-700 { + border-color: var(--color-blue-700); + } .border-gray-100 { border-color: var(--color-gray-100); } @@ -1208,6 +1243,9 @@ .border-gray-500 { border-color: var(--color-gray-500); } + .border-gray-900 { + border-color: var(--color-gray-900); + } .border-green-100 { border-color: var(--color-green-100); } @@ -1262,6 +1300,9 @@ background-color: color-mix(in oklab, var(--color-black) 50%, transparent); } } + .bg-blue-100 { + background-color: var(--color-blue-100); + } .bg-blue-200 { background-color: var(--color-blue-200); } @@ -1295,6 +1336,9 @@ .bg-gray-200 { background-color: var(--color-gray-200); } + .bg-gray-300 { + background-color: var(--color-gray-300); + } .bg-gray-800 { background-color: var(--color-gray-800); } @@ -1692,12 +1736,21 @@ .text-\[\#f84525\] { color: #f84525; } + .text-blue-400 { + color: var(--color-blue-400); + } .text-blue-500 { color: var(--color-blue-500); } .text-blue-600 { color: var(--color-blue-600); } + .text-blue-700 { + color: var(--color-blue-700); + } + .text-blue-800 { + color: var(--color-blue-800); + } .text-emerald-500 { color: var(--color-emerald-500); } @@ -2083,6 +2136,27 @@ } } } + .hover\:bg-blue-200 { + &:hover { + @media (hover: hover) { + background-color: var(--color-blue-200); + } + } + } + .hover\:bg-blue-700 { + &:hover { + @media (hover: hover) { + background-color: var(--color-blue-700); + } + } + } + .hover\:bg-blue-800 { + &:hover { + @media (hover: hover) { + background-color: var(--color-blue-800); + } + } + } .hover\:bg-gray-50 { &:hover { @media (hover: hover) { @@ -2174,6 +2248,13 @@ } } } + .hover\:text-blue-900 { + &:hover { + @media (hover: hover) { + color: var(--color-blue-900); + } + } + } .hover\:text-gray-100 { &:hover { @media (hover: hover) { @@ -2316,6 +2397,11 @@ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } } + .focus\:ring-blue-300 { + &:focus { + --tw-ring-color: var(--color-blue-300); + } + } .focus\:ring-blue-500 { &:focus { --tw-ring-color: var(--color-blue-500); @@ -2538,6 +2624,11 @@ padding-inline: calc(var(--spacing) * 4); } } + .sm\:px-16 { + @media (width >= 40rem) { + padding-inline: calc(var(--spacing) * 16); + } + } .sm\:py-2 { @media (width >= 40rem) { padding-block: calc(var(--spacing) * 2); @@ -3013,6 +3104,12 @@ line-height: var(--tw-leading, var(--text-6xl--line-height)); } } + .lg\:text-xl { + @media (width >= 64rem) { + font-size: var(--text-xl); + line-height: var(--tw-leading, var(--text-xl--line-height)); + } + } .lg\:hover\:underline { @media (width >= 64rem) { &:hover { @@ -3140,6 +3237,11 @@ padding-inline: calc(var(--spacing) * 0); } } + .xl\:px-48 { + @media (width >= 80rem) { + padding-inline: calc(var(--spacing) * 48); + } + } .xl\:py-24 { @media (width >= 80rem) { padding-block: calc(var(--spacing) * 24); @@ -3231,6 +3333,11 @@ } } } + .dark\:border-blue-500 { + &:where(.dark, .dark *) { + border-color: var(--color-blue-500); + } + } .dark\:border-gray-500 { &:where(.dark, .dark *) { border-color: var(--color-gray-500); @@ -3291,6 +3398,11 @@ border-color: var(--color-red-800); } } + .dark\:bg-blue-900 { + &:where(.dark, .dark *) { + background-color: var(--color-blue-900); + } + } .dark\:bg-gray-600 { &:where(.dark, .dark *) { background-color: var(--color-gray-600); @@ -3359,6 +3471,11 @@ background-color: var(--color-teal-900); } } + .dark\:text-blue-300 { + &:where(.dark, .dark *) { + color: var(--color-blue-300); + } + } .dark\:text-blue-500 { &:where(.dark, .dark *) { color: var(--color-blue-500); @@ -3399,6 +3516,11 @@ color: var(--color-gray-600); } } + .dark\:text-gray-700 { + &:where(.dark, .dark *) { + color: var(--color-gray-700); + } + } .dark\:text-green-400 { &:where(.dark, .dark *) { color: var(--color-green-400); @@ -3536,6 +3658,24 @@ } } } + .dark\:hover\:bg-blue-500 { + &:where(.dark, .dark *) { + &:hover { + @media (hover: hover) { + background-color: var(--color-blue-500); + } + } + } + } + .dark\:hover\:bg-blue-800 { + &:where(.dark, .dark *) { + &:hover { + @media (hover: hover) { + background-color: var(--color-blue-800); + } + } + } + } .dark\:hover\:bg-gray-600 { &:where(.dark, .dark *) { &:hover { @@ -3572,6 +3712,15 @@ } } } + .dark\:hover\:text-blue-300 { + &:where(.dark, .dark *) { + &:hover { + @media (hover: hover) { + color: var(--color-blue-300); + } + } + } + } .dark\:hover\:text-gray-200 { &:where(.dark, .dark *) { &:hover { @@ -3675,6 +3824,13 @@ } } } + .dark\:focus\:ring-blue-800 { + &:where(.dark, .dark *) { + &:focus { + --tw-ring-color: var(--color-blue-800); + } + } + } .dark\:focus\:ring-gray-600 { &:where(.dark, .dark *) { &:focus { @@ -3976,6 +4132,11 @@ opacity: 0; } } +@keyframes pulse { + 50% { + opacity: 0.5; + } +} @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 { diff --git a/packages/sshecret-admin/src/sshecret_admin/static/css/style.css b/packages/sshecret-admin/src/sshecret_admin/static/css/style.css new file mode 100644 index 0000000..e58b098 --- /dev/null +++ b/packages/sshecret-admin/src/sshecret_admin/static/css/style.css @@ -0,0 +1,10 @@ +sl-avatar { + --size: 24pt; +} + +.tree-with-lines { + --indent-guide-width: 1px; +} + +.tree-post-button { +} diff --git a/packages/sshecret-admin/tailwind.config.js b/packages/sshecret-admin/tailwind.config.js index 5c1632e..883f282 100644 --- a/packages/sshecret-admin/tailwind.config.js +++ b/packages/sshecret-admin/tailwind.config.js @@ -1,7 +1,7 @@ module.exports = { content: [ - "./src/sshecret_admin/templates/**/*.html", - "./src/sshecret_admin/templates/**/*.html.j2", + "./src/sshecret_admin/**/*.html", + "./src/sshecret_admin/**/*.html.j2", "./src/sshecret_admin/static/**/*.js", ], safelist: [ diff --git a/packages/sshecret-admin/tests/test_password_context.py b/packages/sshecret-admin/tests/test_password_context.py index 77fe78e..6f02f57 100644 --- a/packages/sshecret-admin/tests/test_password_context.py +++ b/packages/sshecret-admin/tests/test_password_context.py @@ -254,6 +254,7 @@ def test_delete_group(password_database: pykeepass.PyKeePass) -> None: secrets = context.get_available_secrets() assert len(secrets) == 10 + def test_get_specific_group(password_database: pykeepass.PyKeePass) -> None: """Test fetching a specific group.""" context = PasswordContext(password_database) @@ -266,3 +267,19 @@ def test_get_specific_group(password_database: pykeepass.PyKeePass) -> None: assert len(results) == 1 # Check if the parent reference is available. assert results[0].parent_group is not None + + +def test_get_ungrouped_secrets(password_database: pykeepass.PyKeePass) -> None: + """Test fetching secrets without groups.""" + context = PasswordContext(password_database) + context.add_group("test_group", "A test group") + for n in range(7): + context.add_entry(f"grouped-{n}", "foo", group_name="test_group") + + for n in range(5): + context.add_entry(f"ungrouped-{n}", "bar") + + ungrouped = context.get_ungrouped_secrets() + assert len(ungrouped) == 5 + for entry in ungrouped: + assert entry.startswith("ungrouped-")