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]

Reply via email to