This is an automated email from the ASF dual-hosted git repository.

sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-release.git


The following commit(s) were added to refs/heads/main by this push:
     new 25a707c  Add a committees section to the key management page
25a707c is described below

commit 25a707c59431a8fc209ae78ed9fd082bb45ecbfa
Author: Sean B. Palmer <[email protected]>
AuthorDate: Fri May 2 11:33:12 2025 +0100

    Add a committees section to the key management page
---
 atr/construct.py                 |   2 +-
 atr/routes/announce.py           |   2 +-
 atr/routes/draft.py              |   2 +-
 atr/routes/finish.py             |   2 +-
 atr/routes/keys.py               | 200 +++++++++++++++++++++++++++++++++++++--
 atr/routes/preview.py            |   2 +-
 atr/routes/root.py               |   4 +-
 atr/routes/voting.py             |   2 +-
 atr/server.py                    |   4 +-
 atr/tasks/checks/__init__.py     |   4 +-
 atr/tasks/vote.py                |   2 +-
 atr/templates/keys-add.html      |   4 +
 atr/templates/keys-review.html   |  73 ++++++++++----
 atr/templates/keys-show-gpg.html |  72 ++++++++++++++
 atr/templates/keys-ssh-add.html  |  10 +-
 atr/templates/keys-upload.html   |   8 +-
 atr/templates/user-ssh-keys.html |   2 +-
 playwright/test.py               |   8 +-
 18 files changed, 355 insertions(+), 48 deletions(-)

diff --git a/atr/construct.py b/atr/construct.py
index 1fa0e10..3dcec12 100644
--- a/atr/construct.py
+++ b/atr/construct.py
@@ -21,8 +21,8 @@ import quart
 
 import atr.config as config
 import atr.db as db
+import atr.db.models as models
 import atr.util as util
-from atr.db import models
 
 
 @dataclasses.dataclass
diff --git a/atr/routes/announce.py b/atr/routes/announce.py
index 6427723..c4c713e 100644
--- a/atr/routes/announce.py
+++ b/atr/routes/announce.py
@@ -25,6 +25,7 @@ import quart
 import werkzeug.wrappers.response as response
 import wtforms
 
+import atr.construct as construct
 import atr.db as db
 import atr.db.models as models
 import atr.routes as routes
@@ -33,7 +34,6 @@ import atr.routes as routes
 import atr.routes.release as routes_release
 import atr.tasks.message as message
 import atr.util as util
-from atr import construct
 
 if TYPE_CHECKING:
     import pathlib
diff --git a/atr/routes/draft.py b/atr/routes/draft.py
index 7f500c9..c6ac1ae 100644
--- a/atr/routes/draft.py
+++ b/atr/routes/draft.py
@@ -33,6 +33,7 @@ import quart
 import wtforms
 
 import atr.analysis as analysis
+import atr.construct as construct
 import atr.db as db
 import atr.db.models as models
 import atr.revision as revision
@@ -42,7 +43,6 @@ import atr.routes.root as root
 import atr.routes.upload as upload
 import atr.tasks.sbom as sbom
 import atr.util as util
-from atr import construct
 
 if TYPE_CHECKING:
     import werkzeug.wrappers.response as response
diff --git a/atr/routes/finish.py b/atr/routes/finish.py
index 0b2cf0a..0814760 100644
--- a/atr/routes/finish.py
+++ b/atr/routes/finish.py
@@ -24,12 +24,12 @@ import quart
 import werkzeug.wrappers.response as response
 import wtforms
 
+import atr.db as db
 import atr.db.models as models
 import atr.revision as revision
 import atr.routes as routes
 import atr.routes.root as root
 import atr.util as util
-from atr import db
 
 _LOGGER: Final = logging.getLogger(__name__)
 
diff --git a/atr/routes/keys.py b/atr/routes/keys.py
index 2cd8ab0..1710e43 100644
--- a/atr/routes/keys.py
+++ b/atr/routes/keys.py
@@ -25,18 +25,20 @@ import datetime
 import hashlib
 import logging
 import logging.handlers
+import pathlib
 import pprint
 import re
+import textwrap
 from collections.abc import AsyncGenerator, Sequence
 
 import asfquart as asfquart
+import asfquart.base as base
 import gnupg
 import quart
 import sqlmodel
 import werkzeug.datastructures as datastructures
 import werkzeug.wrappers.response as response
 import wtforms
-from wtforms import widgets
 
 import atr.db as db
 import atr.db.models as models
@@ -53,6 +55,10 @@ class DeleteKeyForm(util.QuartFormTyped):
     submit = wtforms.SubmitField("Delete key")
 
 
+class UpdateCommitteeKeysForm(util.QuartFormTyped):
+    submit = wtforms.SubmitField("Update KEYS file")
+
+
 @routes.committer("/keys/add", methods=["GET", "POST"])
 async def add(session: routes.CommitterSession) -> str:
     """Add a new public signing key to the user's account."""
@@ -74,8 +80,8 @@ async def add(session: routes.CommitterSession) -> str:
             validators=[wtforms.validators.InputRequired("You must select at 
least one committee")],
             coerce=str,
             choices=committee_choices,
-            option_widget=widgets.CheckboxInput(),
-            widget=widgets.ListWidget(prefix_label=False),
+            option_widget=wtforms.widgets.CheckboxInput(),
+            widget=wtforms.widgets.ListWidget(prefix_label=False),
         )
         submit = wtforms.SubmitField("Add GPG key")
 
@@ -337,26 +343,65 @@ async def key_user_session_add(
 @routes.committer("/keys")
 async def keys(session: routes.CommitterSession) -> str:
     """View all keys associated with the user's account."""
-    # Get all existing keys for the user
+    committees_to_query = list(set(session.committees + session.projects))
+
+    delete_form = await DeleteKeyForm.create_form()
+    update_committee_keys_form = await UpdateCommitteeKeysForm.create_form()
+
     async with db.session() as data:
         user_keys = await data.public_signing_key(apache_uid=session.uid, 
_committees=True).all()
         user_ssh_keys = await data.ssh_key(asf_uid=session.uid).all()
+        user_committees_with_keys = await 
data.committee(name_in=committees_to_query, _public_signing_keys=True).all()
 
     status_message = quart.request.args.get("status_message")
     status_type = quart.request.args.get("status_type")
 
-    delete_form = await DeleteKeyForm.create_form()
-
     return await quart.render_template(
         "keys-review.html",
         asf_id=session.uid,
         user_keys=user_keys,
         user_ssh_keys=user_ssh_keys,
+        committees=user_committees_with_keys,
         algorithms=routes.algorithms,
         status_message=status_message,
         status_type=status_type,
         now=datetime.datetime.now(datetime.UTC),
         delete_form=delete_form,
+        update_committee_keys_form=update_committee_keys_form,
+    )
+
+
[email protected]("/keys/show-gpg/<fingerprint>", methods=["GET"])
+async def show_gpg_key(session: routes.CommitterSession, fingerprint: str) -> 
str:
+    """Display details for a specific GPG key."""
+    async with db.session() as data:
+        key = await data.public_signing_key(fingerprint=fingerprint).get()
+
+    if not key:
+        quart.abort(404, description="GPG key not found")
+
+    authorised = False
+    if key.apache_uid == session.uid:
+        authorised = True
+    else:
+        user_affiliations = set(session.committees + session.projects)
+        async with db.session() as data:
+            key_committees = await data.execute(
+                
sqlmodel.select(models.KeyLink.committee_name).where(models.KeyLink.key_fingerprint
 == fingerprint)
+            )
+            key_committee_names = {row[0] for row in key_committees.all()}
+        if user_affiliations.intersection(key_committee_names):
+            authorised = True
+
+    if not authorised:
+        quart.abort(403, description="You are not authorised to view this key")
+
+    return await quart.render_template(
+        "keys-show-gpg.html",
+        key=key,
+        algorithms=routes.algorithms,
+        now=datetime.datetime.now(datetime.UTC),
+        asf_id=session.uid,
     )
 
 
@@ -386,6 +431,73 @@ async def ssh_add(session: routes.CommitterSession) -> 
response.Response | str:
     )
 
 
[email protected]("/keys/update-committee-keys/<committee_name>", 
methods=["POST"])
+async def update_committee_keys(session: routes.CommitterSession, 
committee_name: str) -> response.Response:
+    """Generate and save the KEYS file for a specific committee."""
+    form = await UpdateCommitteeKeysForm.create_form()
+    if not await form.validate_on_submit():
+        return await session.redirect(keys, error="Invalid request to update 
KEYS file.")
+
+    if committee_name not in (session.committees + session.projects):
+        quart.abort(403, description=f"You are not authorised to update the 
KEYS file for {committee_name}")
+
+    async with db.session() as data:
+        committee = await data.committee(name=committee_name, 
_public_signing_keys=True, _projects=True).demand(
+            base.ASFQuartException(f"Committee {committee_name} not found", 
errorcode=404)
+        )
+
+        if not committee.public_signing_keys:
+            return await session.redirect(
+                keys, error=f"No keys found for committee {committee_name} to 
generate KEYS file."
+            )
+
+        if not committee.projects:
+            return await session.redirect(keys, error=f"No projects found 
associated with committee {committee_name}.")
+
+        sorted_keys = sorted(committee.public_signing_keys, key=lambda k: 
k.fingerprint)
+
+        keys_content_list = []
+        for key in sorted_keys:
+            fingerprint_short = key.fingerprint[:16].upper()
+            apache_uid = key.apache_uid
+            declared_uid_str = key.declared_uid or ""
+            email_match = re.search(r"<([^>]+)>", declared_uid_str)
+            email = email_match.group(1) if email_match else declared_uid_str
+            comment_line = f"# {fingerprint_short} {email} ({apache_uid})"
+            
keys_content_list.append(f"{comment_line}\n\n{key.ascii_armored_key}")
+
+        key_blocks_str = "\n\n".join(keys_content_list) + "\n"
+
+        project_names_updated: list[str] = []
+        write_errors: list[str] = []
+        base_finished_dir = util.get_finished_dir()
+        committee_name_for_header = committee.display_name or committee.name
+        key_count_for_header = len(committee.public_signing_keys)
+
+        for project in committee.projects:
+            await _write_keys_file(
+                project,
+                base_finished_dir,
+                committee_name_for_header,
+                key_count_for_header,
+                key_blocks_str,
+                project_names_updated,
+                write_errors,
+            )
+    if write_errors:
+        error_summary = "; ".join(write_errors)
+        await quart.flash(
+            f"Completed KEYS update for {committee_name}, but encountered 
errors: {error_summary}", "error"
+        )
+    elif project_names_updated:
+        projects_str = ", ".join(project_names_updated)
+        await quart.flash(f"KEYS files updated successfully for projects: 
{projects_str}", "success")
+    else:
+        await quart.flash(f"No KEYS files were updated for committee 
{committee_name}.", "warning")
+
+    return await session.redirect(keys)
+
+
 @routes.committer("/keys/upload", methods=["GET", "POST"])
 async def upload(session: routes.CommitterSession) -> str:
     """Upload a KEYS file containing multiple GPG keys."""
@@ -401,8 +513,8 @@ async def upload(session: routes.CommitterSession) -> str:
             "Associate keys with committees",
             choices=[(c.name, c.display_name) for c in user_committees],
             coerce=str,
-            option_widget=widgets.CheckboxInput(),
-            widget=widgets.ListWidget(prefix_label=False),
+            option_widget=wtforms.widgets.CheckboxInput(),
+            widget=wtforms.widgets.ListWidget(prefix_label=False),
             validators=[wtforms.validators.InputRequired("You must select at 
least one committee")],
         )
 
@@ -600,3 +712,75 @@ async def _upload_process_key_blocks(key_blocks: 
list[str], selected_committees:
     results_sorted = sorted(results, key=lambda x: (x.get("email", 
"").lower(), x.get("fingerprint", "")))
 
     return results_sorted
+
+
+async def _write_keys_file(
+    project: models.Project,
+    base_finished_dir: pathlib.Path,
+    committee_name_for_header: str,
+    key_count_for_header: int,
+    key_blocks_str: str,
+    project_names_updated: list[str],
+    write_errors: list[str],
+) -> None:
+    project_name = project.name
+    project_keys_dir = base_finished_dir / project_name
+    project_keys_path = project_keys_dir / "KEYS"
+
+    timestamp_str = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%d 
%H:%M:%S")
+    purpose_text = (
+        f"This file contains the PGP/GPG public keys used by committers of the 
"
+        f"Apache {project_name} project to sign official release artifacts. "
+        f"Verifying the signature on a downloaded artifact using one of the "
+        f"keys in this file provides confidence that the artifact is authentic 
"
+        f"and was published by the project team."
+    )
+    wrapped_purpose = "\n".join(
+        textwrap.wrap(
+            purpose_text,
+            width=62,
+            initial_indent="# ",
+            subsequent_indent="# ",
+            break_long_words=False,
+            replace_whitespace=False,
+        )
+    )
+
+    header_content = (
+        f"""\
+# Apache Software Foundation (ASF) project signing keys
+#
+# Project:   {project.display_name or project.name}
+# Committee: {committee_name_for_header}
+# Generated: {timestamp_str} UTC
+# Contains:  {key_count_for_header} PGP/GPG public {"key" if 
key_count_for_header == 1 else "keys"}
+#
+# Purpose:
+{wrapped_purpose}
+#
+# Usage (with GnuPG):
+# 1. Import these keys into your GPG keyring:
+#    gpg --import KEYS
+#
+# 2. Verify the signature file against the release artifact:
+#    gpg --verify <artifact-name>.asc <artifact-name>
+#
+# For details on Apache release signing and verification, see:
+# https://infra.apache.org/release-signing.html
+"""
+        + "\n"
+    )
+
+    full_keys_file_content = header_content + key_blocks_str
+    try:
+        await asyncio.to_thread(project_keys_dir.mkdir, parents=True, 
exist_ok=True)
+        await asyncio.to_thread(project_keys_path.write_text, 
full_keys_file_content, encoding="utf-8")
+        project_names_updated.append(project_name)
+    except OSError as e:
+        error_msg = f"Failed to write KEYS file for project {project_name}: 
{e}"
+        logging.exception(error_msg)
+        write_errors.append(error_msg)
+    except Exception as e:
+        error_msg = f"An unexpected error occurred writing KEYS for project 
{project_name}: {e}"
+        logging.exception(error_msg)
+        write_errors.append(error_msg)
diff --git a/atr/routes/preview.py b/atr/routes/preview.py
index 495c5f4..3eb731e 100644
--- a/atr/routes/preview.py
+++ b/atr/routes/preview.py
@@ -26,12 +26,12 @@ import quart
 import werkzeug.wrappers.response as response
 import wtforms
 
+import atr.construct as construct
 import atr.db as db
 import atr.db.models as models
 import atr.routes as routes
 import atr.routes.root as root
 import atr.util as util
-from atr import construct
 
 if asfquart.APP is ...:
     raise RuntimeError("APP is not set")
diff --git a/atr/routes/root.py b/atr/routes/root.py
index 0e8c3ea..aef6220 100644
--- a/atr/routes/root.py
+++ b/atr/routes/root.py
@@ -19,9 +19,9 @@
 
 import asfquart.session
 import quart
+import sqlalchemy.orm as orm
 import sqlmodel
 import werkzeug.wrappers.response as response
-from sqlalchemy.orm import selectinload
 
 import atr.db as db
 import atr.db.models as models
@@ -61,7 +61,7 @@ async def index() -> response.Response | str:
                         models.Release.project_name == project.name,
                         
db.validate_instrumented_attribute(models.Release.phase).in_(active_phases),
                     )
-                    
.options(selectinload(db.validate_instrumented_attribute(models.Release.project)))
+                    
.options(orm.selectinload(db.validate_instrumented_attribute(models.Release.project)))
                     
.order_by(db.validate_instrumented_attribute(models.Release.created).desc())
                 )
                 result = await data.execute(stmt)
diff --git a/atr/routes/voting.py b/atr/routes/voting.py
index 832a91f..b9a27f9 100644
--- a/atr/routes/voting.py
+++ b/atr/routes/voting.py
@@ -22,6 +22,7 @@ import quart
 import werkzeug.wrappers.response as response
 import wtforms
 
+import atr.construct as construct
 import atr.db as db
 import atr.db.models as models
 import atr.routes as routes
@@ -31,7 +32,6 @@ import atr.routes.vote as vote
 import atr.tasks.vote as tasks_vote
 import atr.user as user
 import atr.util as util
-from atr import construct
 
 
 @routes.committer("/voting/<project_name>/<version_name>/<revision>", 
methods=["GET", "POST"])
diff --git a/atr/server.py b/atr/server.py
index 0c1644b..3bea1ec 100644
--- a/atr/server.py
+++ b/atr/server.py
@@ -37,12 +37,12 @@ import atr
 import atr.blueprints as blueprints
 import atr.config as config
 import atr.db as db
+import atr.filters as filters
 import atr.manager as manager
 import atr.preload as preload
 import atr.ssh as ssh
 import atr.user as user
 import atr.util as util
-from atr.filters import register_filters
 
 # TODO: Technically this is a global variable
 # We should probably find a cleaner way to do this
@@ -182,7 +182,7 @@ def create_app(app_config: type[config.AppConfig]) -> 
base.QuartApp:
     register_routes(app)
     blueprints.register(app)
 
-    register_filters(app)
+    filters.register_filters(app)
 
     config_mode = config.get_mode()
 
diff --git a/atr/tasks/checks/__init__.py b/atr/tasks/checks/__init__.py
index b7e9492..e07fb4e 100644
--- a/atr/tasks/checks/__init__.py
+++ b/atr/tasks/checks/__init__.py
@@ -19,8 +19,8 @@ from __future__ import annotations
 
 import dataclasses
 import datetime
+import functools
 import pathlib
-from functools import wraps
 from typing import TYPE_CHECKING, Any
 
 import sqlmodel
@@ -171,7 +171,7 @@ def with_model(cls: type[pydantic.BaseModel]) -> 
Callable[[Callable[..., Any]],
     """Decorator to specify the parameters for a check."""
 
     def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
-        @wraps(func)
+        @functools.wraps(func)
         async def wrapper(data_dict: dict[str, Any], *args: Any, **kwargs: 
Any) -> Any:
             model_instance = cls(**data_dict)
             return await func(model_instance, *args, **kwargs)
diff --git a/atr/tasks/vote.py b/atr/tasks/vote.py
index 9ee9ee4..0ed5ee7 100644
--- a/atr/tasks/vote.py
+++ b/atr/tasks/vote.py
@@ -23,11 +23,11 @@ from typing import Any, Final
 
 import pydantic
 
+import atr.construct as construct
 import atr.db as db
 import atr.mail as mail
 import atr.tasks.checks as checks
 import atr.util as util
-from atr import construct
 
 # Configure detailed logging
 _LOGGER: Final = logging.getLogger(__name__)
diff --git a/atr/templates/keys-add.html b/atr/templates/keys-add.html
index b27798a..ee581a6 100644
--- a/atr/templates/keys-add.html
+++ b/atr/templates/keys-add.html
@@ -9,6 +9,10 @@
 {% endblock description %}
 
 {% block content %}
+  <p>
+    <a href="{{ as_url(routes.keys.keys) }}" class="atr-back-link">← Back to 
Manage keys</a>
+  </p>
+
   <div class="my-4">
     <h1 class="mb-4">Add your GPG key</h1>
 
diff --git a/atr/templates/keys-review.html b/atr/templates/keys-review.html
index e3bc453..aa8aa74 100644
--- a/atr/templates/keys-review.html
+++ b/atr/templates/keys-review.html
@@ -1,7 +1,7 @@
 {% extends "layouts/base.html" %}
 
 {% block title %}
-  Your public keys ~ ATR
+  Manage keys ~ ATR
 {% endblock title %}
 
 {% block description %}
@@ -9,7 +9,14 @@
 {% endblock description %}
 
 {% block content %}
-  <h1>Your public keys</h1>
+  <h1>Manage keys</h1>
+
+  <p class="mb-4">
+    <a href="#your-public-keys" class="btn btn-sm btn-secondary me-3">Your 
public keys</a>
+    <a href="#your-committee-keys" class="btn btn-sm btn-secondary">Your 
committee's keys</a>
+  </p>
+
+  <h2 id="your-public-keys">Your public keys</h2>
   <p>Review your public keys used for signing release artifacts.</p>
 
   <div>
@@ -18,15 +25,13 @@
     </p>
   </div>
 
-  <div class="d-flex gap-4">
-    <a href="{{ as_url(routes.keys.add) }}" class="btn 
btn-outline-primary">Add a GPG key</a>
-    <a href="{{ as_url(routes.keys.upload) }}"
-       class="btn btn-outline-primary">Upload a KEYS file</a>
+  <div class="d-flex gap-3 mb-4">
+    <a href="{{ as_url(routes.keys.add) }}" class="btn 
btn-outline-primary">Add your GPG key</a>
     <a href="{{ as_url(routes.keys.ssh_add) }}"
-       class="btn btn-outline-primary">Add an SSH key</a>
+       class="btn btn-outline-primary">Add your SSH key</a>
   </div>
 
-  <h2>GPG keys</h2>
+  <h3>GPG keys</h3>
 
   {% if user_keys %}
     <div class="mb-5 p-4 bg-light rounded">
@@ -106,16 +111,12 @@
       </div>
     </div>
   {% else %}
-    <h2>Keys</h2>
     <p>
-      <strong>You haven't added any signing keys yet.</strong>
-    </p>
-    <p>
-      <a href="{{ as_url(routes.keys.add) }}">Add a key</a>
+      <strong>You haven't added any personal GPG keys yet.</strong>
     </p>
   {% endif %}
 
-  <h2>SSH keys</h2>
+  <h3>SSH keys</h3>
   {% if user_ssh_keys %}
     <div class="mb-5 p-4 bg-light rounded">
       <div class="d-grid gap-4">
@@ -155,8 +156,46 @@
     <p>
       <strong>You haven't added any SSH keys yet.</strong>
     </p>
-    <p>
-      <a href="{{ as_url(routes.keys.ssh_add) }}">Add an SSH key</a>
-    </p>
   {% endif %}
+
+  <h2 id="your-committee-keys">Your committee's keys</h2>
+  <div class="mb-4">
+    <a href="{{ as_url(routes.keys.upload) }}"
+       class="btn btn-outline-primary">Upload a KEYS file</a>
+  </div>
+  {% for committee in committees %}
+    <h3 class="mt-3">{{ committee.display_name }}</h3>
+    {% if committee.public_signing_keys %}
+      <div class="table-responsive mb-2">
+        <table class="table border table-striped table-hover table-sm">
+          <thead>
+            <tr>
+              <th class="px-2" scope="col">Fingerprint</th>
+              <th class="px-2" scope="col">Email</th>
+              <th class="px-2" scope="col">Apache UID</th>
+            </tr>
+          </thead>
+          <tbody>
+            {% for key in committee.public_signing_keys %}
+              <tr>
+                <td class="text-break font-monospace px-2">
+                  <a href="{{ as_url(routes.keys.show_gpg_key, 
fingerprint=key.fingerprint) }}">{{ key.fingerprint[:16]|upper }}</a>
+                </td>
+                <td class="text-break px-2">{{ key.declared_uid or 'Not 
specified' }}</td>
+                <td class="text-break px-2">{{ key.apache_uid }}</td>
+              </tr>
+            {% endfor %}
+          </tbody>
+        </table>
+      </div>
+      <form method="post"
+            action="{{ as_url(routes.keys.update_committee_keys, 
committee_name=committee.name) }}"
+            class="mb-4 d-inline-block">
+        {{ update_committee_keys_form.hidden_tag() }}
+        {{ update_committee_keys_form.submit(class_='btn btn-sm 
btn-outline-secondary') }}
+      </form>
+    {% else %}
+      <p class="mb-4">No keys uploaded for this committee yet.</p>
+    {% endif %}
+  {% endfor %}
 {% endblock content %}
diff --git a/atr/templates/keys-show-gpg.html b/atr/templates/keys-show-gpg.html
new file mode 100644
index 0000000..5b5ecb9
--- /dev/null
+++ b/atr/templates/keys-show-gpg.html
@@ -0,0 +1,72 @@
+{% extends "layouts/base.html" %}
+
+{% block title %}
+  GPG key details ~ ATR
+{% endblock title %}
+
+{% block description %}
+  View details for a specific GPG public key.
+{% endblock description %}
+
+{% block content %}
+  <p>
+    <a href="{{ as_url(routes.keys.keys) }}" class="atr-back-link">← Back to 
Manage keys</a>
+  </p>
+
+  <h1>GPG key details</h1>
+
+  <div class="card p-3 border mb-4">
+    <table class="mb-0">
+      <tbody>
+        <tr>
+          <th class="p-2 text-dark">Fingerprint</th>
+          <td class="text-break">{{ key.fingerprint }}</td>
+        </tr>
+        <tr>
+          <th class="p-2 text-dark">Type</th>
+          <td class="text-break">{{ algorithms[key.algorithm] }} ({{ 
key.length }} bits)</td>
+        </tr>
+        <tr>
+          <th class="p-2 text-dark">Created</th>
+          <td class="text-break">{{ key.created.strftime("%Y-%m-%d %H:%M:%S") 
}}</td>
+        </tr>
+        <tr>
+          <th class="p-2 text-dark">Expires</th>
+          <td class="text-break">
+            {% if key.expires %}
+              {% set days_until_expiry = (key.expires - now).days %}
+              {% if days_until_expiry < 0 %}
+                <span class="text-danger fw-bold">
+                  {{ key.expires.strftime("%Y-%m-%d %H:%M:%S") }}
+                  <span class="badge bg-danger text-white ms-2">Expired</span>
+                </span>
+              {% elif days_until_expiry <= 30 %}
+                <span class="text-warning fw-bold">
+                  {{ key.expires.strftime("%Y-%m-%d %H:%M:%S") }}
+                  <span class="badge bg-warning text-dark ms-2">Expires in {{ 
days_until_expiry }} days</span>
+                </span>
+              {% else %}
+                {{ key.expires.strftime("%Y-%m-%d %H:%M:%S") }}
+              {% endif %}
+            {% else %}
+              Never
+            {% endif %}
+          </td>
+        </tr>
+        <tr>
+          <th class="p-2 text-dark">Apache UID</th>
+          <td class="text-break">{{ key.apache_uid }}</td>
+        </tr>
+        <tr>
+          <th class="p-2 text-dark">Declared UID</th>
+          <td class="text-break">{{ key.declared_uid or 'Not specified' }}</td>
+        </tr>
+      </tbody>
+    </table>
+
+    <details class="mt-3 p-3 bg-light rounded">
+      <summary class="fw-bold">View ASCII Armored Key</summary>
+      <pre class="mt-3">{{ key.ascii_armored_key }}</pre>
+    </details>
+  </div>
+{% endblock content %}
diff --git a/atr/templates/keys-ssh-add.html b/atr/templates/keys-ssh-add.html
index 4b22ff6..fdb8091 100644
--- a/atr/templates/keys-ssh-add.html
+++ b/atr/templates/keys-ssh-add.html
@@ -1,15 +1,19 @@
 {% extends "layouts/base.html" %}
 
 {% block title %}
-  Add SSH key ~ ATR
+  Add your SSH key ~ ATR
 {% endblock title %}
 
 {% block description %}
-  Add an SSH public key to your account.
+  Add your SSH public key to your account.
 {% endblock description %}
 
 {% block content %}
-  <h1>Add SSH key</h1>
+  <p>
+    <a href="{{ as_url(routes.keys.keys) }}" class="atr-back-link">← Back to 
Manage keys</a>
+  </p>
+
+  <h1>Add your SSH key</h1>
   <p>Add your SSH public key to use for rsync authentication.</p>
 
   <div>
diff --git a/atr/templates/keys-upload.html b/atr/templates/keys-upload.html
index 6a579a6..681bdd0 100644
--- a/atr/templates/keys-upload.html
+++ b/atr/templates/keys-upload.html
@@ -1,7 +1,7 @@
 {% extends "layouts/base.html" %}
 
 {% block title %}
-  Upload KEYS file ~ ATR
+  Upload a KEYS file ~ ATR
 {% endblock title %}
 
 {% block description %}
@@ -77,7 +77,11 @@
 {% endblock stylesheets %}
 
 {% block content %}
-  <h1>Upload KEYS file</h1>
+  <p>
+    <a href="{{ as_url(routes.keys.keys) }}" class="atr-back-link">← Back to 
Manage keys</a>
+  </p>
+
+  <h1>Upload a KEYS file</h1>
   <p>Upload a KEYS file containing multiple GPG public keys.</p>
 
   {% if form.errors %}
diff --git a/atr/templates/user-ssh-keys.html b/atr/templates/user-ssh-keys.html
index 756cf44..315e442 100644
--- a/atr/templates/user-ssh-keys.html
+++ b/atr/templates/user-ssh-keys.html
@@ -4,7 +4,7 @@
   <div class="card-body p-3">
     <p>
       {% if key_count == 0 %}
-        We have no SSH keys on file for you, so you cannot yet use this 
command. Please <a href="{{ as_url(routes.keys.ssh_add) }}">add an SSH key</a>.
+        We have no SSH keys on file for you, so you cannot yet use this 
command. Please <a href="{{ as_url(routes.keys.ssh_add) }}">add your SSH 
key</a>.
       {% elif key_count == 1 %}
         {% set key = user_ssh_keys[0] %}
         {% set key_parts = key.key.split(' ', 2) %}
diff --git a/playwright/test.py b/playwright/test.py
index 9615988..3d0ae2d 100644
--- a/playwright/test.py
+++ b/playwright/test.py
@@ -675,7 +675,7 @@ def test_gpg_01_upload(page: sync_api.Page, credentials: 
Credentials) -> None:
     go_to_path(page, "/keys")
 
     logging.info("Following link to add GPG key")
-    add_key_link_locator = page.locator('a:has-text("Add a GPG key")')
+    add_key_link_locator = page.locator('a:has-text("Add your GPG key")')
     sync_api.expect(add_key_link_locator).to_be_visible()
     add_key_link_locator.click()
 
@@ -870,11 +870,11 @@ def test_ssh_01_add_key(page: sync_api.Page, credentials: 
Credentials) -> None:
     wait_for_path(page, "/keys")
     logging.info("Navigated to Your Public Keys page")
 
-    logging.info("Clicking Add an SSH key button")
+    logging.info("Clicking Add your SSH key button")
     # There can be two buttons with the same text if the user did not upload 
an SSH key yet
-    page.locator('a[href="/keys/ssh/add"]:has-text("Add an SSH 
key")').first.click()
+    page.locator('a[href="/keys/ssh/add"]:has-text("Add your SSH 
key")').first.click()
     wait_for_path(page, "/keys/ssh/add")
-    logging.info("Navigated to Add SSH Key page")
+    logging.info("Navigated to Add your SSH key page")
 
     public_key_path = f"{_SSH_KEY_PATH}.pub"
     try:


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to