Built-in OAuth2 client-registration admin screen (todo §6); /admin/clients lists/registers/deletes the Hydra OAuth2 clients other apps log in through us with. New src/admin-clients.ts (pure builders + handleAdminClients, mirroring the §5 Users/Roles screens): list (search/paginate over one fetched Hydra page), register (GET form + POST), read-only detail, delete-confirm. src/hydra-admin.ts gains the client half of the admin API — createClient/listClients/getClient/deleteClient over /admin/clients (+ a nextPageToken Link parser like kratos-admin) and the registration fields on OAuth2Client. Register builds a standard authorization-code client (+ refresh_token), confidential (client_secret_basic) or public (PKCE/none), with an optional first-party auto-consent flag; Hydra returns the client_secret once, so the register POST renders the new client's detail page with the one-time secret directly (no PRG) and it is never re-shown (getClient carries no secret; the detail test asserts it). Writes go only to Hydra; gated admin-only (anon->/login, non-admin->403) + every mutation CSRF-guarded via requireAdmin/guardedForm like §5; a Hydra 4xx (bad redirect/scope) re-renders the form (400), a 5xx -> 500 (mirrors oauth-login.ts); :id via safeDecode (malformed->404). Wired into app.ts (/admin/clients, gated on the hydra client present) and the shared adminSection (Users.Groups.Roles.OAuth2 clients, i-globe) so it shows for admins and is invisible otherwise. New views (admin/clients, client-form, client-detail + partials/client-{form,detail}-body) reuse the shell/filter-bar/data-table/field blocks; one .detail-list CSS rule; README Layout/§6 updated. Tests-first: hydra-admin.test.ts (client CRUD contracts incl. Link pagination/404->null/204), admin-clients.test.ts (builder/validation/payload matrix), app.test.ts HTTP integration (gate/list/register-shows-secret-once/invalid+CSRF-reject/detail-hides-secret/delete + malformed-%->404). Stability-reviewer run as a local PR: APPROVE, no Critical/High; addressed its one nit (dropped a dead URL.protocol check in validateClientInput). Boot-verified the client CRUD live against real Hydra v26.2.0 (create->201 w/ one-time secret -> list finds it -> get -> delete -> get null); torn down. typecheck + 274 units green.
This commit is contained in:
17
views/admin/client-detail.ejs
Normal file
17
views/admin/client-detail.ejs
Normal file
@@ -0,0 +1,17 @@
|
||||
<%#
|
||||
OAuth2 client detail page (todo §6): the client-detail body (info · one-time secret · delete) in the
|
||||
shell. Doubles as the post-register page when `created`/`secret` are set.
|
||||
%><%
|
||||
const nav = include("partials/nav-tree", { nodes: model.nav });
|
||||
const body = include("partials/client-detail-body", { client: model.client, created: model.created, csrfToken: model.shell.csrfToken, del: model.delete, secret: model.secret });
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
body,
|
||||
brand: model.shell.brand,
|
||||
breadcrumbs: model.shell.breadcrumbs,
|
||||
csrfToken: model.shell.csrfToken,
|
||||
nav,
|
||||
theme: model.shell.theme,
|
||||
title: model.shell.title,
|
||||
user: model.shell.user,
|
||||
}) %>
|
||||
16
views/admin/client-form.ejs
Normal file
16
views/admin/client-form.ejs
Normal file
@@ -0,0 +1,16 @@
|
||||
<%#
|
||||
OAuth2 client register page (todo §6): the client-form body captured into the app shell.
|
||||
%><%
|
||||
const nav = include("partials/nav-tree", { nodes: model.nav });
|
||||
const body = include("partials/client-form-body", { error: model.error, form: model.form });
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
body,
|
||||
brand: model.shell.brand,
|
||||
breadcrumbs: model.shell.breadcrumbs,
|
||||
csrfToken: model.shell.csrfToken,
|
||||
nav,
|
||||
theme: model.shell.theme,
|
||||
title: model.shell.title,
|
||||
user: model.shell.user,
|
||||
}) %>
|
||||
21
views/admin/clients.ejs
Normal file
21
views/admin/clients.ejs
Normal file
@@ -0,0 +1,21 @@
|
||||
<%#
|
||||
OAuth2 clients admin list (todo §6): apps that log in *through* us (Hydra). Same building blocks as
|
||||
the Roles screen, around the shell, backed by live Hydra OAuth2 clients (src/admin-clients.ts).
|
||||
%><%
|
||||
const nav = include("partials/nav-tree", { nodes: model.nav });
|
||||
const filters = include("partials/filter-bar", model.filterBar);
|
||||
const table = include("partials/data-table", model.table);
|
||||
const pager = include("partials/pagination", model.pagination);
|
||||
const actions = '<a class="btn btn-primary" href="/admin/clients/new"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Register client</a>';
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
actions,
|
||||
body: filters + table + pager,
|
||||
brand: model.shell.brand,
|
||||
breadcrumbs: model.shell.breadcrumbs,
|
||||
csrfToken: model.shell.csrfToken,
|
||||
nav,
|
||||
theme: model.shell.theme,
|
||||
title: model.shell.title,
|
||||
user: model.shell.user,
|
||||
}) %>
|
||||
37
views/partials/client-detail-body.ejs
Normal file
37
views/partials/client-detail-body.ejs
Normal file
@@ -0,0 +1,37 @@
|
||||
<%#
|
||||
Admin OAuth2 client detail body (todo §6), captured into the shell content slot. Config:
|
||||
client { firstParty, id, name, public, redirectUris[], scopes[] }
|
||||
created bool just registered → success banner
|
||||
secret? string one-time client secret (confidential clients), shown once right after create
|
||||
del { action } delete the client
|
||||
csrfToken
|
||||
%><%
|
||||
const c = locals.client;
|
||||
const del = locals.del;
|
||||
-%>
|
||||
<div class="form-page">
|
||||
<% if (locals.created) { -%>
|
||||
<%- include("alert", { text: "Client registered.", tone: "pos" }) %>
|
||||
<% } -%>
|
||||
<% if (locals.secret) { -%>
|
||||
<section class="form-card" aria-labelledby="secret-h">
|
||||
<h2 class="card-title" id="secret-h">Client secret</h2>
|
||||
<p class="field-hint">Copy these now — the secret can't be shown again. Store them where the app reads its credentials.</p>
|
||||
<div class="field"><label for="cid">Client ID</label><input class="input" id="cid" type="text" value="<%= c.id %>" readonly></div>
|
||||
<div class="field"><label for="csecret">Client secret</label><input class="input" id="csecret" type="text" value="<%= locals.secret %>" readonly></div>
|
||||
</section>
|
||||
<% } -%>
|
||||
<section class="form-card" aria-labelledby="client-h">
|
||||
<h2 class="card-title" id="client-h"><%= c.name %></h2>
|
||||
<dl class="detail-list">
|
||||
<dt>Client ID</dt><dd><%= c.id %></dd>
|
||||
<dt>Type</dt><dd><%= c.public ? "Public (PKCE)" : "Confidential" %></dd>
|
||||
<dt>Consent</dt><dd><%= c.firstParty ? "First-party (auto-granted)" : "Shows the consent screen" %></dd>
|
||||
<dt>Scopes</dt><dd><%= c.scopes.length ? c.scopes.join(" ") : "—" %></dd>
|
||||
<dt>Redirect URIs</dt><dd><% if (c.redirectUris.length) { %><ul class="plain-list"><% c.redirectUris.forEach((u) => { %><li><%= u %></li><% }) %></ul><% } else { %>—<% } %></dd>
|
||||
</dl>
|
||||
</section>
|
||||
<section class="form-card admin-actions" aria-label="Client actions">
|
||||
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete client</a>
|
||||
</section>
|
||||
</div>
|
||||
29
views/partials/client-form-body.ejs
Normal file
29
views/partials/client-form-body.ejs
Normal file
@@ -0,0 +1,29 @@
|
||||
<%#
|
||||
Admin OAuth2 client register form body (todo §6), captured into the shell content slot. Config:
|
||||
form { action, csrfToken, submitLabel, cancelHref, nameField, scopeField (field.ejs configs),
|
||||
redirectUris: string (newline-separated), public: bool, firstParty: bool }
|
||||
error? string shown when a write was rejected
|
||||
%><%
|
||||
const form = locals.form;
|
||||
-%>
|
||||
<div class="form-page">
|
||||
<% if (locals.error) { -%>
|
||||
<%- include("alert", { text: locals.error, tone: "neg" }) %>
|
||||
<% } -%>
|
||||
<form class="form-card" method="post" action="<%= form.action %>">
|
||||
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
||||
<%- include("field", form.nameField) %>
|
||||
<div class="field">
|
||||
<label for="redirectUris">Redirect URIs</label>
|
||||
<textarea class="input" id="redirectUris" name="redirectUris" rows="3" placeholder="https://app.example.com/callback"><%= form.redirectUris %></textarea>
|
||||
<span class="field-hint">One per line — where the app is sent back after sign-in.</span>
|
||||
</div>
|
||||
<%- include("field", form.scopeField) %>
|
||||
<label class="check"><input type="checkbox" name="public"<% if (form.public) { %> checked<% } %>> Public client (SPA / native app, PKCE — no secret)</label>
|
||||
<label class="check"><input type="checkbox" name="firstParty"<% if (form.firstParty) { %> checked<% } %>> First-party (auto-grant consent — skip the consent screen)</label>
|
||||
<div class="form-actions">
|
||||
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
|
||||
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
Reference in New Issue
Block a user