Preserve history when navigating the secrets page

This commit is contained in:
2025-06-09 15:44:21 +02:00
parent d1fa6c0076
commit 43d00cecb4
4 changed files with 192 additions and 13 deletions

View File

@ -4,6 +4,12 @@
class="tree-entry-item" class="tree-entry-item"
data-type="entry" data-type="entry"
data-name="{{ entry.name }}" data-name="{{ entry.name }}"
data-group-path="/"
{% if secret | default(false) %}
{% if secret.name == entry.name %}
selected=""
{% endif %}
{% endif %}
> >
<sl-icon name="shield"> </sl-icon> <sl-icon name="shield"> </sl-icon>
<span class="px-2">{{ entry.name }}</span> <span class="px-2">{{ entry.name }}</span>
@ -16,6 +22,13 @@
class="secret-group-list-item" class="secret-group-list-item"
data-type="group" data-type="group"
data-name="{{ group.group_name }}" 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 %}
> >
<sl-icon name="folder"> </sl-icon> <sl-icon name="folder"> </sl-icon>
<span class="px-2">{{ group.group_name }}</span> <span class="px-2">{{ group.group_name }}</span>
@ -81,7 +94,22 @@
</div> </div>
<div class="2xl:col-span-2 xl:col-span-2 p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800"> <div class="2xl:col-span-2 xl:col-span-2 p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
{% include '/secrets/partials/default_detail.html.j2' %} {% if group_page | default(false) %}
<div class="w-full" id="secretdetails">
{% include '/secrets/partials/group_detail.html.j2' %}
</div>
{% elif root_group_page | default(false) %}
<div class="w-full" id="secretdetails">
{% include '/secrets/partials/edit_root.html.j2' %}
</div>
{% elif secret_page | default(false) %}
<div class="w-full" id="secretdetails">
{% include '/secrets/partials/tree_detail.html.j2' %}
</div>
{% else %}
{% include '/secrets/partials/default_detail.html.j2' %}
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@ -99,17 +127,19 @@
const type = selectedEl.dataset.type; const type = selectedEl.dataset.type;
const name = selectedEl.dataset.name; 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; if (!type || !name) return;
let url = ''; let url = '';
if (type === 'entry') { if (type === 'entry') {
url = `/secrets/partial/secret/${encodeURIComponent(name)}`; url = `/secrets/secret/${encodeURIComponent(name)}`;
} else if (type === 'group') { } 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') { } else if (type == 'root') {
url = `/secrets/partial/root_group`; url = `/secrets/group/`;
} }
if (url) { if (url) {

View File

@ -64,7 +64,6 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)], current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
): ):
groups = await admin.get_secret_groups() groups = await admin.get_secret_groups()
LOG.info("Groups: %r", groups)
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"secrets/index.html.j2", "secrets/index.html.j2",
@ -85,12 +84,13 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
request, request,
"secrets/partials/edit_root.html.j2", "secrets/partials/edit_root.html.j2",
{ {
"group_path_nodes": [],
"clients": clients, "clients": clients,
}, },
) )
@app.get("/secrets/partial/secret/{name}") @app.get("/secrets/partial/secret/{name}")
async def get_secret_tree_detail( async def get_secret_tree_detail_partial(
request: Request, request: Request,
name: str, name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], 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}") @app.get("/secrets/partial/group/{name}")
async def get_group_details( async def get_group_details(
request: Request, request: Request,

View File

@ -411,6 +411,18 @@ class AdminBackend:
return matches.groups[0] return matches.groups[0]
return None 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: async def get_secret(self, name: str) -> SecretView | None:
"""Get secrets from backend.""" """Get secrets from backend."""
try: try:

View File

@ -32,7 +32,9 @@ def create_password_db(location: Path, password: str) -> None:
def _kp_group_to_secret_group( 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: ) -> SecretGroup:
"""Convert keepass group to secret group dataclass.""" """Convert keepass group to secret group dataclass."""
group_name = cast(str, kp_group.name) group_name = cast(str, kp_group.name)
@ -143,8 +145,9 @@ class PasswordContext:
return None return None
return str(entry.group.name) return str(entry.group.name)
def get_secret_groups(
def get_secret_groups(self, pattern: str | None = None, regex: bool = True) -> list[SecretGroup]: self, pattern: str | None = None, regex: bool = True
) -> list[SecretGroup]:
"""Get secret groups. """Get secret groups.
A regex pattern may be provided to filter 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] secret_groups = [_kp_group_to_secret_group(group) for group in groups]
return secret_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.""" """Get a flat list of groups."""
if pattern: if pattern:
return self.get_secret_groups(pattern, regex) 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] secret_groups = [_kp_group_to_secret_group(group) for group in groups]
return secret_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]: def get_ungrouped_secrets(self) -> list[str]:
"""Get secrets without groups.""" """Get secrets without groups."""
@ -193,7 +216,9 @@ class PasswordContext:
f"Error: Cannot find a parent group named {parent_group}" f"Error: Cannot find a parent group named {parent_group}"
) )
kp_parent_group = query 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() self.keepass.save()
def set_group_description(self, name: str, description: str) -> None: def set_group_description(self, name: str, description: str) -> None: