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]