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 489d64f Add a form to generate JWTs on the tokens page
489d64f is described below
commit 489d64f64af981f4fef7df8bbe89a940ee7b042b
Author: Sean B. Palmer <[email protected]>
AuthorDate: Thu Jul 3 19:34:47 2025 +0100
Add a form to generate JWTs on the tokens page
---
atr/routes/tokens.py | 94 ++++++++++++++++++++++++++++++++++-----
atr/static/css/atr.css | 5 +++
atr/static/js/create-a-jwt.js | 33 ++++++++++++++
atr/static/js/create-a-jwt.js.map | 1 +
atr/static/ts/create-a-jwt.ts | 25 +++++++++++
atr/templates/blank.html | 7 +++
atr/util.py | 7 +++
7 files changed, 160 insertions(+), 12 deletions(-)
diff --git a/atr/routes/tokens.py b/atr/routes/tokens.py
index 998aec4..f6a0f41 100644
--- a/atr/routes/tokens.py
+++ b/atr/routes/tokens.py
@@ -30,10 +30,11 @@ import werkzeug.datastructures as datastructures
import werkzeug.wrappers.response as response
import wtforms
import wtforms.fields.core as core
-from htpy import Element, code, div, form, h1, h2, p, strong, table, tbody,
td, th, thead, tr
+from htpy import Element, code, div, form, h1, h2, p, pre, strong, table,
tbody, td, th, thead, tr
import atr.db as db
import atr.db.models as models
+import atr.jwtoken as jwtoken
import atr.routes as routes
import atr.template as templates
import atr.util as util
@@ -41,11 +42,11 @@ import atr.util as util
_EXPIRY_DAYS: Final[int] = 180
_LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
+
type Fragment = Element | core.Field | str
class AddTokenForm(util.QuartFormTyped):
- csrf_token: wtforms.Field
label = wtforms.StringField(
"Label",
validators=[wtforms.validators.Optional(),
wtforms.validators.Length(max=100)],
@@ -55,11 +56,22 @@ class AddTokenForm(util.QuartFormTyped):
class DeleteTokenForm(util.QuartFormTyped):
- csrf_token: wtforms.Field
token_id =
wtforms.HiddenField(validators=[wtforms.validators.InputRequired()])
submit = wtforms.SubmitField("Delete")
+class IssueJWTForm(util.QuartFormTyped):
+ submit = wtforms.SubmitField("Generate JWT")
+
+
[email protected]("/tokens/jwt", methods=["POST"])
+async def jwt_post(session: routes.CommitterSession) -> quart.Response:
+ await util.validate_empty_form()
+
+ jwt_token = jwtoken.issue(session.uid)
+ return quart.Response(jwt_token, mimetype="text/plain")
+
+
@routes.committer("/tokens", methods=["GET", "POST"])
async def tokens(session: routes.CommitterSession) -> str | response.Response:
request_form = await quart.request.form
@@ -70,6 +82,7 @@ async def tokens(session: routes.CommitterSession) -> str |
response.Response:
return maybe_response
add_form = await AddTokenForm.create_form(data=request_form if is_post
else None)
+ issue_form = await IssueJWTForm.create_form(data=request_form if is_post
else None)
start = time.perf_counter_ns()
tokens_list = await _fetch_tokens(session.uid)
@@ -78,8 +91,19 @@ async def tokens(session: routes.CommitterSession) -> str |
response.Response:
start = time.perf_counter_ns()
add_form_elem = _build_add_form_element(add_form)
+ issue_form_elem = _build_issue_jwt_form_element(issue_form)
tokens_table = _build_tokens_table(tokens_list)
+ issue_jwt_elem = div[
+ p[
+ "Generate a JSON Web Token (JWT) to authenticate calls to ATR's
private API routes. "
+ "Treat the token like a password and include it in the
Authorization header "
+ "as a Bearer token when invoking the protected endpoints.",
+ ],
+ issue_form_elem,
+ pre(id="jwt-output", class_="d-none mt-2 p-3 atr-word-wrap border
rounded w-50"),
+ ]
+
content_elem = div[
h1["Tokens"],
h2["Personal Access Tokens (PATs)"],
@@ -93,6 +117,8 @@ async def tokens(session: routes.CommitterSession) -> str |
response.Response:
div(".card-body")[add_form_elem],
],
tokens_table,
+ h2["JSON Web Token (JWT)"],
+ issue_jwt_elem,
]
end = time.perf_counter_ns()
_LOGGER.info("Content elem built in %dms", (end - start) / 1_000_000)
@@ -101,8 +127,9 @@ async def tokens(session: routes.CommitterSession) -> str |
response.Response:
rendered = await templates.render(
"blank.html",
title="Tokens",
- description="Manage your Personal Access Tokens.",
+ description="Manage your PATs and JWTs.",
content=content_elem,
+ javascripts=[util.static_path("js", "create-a-jwt.js")],
)
end = time.perf_counter_ns()
_LOGGER.info("Rendered in %dms", (end - start) / 1_000_000)
@@ -137,6 +164,14 @@ def _build_delete_form_element(token_id: int | None) ->
markupsafe.Markup:
return _as_markup(elem)
+def _build_issue_jwt_form_element(j_form: IssueJWTForm) -> markupsafe.Markup:
+ elem = form("#issue-jwt-form", method="post",
action=util.as_url(jwt_post))[
+ _as_markup(j_form.csrf_token),
+ j_form.submit(class_="btn btn-primary"),
+ ]
+ return _as_markup(elem)
+
+
def _build_tokens_table(tokens_list: list[models.PersonalAccessToken]) ->
markupsafe.Markup:
if not tokens_list:
return _as_markup(p["No tokens found."])
@@ -213,15 +248,17 @@ async def _handle_post(
session: routes.CommitterSession, request_form: datastructures.MultiDict
) -> response.Response | None:
if "token_id" in request_form:
- del_form = await DeleteTokenForm.create_form(data=request_form)
- if await del_form.validate_on_submit():
- token_id_val = int(str(del_form.token_id.data))
- await _delete_token(session.uid, token_id_val)
- await quart.flash("Token deleted successfully", "success")
- return await session.redirect(tokens)
- await quart.flash("Invalid delete request", "error")
- return None
+ return await _handle_delete_token_post(session, request_form)
+
+ if "label" in request_form:
+ return await _handle_add_token_post(session, request_form)
+ return await _handle_issue_jwt_post(session, request_form)
+
+
+async def _handle_add_token_post(
+ session: routes.CommitterSession, request_form: datastructures.MultiDict
+) -> response.Response | None:
add_form = await AddTokenForm.create_form(data=request_form)
if await add_form.validate_on_submit():
label_val = str(add_form.label.data) if add_form.label.data else None
@@ -238,3 +275,36 @@ async def _handle_post(
return await session.redirect(tokens)
return None
+
+
+async def _handle_delete_token_post(
+ session: routes.CommitterSession, request_form: datastructures.MultiDict
+) -> response.Response | None:
+ del_form = await DeleteTokenForm.create_form(data=request_form)
+ if await del_form.validate_on_submit():
+ token_id_val = int(str(del_form.token_id.data))
+ await _delete_token(session.uid, token_id_val)
+ await quart.flash("Token deleted successfully", "success")
+ return await session.redirect(tokens)
+
+ await quart.flash("Invalid delete request", "error")
+ return None
+
+
+async def _handle_issue_jwt_post(
+ session: routes.CommitterSession, request_form: datastructures.MultiDict
+) -> response.Response | None:
+ issue_form = await IssueJWTForm.create_form(data=request_form)
+ if await issue_form.validate_on_submit():
+ jwt_token = jwtoken.issue(session.uid)
+ success_msg = div[
+ p[
+ strong["Your new JWT"],
+ " is:",
+ ],
+ p[code(".bg-light.border.rounded.px-1.atr-word-wrap")[jwt_token],],
+ ]
+ await quart.flash(_as_markup(success_msg), "success")
+ return await session.redirect(tokens)
+
+ return None
diff --git a/atr/static/css/atr.css b/atr/static/css/atr.css
index 91b0c33..bded6f0 100644
--- a/atr/static/css/atr.css
+++ b/atr/static/css/atr.css
@@ -499,3 +499,8 @@ aside.sidebar nav a:hover {
.atr-nowrap {
white-space: nowrap;
}
+
+.atr-word-wrap {
+ overflow-wrap: anywhere;
+ word-break: break-all;
+}
diff --git a/atr/static/js/create-a-jwt.js b/atr/static/js/create-a-jwt.js
new file mode 100644
index 0000000..e9a56e0
--- /dev/null
+++ b/atr/static/js/create-a-jwt.js
@@ -0,0 +1,33 @@
+"use strict";
+var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P,
generator) {
+ function adopt(value) { return value instanceof P ? value : new P(function
(resolve) { resolve(value); }); }
+ return new (P || (P = Promise))(function (resolve, reject) {
+ function fulfilled(value) { try { step(generator.next(value)); } catch
(e) { reject(e); } }
+ function rejected(value) { try { step(generator["throw"](value)); }
catch (e) { reject(e); } }
+ function step(result) { result.done ? resolve(result.value) :
adopt(result.value).then(fulfilled, rejected); }
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
+ });
+};
+document.addEventListener("DOMContentLoaded", () => {
+ const form = document.getElementById("issue-jwt-form");
+ const output = document.getElementById("jwt-output");
+ if (!form || !output) {
+ return;
+ }
+ form.addEventListener("submit", (e) => __awaiter(void 0, void 0, void 0,
function* () {
+ e.preventDefault();
+ const resp = yield fetch(form.action, {
+ method: "POST",
+ body: new FormData(form),
+ });
+ if (resp.ok) {
+ const token = yield resp.text();
+ output.classList.remove("d-none");
+ output.textContent = token;
+ }
+ else {
+ alert("Failed to fetch JWT");
+ }
+ }));
+});
+//# sourceMappingURL=create-a-jwt.js.map
diff --git a/atr/static/js/create-a-jwt.js.map
b/atr/static/js/create-a-jwt.js.map
new file mode 100644
index 0000000..5288535
--- /dev/null
+++ b/atr/static/js/create-a-jwt.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"create-a-jwt.js","sourceRoot":"","sources":["../ts/create-a-jwt.ts"],"names":[],"mappings":";;;;;;;;;;AAAA,QAAQ,CAAC,gBAAgB,CAAC,kBAAkB,EAAE,GAAS,EAAE;IACvD,MAAM,IAAI,GAAG,QAAQ,CAAC,cAAc,CAAC,gBAAgB,CAA2B,CAAC;IACjF,MAAM,MAAM,GAAG,QAAQ,CAAC,cAAc,CAAC,YAAY,CAAC,CAAC;IAErD,IAAI,CAAC,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;QACrB,OAAO;IACT,CAAC;IAED,IAAI,CAAC,gBAAgB,CAAC,QAAQ,EAAE,CAAO,CAAQ,EAAiB,EAAE;QAChE,CAAC,CAAC,cAAc,EAAE,CAAC;QAEnB,MAAM,IAAI,GAAG,MAAM,KAAK,CAAC,IAAI,CAAC,MAA
[...]
diff --git a/atr/static/ts/create-a-jwt.ts b/atr/static/ts/create-a-jwt.ts
new file mode 100644
index 0000000..91be35b
--- /dev/null
+++ b/atr/static/ts/create-a-jwt.ts
@@ -0,0 +1,25 @@
+document.addEventListener("DOMContentLoaded", (): void => {
+ const form = document.getElementById("issue-jwt-form") as HTMLFormElement |
null;
+ const output = document.getElementById("jwt-output");
+
+ if (!form || !output) {
+ return;
+ }
+
+ form.addEventListener("submit", async (e: Event): Promise<void> => {
+ e.preventDefault();
+
+ const resp = await fetch(form.action, {
+ method: "POST",
+ body: new FormData(form),
+ });
+
+ if (resp.ok) {
+ const token = await resp.text();
+ output.classList.remove("d-none");
+ output.textContent = token;
+ } else {
+ alert("Failed to fetch JWT");
+ }
+ });
+});
diff --git a/atr/templates/blank.html b/atr/templates/blank.html
index 51e23d3..b8d1536 100644
--- a/atr/templates/blank.html
+++ b/atr/templates/blank.html
@@ -11,3 +11,10 @@
{% block content %}
{{ content }}
{% endblock content %}
+
+{% block javascripts %}
+ {{ super() }}
+ {% if javascripts %}
+ {% for js in javascripts %}<script src="{{- js -}}"></script>{% endfor %}
+ {% endif %}
+{% endblock javascripts %}
diff --git a/atr/util.py b/atr/util.py
index 61a2351..22f8d36 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -81,6 +81,8 @@ class FileStat:
class QuartFormTyped(quart_wtf.QuartForm):
"""Quart form with type annotations."""
+ csrf_token = wtforms.HiddenField()
+
@classmethod
async def create_form(
cls: type[F],
@@ -762,6 +764,11 @@ def release_directory_version(release: models.Release) ->
pathlib.Path:
return path
+def static_path(*args: str) -> str:
+ filename = str(pathlib.PurePosixPath(*args))
+ return quart.url_for("static", filename=filename)
+
+
async def thread_messages(
thread_id: str,
) -> AsyncGenerator[tuple[str, dict[str, Any]]]:
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]