mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
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:
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user