From 43d00cecb47f07bc967dead5cf1ae4fd650e1f9a Mon Sep 17 00:00:00 2001 From: Allan Eising Date: Mon, 9 Jun 2025 15:44:21 +0200 Subject: [PATCH] Preserve history when navigating the secrets page --- .../frontend/templates/secrets/index.html.j2 | 40 +++++- .../sshecret_admin/frontend/views/secrets.py | 116 +++++++++++++++++- .../sshecret_admin/services/admin_backend.py | 12 ++ .../src/sshecret_admin/services/keepass.py | 37 +++++- 4 files changed, 192 insertions(+), 13 deletions(-) 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 b856ea6..8f0fb87 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 @@ -4,6 +4,12 @@ class="tree-entry-item" data-type="entry" data-name="{{ entry.name }}" + data-group-path="/" + {% if secret | default(false) %} + {% if secret.name == entry.name %} + selected="" + {% endif %} + {% endif %} > {{ entry.name }} @@ -16,6 +22,13 @@ class="secret-group-list-item" data-type="group" data-name="{{ group.group_name }}" + data-group-path="{{ group.path }}" + {% if group_path_nodes | default(false) %} + {% if group.group_name in group_path_nodes %} + expanded="" + {% endif %} + + {% endif %} > {{ group.group_name }} @@ -81,7 +94,22 @@
- {% include '/secrets/partials/default_detail.html.j2' %} + {% if group_page | default(false) %} +
+ {% include '/secrets/partials/group_detail.html.j2' %} +
+ {% elif root_group_page | default(false) %} +
+ {% include '/secrets/partials/edit_root.html.j2' %} +
+ {% elif secret_page | default(false) %} +
+ {% include '/secrets/partials/tree_detail.html.j2' %} +
+ {% else %} + {% include '/secrets/partials/default_detail.html.j2' %} + {% endif %} +
@@ -99,17 +127,19 @@ const type = selectedEl.dataset.type; const name = selectedEl.dataset.name; - console.log(`Event on ${type} ${name}`); + const groupPath = selectedEl.dataset.groupPath; + console.log(`Event on ${type} ${name} path: ${groupPath}`); if (!type || !name) return; let url = ''; if (type === 'entry') { - url = `/secrets/partial/secret/${encodeURIComponent(name)}`; + url = `/secrets/secret/${encodeURIComponent(name)}`; } else if (type === 'group') { - url = `/secrets/partial/group/${encodeURIComponent(name)}`; + //url = `/secrets/partial/group/${encodeURIComponent(name)}`; + url = `/secrets/group/${encodeURIComponent(groupPath)}`; } else if (type == 'root') { - url = `/secrets/partial/root_group`; + url = `/secrets/group/`; } if (url) { 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 a0c1b94..74afb5e 100644 --- a/packages/sshecret-admin/src/sshecret_admin/frontend/views/secrets.py +++ b/packages/sshecret-admin/src/sshecret_admin/frontend/views/secrets.py @@ -64,7 +64,6 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter: current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)], ): groups = await admin.get_secret_groups() - LOG.info("Groups: %r", groups) return templates.TemplateResponse( request, "secrets/index.html.j2", @@ -85,12 +84,13 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter: request, "secrets/partials/edit_root.html.j2", { + "group_path_nodes": [], "clients": clients, }, ) @app.get("/secrets/partial/secret/{name}") - async def get_secret_tree_detail( + async def get_secret_tree_detail_partial( request: Request, name: str, admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], @@ -114,6 +114,118 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter: }, ) + @app.get("/secrets/group/") + async def show_root_group( + request: Request, + admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], + current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)], + ): + """Show the root path.""" + clients = await admin.get_clients() + context: dict[str, Any] = { + "clients": clients, + "root_group_page": True, + } + headers: dict[str, str] = {} + if request.headers.get("HX-Request"): + # This is a HTMX request. + template_name = "secrets/partials/edit_root.html.j2" + headers["HX-Push-Url"] = request.url.path + else: + groups = await admin.get_secret_groups() + template_name = "secrets/index.html.j2" + context["user"] = current_user + context["groups"] = groups + context["group_path_nodes"] = ["/"] + + return templates.TemplateResponse( + request, template_name, context, headers=headers + ) + + @app.get("/secrets/group/{group_path:path}") + async def show_group( + request: Request, + group_path: str, + admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], + current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)], + ): + """Show a group.""" + group = await admin.get_secret_group_by_path(group_path) + if not group: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Group not found" + ) + clients = await admin.get_clients() + + headers: dict[str, str] = {} + context: dict[str, Any] = { + "group_page": True, + "name": group.group_name, + "description": group.description, + "clients": clients, + } + if request.headers.get("HX-Request"): + # This is a HTMX request. + template_name = "secrets/partials/group_detail.html.j2" + headers["HX-Push-Url"] = request.url.path + else: + template_name = "secrets/index.html.j2" + + groups = await admin.get_secret_groups() + context["user"] = current_user + context["groups"] = groups + context["group_path_nodes"] = group.path.split("/") + + return templates.TemplateResponse( + request, template_name, context, headers=headers + ) + + @app.get("/secrets/secret/{name}") + async def get_secret_tree_detail( + request: Request, + name: str, + admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], + current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)], + ): + """Get secret detail.""" + secret = await admin.get_secret(name) + groups = await admin.get_secret_groups(flat=True) + events = await admin.get_audit_log_detailed(limit=10, secret_name=name) + + if not secret: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found" + ) + + context: dict[str, Any] = { + "secret": secret, + "groups": groups, + "events": events, + "secret_page": True, + } + + headers: dict[str, str] = {} + + if request.headers.get("HX-Request"): + # This is a HTMX request. + template_name = "secrets/partials/tree_detail.html.j2" + headers["HX-Push-Url"] = request.url.path + else: + group_path = ["/"] + if secret.group: + group = await admin.get_secret_group(secret.group) + if group: + group_path = group.path.split("/") + + template_name = "secrets/index.html.j2" + context["user"] = current_user + context["groups"] = groups + context["group_path_nodes"] = group_path + + return templates.TemplateResponse( + request, template_name, context, headers=headers + ) + @app.get("/secrets/partial/group/{name}") async def get_group_details( request: Request, 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 5a21880..7c59777 100644 --- a/packages/sshecret-admin/src/sshecret_admin/services/admin_backend.py +++ b/packages/sshecret-admin/src/sshecret_admin/services/admin_backend.py @@ -411,6 +411,18 @@ class AdminBackend: return matches.groups[0] return None + async def get_secret_group_by_path(self, path: str) -> ClientSecretGroup | None: + """Get a group based on its path.""" + with self.password_manager() as password_manager: + secret_group = password_manager.get_secret_group(path) + + if not secret_group: + return None + + all_secrets = await self.backend.get_detailed_secrets() + secrets_mapping = {secret.name: secret for secret in all_secrets} + return add_clients_to_secret_group(secret_group, secrets_mapping) + async def get_secret(self, name: str) -> SecretView | None: """Get secrets from backend.""" try: diff --git a/packages/sshecret-admin/src/sshecret_admin/services/keepass.py b/packages/sshecret-admin/src/sshecret_admin/services/keepass.py index 3f6ede7..04af2fd 100644 --- a/packages/sshecret-admin/src/sshecret_admin/services/keepass.py +++ b/packages/sshecret-admin/src/sshecret_admin/services/keepass.py @@ -32,7 +32,9 @@ def create_password_db(location: Path, password: str) -> None: def _kp_group_to_secret_group( - kp_group: pykeepass.group.Group, parent: SecretGroup | None = None, depth: int | None = None + kp_group: pykeepass.group.Group, + parent: SecretGroup | None = None, + depth: int | None = None, ) -> SecretGroup: """Convert keepass group to secret group dataclass.""" group_name = cast(str, kp_group.name) @@ -143,8 +145,9 @@ class PasswordContext: return None return str(entry.group.name) - - def get_secret_groups(self, pattern: str | None = None, regex: bool = True) -> list[SecretGroup]: + def get_secret_groups( + self, pattern: str | None = None, regex: bool = True + ) -> list[SecretGroup]: """Get secret groups. A regex pattern may be provided to filter groups. @@ -160,15 +163,35 @@ class PasswordContext: secret_groups = [_kp_group_to_secret_group(group) for group in groups] return secret_groups - def get_secret_group_list(self, pattern: str | None = None, regex: bool = True) -> list[SecretGroup]: + def get_secret_group_list( + self, pattern: str | None = None, regex: bool = True + ) -> list[SecretGroup]: """Get a flat list of groups.""" if pattern: return self.get_secret_groups(pattern, regex) - groups = [ group for group in self.keepass.groups if not group.is_root_group ] + groups = [group for group in self.keepass.groups if not group.is_root_group] secret_groups = [_kp_group_to_secret_group(group) for group in groups] return secret_groups + def get_secret_group(self, path: str) -> SecretGroup | None: + """Get a secret group by path.""" + elements = path.split("/") + final_element = elements[-1] + + current = self._root_group + while elements: + groupname = elements.pop(0) + matches = [ + subgroup for subgroup in current.subgroups if subgroup.name == groupname + ] + if matches: + current = matches[0] + else: + return None + if not current.is_root_group and current.name == final_element: + return _kp_group_to_secret_group(current) + return None def get_ungrouped_secrets(self) -> list[str]: """Get secrets without groups.""" @@ -193,7 +216,9 @@ class PasswordContext: f"Error: Cannot find a parent group named {parent_group}" ) kp_parent_group = query - self.keepass.add_group(destination_group=kp_parent_group, group_name=name, notes=description) + self.keepass.add_group( + destination_group=kp_parent_group, group_name=name, notes=description + ) self.keepass.save() def set_group_description(self, name: str, description: str) -> None: