feat: OIDC identity management inline in Users admin tab (#72)

Expandable user rows in the console Users tab reveal OIDC identities
linked to each user. Issuer badge, truncated subject, email, relative
last-login time, and unlink action with confirmation modal + audit trail.

Keyboard accessible (tabindex, Enter/Space, aria-expanded, focus-visible).
In-place refresh after unlink (no close/reopen flicker). Audit captures
user_id before delete. Mobile responsive (3-column at <700px).
Reduced-motion support. 2 new admin API endpoints reusing admin.users
permission and existing storage methods.
This commit is contained in:
Patrick Buckley
2026-03-15 03:52:42 -07:00
committed by GitHub
parent 20df7b3034
commit 5f27ed9fca
4 changed files with 461 additions and 1 deletions
+14
View File
@@ -294,6 +294,20 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
error_codes=[404],
tags=["Admin"],
),
# --- OIDC Identities ---
EndpointSpec(
"/v1/api/admin/users/{user_id}/oidc-identities",
"GET",
"List OIDC identities linked to a user",
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/oidc-identities",
"DELETE",
"Unlink an OIDC identity (issuer + subject as query params)",
error_codes=[400, 404],
tags=["Admin"],
),
# --- Schedules ---
EndpointSpec(
"/v1/api/admin/schedules",
+70
View File
@@ -1098,6 +1098,67 @@ async def admin_delete_channel(request: Request) -> JSONResponse:
return JSONResponse({"error": "Channel link not found"}, status_code=404)
# ---------------------------------------------------------------------------
# Admin API endpoints — OIDC identities
# ---------------------------------------------------------------------------
async def admin_list_oidc_identities(request: Request) -> JSONResponse:
"""GET /v1/api/admin/users/{user_id}/oidc-identities — list OIDC links for a user."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.users")
if err:
return err
user_id = request.path_params["user_id"]
identities = storage.list_oidc_identities_for_user(user_id)
return JSONResponse({"oidc_identities": identities})
async def admin_delete_oidc_identity(request: Request) -> JSONResponse:
"""DELETE /v1/api/admin/oidc-identities?issuer=...&subject=... — unlink OIDC identity."""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.users")
if err:
return err
issuer = request.query_params.get("issuer", "")
subject = request.query_params.get("subject", "")
if not issuer or not subject:
return JSONResponse({"error": "issuer and subject required"}, status_code=400)
# Look up before delete so audit captures which user was affected
identity = storage.get_oidc_identity(issuer, subject)
if not identity:
return JSONResponse({"error": "Identity not found"}, status_code=404)
storage.delete_oidc_identity(issuer, subject)
audit_uid, ip = _audit_context(request)
record_audit(
storage,
audit_uid,
"oidc_identity.delete",
"oidc_identity",
f"{issuer}:{subject}",
{"user_id": identity["user_id"]},
ip,
)
return JSONResponse({"status": "ok"})
# ---------------------------------------------------------------------------
# Admin API endpoints — scheduled tasks
# ---------------------------------------------------------------------------
@@ -3628,6 +3689,15 @@ def create_app(
admin_delete_channel,
methods=["DELETE"],
),
Route(
"/api/admin/users/{user_id}/oidc-identities",
admin_list_oidc_identities,
),
Route(
"/api/admin/oidc-identities",
admin_delete_oidc_identity,
methods=["DELETE"],
),
Route("/api/admin/schedules", admin_list_schedules),
Route("/api/admin/schedules", admin_create_schedule, methods=["POST"]),
Route("/api/admin/schedules/{task_id}", admin_get_schedule),
+278 -1
View File
@@ -267,8 +267,13 @@ function _renderUsers(users) {
for (var i = 0; i < users.length; i++) {
var u = users[i];
html +=
'<div class="admin-row" role="listitem">' +
'<div class="admin-row" role="listitem" data-expandable data-user-id="' +
escapeHtml(u.user_id) +
'" data-username="' +
escapeHtml(u.username) +
'" tabindex="0" aria-expanded="false">' +
'<span class="admin-col admin-col-username">' +
'<span class="admin-expand-indicator" aria-hidden="true">\u25b8</span>' +
escapeHtml(u.username) +
"</span>" +
'<span class="admin-col admin-col-name">' +
@@ -307,6 +312,31 @@ function _renderUsers(users) {
);
});
}
// Bind expandable row click + keyboard handlers for OIDC detail panel
var rows = container.querySelectorAll(".admin-row[data-expandable]");
for (var k = 0; k < rows.length; k++) {
(function (row) {
var _expand = function () {
var uid = row.getAttribute("data-user-id");
var uname = row.getAttribute("data-username");
_toggleOidcPanel(uid, uname, row);
};
row.addEventListener("click", function (e) {
if (
e.target.closest(".admin-btn-danger") ||
e.target.closest(".admin-btn-action")
)
return;
_expand();
});
row.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
_expand();
}
});
})(rows[k]);
}
}
function confirmDeleteUser(userId, username) {
@@ -332,6 +362,253 @@ function confirmDeleteUser(userId, username) {
);
}
// ---------------------------------------------------------------------------
// OIDC identity expansion in Users tab
// ---------------------------------------------------------------------------
function _toggleOidcPanel(userId, username, rowEl) {
var existing = rowEl.nextElementSibling;
if (existing && existing.classList.contains("oidc-detail-panel")) {
// Collapse
existing.style.maxHeight = "0";
var indicator = rowEl.querySelector(".admin-expand-indicator");
if (indicator) indicator.classList.remove("expanded");
rowEl.setAttribute("aria-expanded", "false");
setTimeout(function () {
if (existing.parentNode) existing.remove();
}, 160);
return;
}
// Collapse any other open panel first
var openPanels = document.querySelectorAll(
"#admin-users-table .oidc-detail-panel",
);
for (var i = 0; i < openPanels.length; i++) {
openPanels[i].style.maxHeight = "0";
var prevRow = openPanels[i].previousElementSibling;
if (prevRow) {
var ind = prevRow.querySelector(".admin-expand-indicator");
if (ind) ind.classList.remove("expanded");
prevRow.setAttribute("aria-expanded", "false");
}
(function (panel) {
setTimeout(function () {
if (panel.parentNode) panel.remove();
}, 160);
})(openPanels[i]);
}
// Mark expanded
var indicator = rowEl.querySelector(".admin-expand-indicator");
if (indicator) indicator.classList.add("expanded");
rowEl.setAttribute("aria-expanded", "true");
// Create panel (role="none" so it doesn't break the parent role="list")
var panel = document.createElement("div");
panel.className = "oidc-detail-panel";
panel.setAttribute("role", "none");
panel.innerHTML =
'<div class="oidc-detail-inner">' +
'<div class="oidc-detail-header">OIDC Identities</div>' +
'<div class="oidc-detail-body"><span class="oidc-detail-empty">Loading\u2026</span></div>' +
"</div>";
rowEl.after(panel);
// Animate open
requestAnimationFrame(function () {
panel.style.maxHeight = panel.scrollHeight + "px";
});
// Fetch identities
authFetch(
"/v1/api/admin/users/" + encodeURIComponent(userId) + "/oidc-identities",
)
.then(function (r) {
if (!r.ok) throw new Error("Failed");
return r.json();
})
.then(function (data) {
_renderOidcDetail(panel, data.oidc_identities || [], userId, username);
})
.catch(function () {
var body = panel.querySelector(".oidc-detail-body");
if (body)
body.innerHTML =
'<span class="oidc-detail-empty">Failed to load</span>';
});
}
function _renderOidcDetail(panel, identities, userId, username) {
var body = panel.querySelector(".oidc-detail-body");
if (!body) return;
if (!identities.length) {
body.innerHTML =
'<span class="oidc-detail-empty">No OIDC identities linked</span>';
panel.style.maxHeight = panel.scrollHeight + "px";
return;
}
var html = "";
for (var i = 0; i < identities.length; i++) {
var oid = identities[i];
var shortIssuer = _issuerShortName(oid.issuer || "");
var shortSubject =
(oid.subject || "").length > 12
? (oid.subject || "").slice(0, 12) + "\u2026"
: oid.subject || "";
var lastLogin = oid.last_login ? _relativeTime(oid.last_login) : "never";
html +=
'<div class="oidc-identity-row">' +
'<span class="oidc-identity-issuer"><span class="scope-badge">' +
escapeHtml(shortIssuer) +
"</span></span>" +
'<span class="oidc-identity-subject" title="' +
escapeHtml(oid.subject || "") +
'">' +
escapeHtml(shortSubject) +
"</span>" +
'<span class="oidc-identity-email" title="' +
escapeHtml(oid.email || "") +
'">' +
escapeHtml(oid.email || "\u2014") +
"</span>" +
'<span class="oidc-identity-time">' +
escapeHtml(lastLogin) +
"</span>" +
'<span class="oidc-identity-actions">' +
'<button class="admin-btn-danger" aria-label="Unlink ' +
escapeHtml(shortIssuer) +
" identity " +
escapeHtml(shortSubject) +
'" data-oidc-issuer="' +
escapeHtml(oid.issuer || "") +
'" data-oidc-subject="' +
escapeHtml(oid.subject || "") +
'" data-oidc-username="' +
escapeHtml(username) +
'" data-oidc-user-id="' +
escapeHtml(userId) +
'">unlink</button>' +
"</span></div>";
}
body.innerHTML = html;
// Update panel height for animation
panel.style.maxHeight = panel.scrollHeight + "px";
// Bind unlink buttons
var btns = body.querySelectorAll("[data-oidc-issuer]");
for (var j = 0; j < btns.length; j++) {
btns[j].addEventListener("click", function (e) {
e.stopPropagation();
var issuer = this.getAttribute("data-oidc-issuer");
var subject = this.getAttribute("data-oidc-subject");
var uname = this.getAttribute("data-oidc-username");
var uid = this.getAttribute("data-oidc-user-id");
_confirmUnlinkOidc(issuer, subject, uname, uid);
});
}
}
function _confirmUnlinkOidc(issuer, subject, username, userId) {
var shortIssuer = _issuerShortName(issuer);
var shortSubject =
subject.length > 16 ? subject.slice(0, 16) + "\u2026" : subject;
showConfirmModal(
"Unlink OIDC Identity",
"Unlink " +
shortIssuer +
" identity \u2018" +
shortSubject +
"\u2019 from user " +
username +
"?\n\nThe user will need to log in via OIDC again to re-link.",
"Unlink",
function () {
authFetch(
"/v1/api/admin/oidc-identities?issuer=" +
encodeURIComponent(issuer) +
"&subject=" +
encodeURIComponent(subject),
{ method: "DELETE" },
)
.then(function (r) {
if (!r.ok) throw new Error("Unlink failed");
showToast("OIDC identity unlinked");
// Refresh the panel content in place (no close/reopen flicker)
var allRows = document.querySelectorAll(
"#admin-users-table .admin-row[data-expandable]",
);
var targetRow = null;
for (var ri = 0; ri < allRows.length; ri++) {
if (allRows[ri].getAttribute("data-user-id") === userId) {
targetRow = allRows[ri];
break;
}
}
if (targetRow) {
var panel = targetRow.nextElementSibling;
if (panel && panel.classList.contains("oidc-detail-panel")) {
var body = panel.querySelector(".oidc-detail-body");
if (body)
body.innerHTML =
'<span class="oidc-detail-empty">Loading\u2026</span>';
authFetch(
"/v1/api/admin/users/" +
encodeURIComponent(userId) +
"/oidc-identities",
)
.then(function (r2) {
if (!r2.ok) throw new Error("Failed");
return r2.json();
})
.then(function (data) {
_renderOidcDetail(
panel,
data.oidc_identities || [],
userId,
username,
);
})
.catch(function () {
if (body)
body.innerHTML =
'<span class="oidc-detail-empty">Failed to load</span>';
});
}
}
})
.catch(function () {
showToast("Failed to unlink OIDC identity");
});
},
);
}
function _issuerShortName(issuer) {
try {
var host = new URL(issuer).hostname;
if (host.includes("google")) return "google";
if (host.includes("microsoftonline") || host.includes("azure"))
return "azure";
if (host.includes("okta")) return "okta";
if (host.includes("auth0")) return "auth0";
if (host.includes("keycloak")) return "keycloak";
return host.replace(/^(login|accounts|auth|id|sso)\./, "");
} catch (e) {
return issuer || "unknown";
}
}
function _relativeTime(isoStr) {
try {
var then = new Date(
isoStr + (isoStr.includes("Z") || isoStr.includes("+") ? "" : "Z"),
);
var diff = (Date.now() - then.getTime()) / 1000;
if (diff < 60) return "just now";
if (diff < 3600) return Math.floor(diff / 60) + "m ago";
if (diff < 86400) return Math.floor(diff / 3600) + "h ago";
if (diff < 2592000) return Math.floor(diff / 86400) + "d ago";
return isoStr.slice(0, 10);
} catch (e) {
return isoStr || "unknown";
}
}
// ---------------------------------------------------------------------------
// Tokens
// ---------------------------------------------------------------------------
+99
View File
@@ -1891,6 +1891,104 @@
.admin-action-btn-ghost{background:transparent;color:var(--fg-dim);border:1px solid var(--border-strong)}
.admin-action-btn-ghost:hover{color:var(--fg);background:var(--bg-highlight)}
/* ==========================================================================
OIDC detail panel (inline expansion below user row)
========================================================================== */
.oidc-detail-panel {
max-height: 0;
overflow: hidden;
transition: max-height 150ms ease;
margin: 0 8px 0 24px;
}
.oidc-detail-inner {
border: 1px dashed var(--border);
border-radius: var(--radius-sm);
padding: 12px 16px;
margin-bottom: 8px;
background: var(--row-alt);
}
.oidc-detail-header {
font-family: var(--font-display);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-dim);
margin-bottom: 8px;
}
.oidc-detail-header::before {
content: "\25c6 ";
color: var(--accent);
}
.oidc-identity-row {
display: grid;
grid-template-columns: 70px 100px 1fr 60px 50px;
gap: 8px;
padding: 5px 0;
font-size: 12px;
align-items: center;
}
.oidc-identity-row + .oidc-identity-row {
border-top: 1px solid var(--border);
}
.oidc-identity-issuer {
overflow: hidden;
}
.oidc-identity-issuer .scope-badge {
font-size: 10px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.oidc-identity-subject {
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.oidc-identity-email {
color: var(--fg-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.oidc-identity-time {
color: var(--fg-dim);
font-size: 11px;
}
.oidc-identity-actions .admin-btn-danger { font-size: 11px; }
.oidc-detail-empty {
color: var(--fg-dim);
font-size: 12px;
font-style: italic;
}
/* Expand indicator on user rows */
.admin-row[data-expandable] { cursor: pointer; }
.admin-row[data-expandable]:hover { background: var(--bg-highlight); }
.admin-row[data-expandable]:hover .admin-expand-indicator { color: var(--fg); }
.admin-row[data-expandable]:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.admin-expand-indicator {
display: inline-block;
width: 14px;
font-size: 10px;
color: var(--fg-dim);
transition: transform 150ms ease;
transform-origin: center;
}
.admin-expand-indicator.expanded { transform: rotate(90deg); }
@media (max-width: 700px) {
.oidc-identity-row { grid-template-columns: 70px 1fr 50px; }
.oidc-identity-email, .oidc-identity-time { display: none; }
.oidc-detail-panel { margin-left: 8px; }
}
/* ==========================================================================
Reduced motion — console-specific
========================================================================== */
@@ -1909,4 +2007,5 @@
.admin-action-btn, .modal-cancel, .modal-submit { transition: none; }
.admin-modal input, .admin-modal select { transition: none; }
.mcp-status-dot.connecting { animation: none; }
.oidc-detail-panel, .admin-expand-indicator { transition: none; }
}