auth-yes/ui/components/AdminUserDetailsPage.tsx
google-labs-jules[bot] 4c7d8d4af7 feat(ui): decompose admin pages into reusable AdminTable and AdminModal components
Refactored AdminInvitesPage, AdminUserDetailsPage, AdminRolesPage, and AdminAppsPage to use the new pure Hono SSR JSX stateless components.
Fixed missing import definitions in AdminRolesPage.
Moved task file to complete state.

Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com>
2026-08-26 00:34:29 +00:00

553 lines
21 KiB
XML

import { AdminLayout } from "./AdminLayout.tsx";
import { AdminTable } from "./admin/AdminTable.tsx";
export const AdminUserDetailsPage = ({
user,
sessions,
passkeys,
grants = [],
allApps = [],
allRoles = [],
}: {
user: any;
sessions: any[];
passkeys: any[];
grants?: any[];
allApps?: any[];
allRoles?: any[];
}) => {
return (
<AdminLayout title={`User: ${user.username}`} currentPath="/admin/users">
<div
id="status-banner"
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
/>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
<div>
<h1 style="margin: 0 0 0.25rem 0; font-size: 1.75rem; font-weight: 700; color: var(--text-primary);">
User Profile: @{user.username}
</h1>
<span style="font-size: 0.85rem; color: var(--text-muted); font-family: monospace;">
UUID: {user.id}
</span>
</div>
<a
href="/admin/users"
class="btn-outline"
style="text-decoration: none; font-size: 0.85rem; min-height: 36px;"
>
&larr; Back to Users
</a>
</div>
{/* Profile & Display Name Editor Card */}
<div class="card" style="margin-bottom: 1.5rem;">
<h3 style="margin: 0 0 0.5rem 0; color: var(--text-primary);">
Identity Details & Display Name
</h3>
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1.25rem 0;">
Human-friendly display name passed to connected apps in the{" "}
<code style="font-family: monospace;">X-Forwarded-User</code> header.
</p>
<form
id="editProfileForm"
onsubmit={`handleUpdateProfile(event, '${user.id}')`}
style="display: flex; gap: 0.75rem; align-items: flex-end; flex-wrap: wrap; max-width: 550px;"
>
<div style="flex: 1; min-width: 240px;">
<label style="display: block; font-size: 0.85rem; font-weight: 600; margin-bottom: 0.35rem; color: var(--text-secondary);">
Display Name
</label>
<input
type="text"
id="displayNameInput"
value={user.display_name || ""}
placeholder={`e.g. Tyler Gillispie (defaults to @${user.username})`}
style="width: 100%;"
/>
</div>
<button
type="submit"
class="btn-primary"
style="min-height: 44px;"
>
Save Display Name
</button>
</form>
</div>
{/* Application RBAC Access Matrix */}
<div
class="card"
style="border-left: 4px solid var(--primary); margin-bottom: 1.5rem;"
>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
<div>
<h3 style="margin: 0; color: var(--text-primary);">
Application Access & RBAC Grants
</h3>
<p style="color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.2rem; margin-bottom: 0;">
Manage this user's explicit permissions across registered
applications (Default-Deny Zero-Trust).
</p>
</div>
</div>
{/* Grant New Application Form */}
<div style="margin-top: 1rem; padding: 1.25rem; background: var(--surface-muted); border: 1px solid var(--border-subtle); border-radius: var(--radius-md);">
<h4 style="margin: 0 0 0.75rem 0; font-size: 0.95rem; color: var(--text-primary);">
Assign / Update Application Access
</h4>
<form
id="grantAccessForm"
onsubmit={`handleGrantAccess(event, '${user.id}')`}
style="display: flex; gap: 0.8rem; align-items: flex-end; flex-wrap: wrap;"
>
<div style="flex: 2; min-width: 200px;">
<label style="display: block; font-size: 0.8rem; font-weight: 600; margin-bottom: 0.3rem; color: var(--text-secondary);">
Application
</label>
<select
id="grantAppId"
onchange="updateRoleOptions()"
required
style="width: 100%;"
>
{allApps.map((app) => (
<option value={app.id}>
{app.name} ({app.spiffe_id})
</option>
))}
</select>
</div>
<div style="flex: 1; min-width: 140px;">
<label style="display: block; font-size: 0.8rem; font-weight: 600; margin-bottom: 0.3rem; color: var(--text-secondary);">
Assigned Role
</label>
<select
id="grantRole"
required
style="width: 100%;"
>
{/* Dynamically populated */}
</select>
</div>
<button
type="submit"
class="btn-primary"
style="min-height: 44px;"
>
Save Grant
</button>
</form>
</div>
<div style="margin-top: 1.25rem;">
<AdminTable
id="grantsTable"
headers={[
"Application Name",
"SPIFFE Workload ID",
"Assigned Role",
"Granted At",
"Actions",
]}
isEmpty={grants.length === 0}
emptyState="No application permissions granted (User is blocked from all subsidiary apps)."
desktopRows={grants.map((grant) => (
<tr key={grant.id}>
<td>
<strong style="color: var(--text-primary);">
{grant.app_name}
</strong>
</td>
<td>
<code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); font-size: 0.8rem; font-family: monospace;">
{grant.spiffe_id}
</code>
</td>
<td>
<span class="badge badge-info">
{grant.role}
</span>
</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">
{new Date(grant.created_at).toLocaleDateString()}
</td>
<td>
<button
type="button"
class="btn-danger"
style="padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px;"
onclick={`revokeGrant('${user.id}', '${grant.app_id}', '${grant.app_name}')`}
>
Revoke Access
</button>
</td>
</tr>
))}
mobileCards={grants.map((grant) => (
<div class="card" key={grant.id} style="margin-bottom: 0;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem;">
<strong style="color: var(--text-primary);">
{grant.app_name}
</strong>
<span class="badge badge-info">{grant.role}</span>
</div>
<div style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 0.75rem; font-family: monospace;">
{grant.spiffe_id}
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 0.8rem; color: var(--text-secondary);">
{new Date(grant.created_at).toLocaleDateString()}
</span>
<button
type="button"
class="btn-danger"
style="padding: 0.25rem 0.5rem; font-size: 0.75rem;"
onclick={`revokeGrant('${user.id}', '${grant.app_id}', '${grant.app_name}')`}
>
Revoke Access
</button>
</div>
</div>
))}
/>
</div>
</div>
{/* Out-of-band Recovery */}
<div class="card" style="margin-bottom: 1.5rem;">
<h3 style="margin: 0 0 0.5rem 0; color: var(--text-primary);">
Out-of-Band Account Recovery
</h3>
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1rem 0;">
Generate a one-time recovery link to allow the user to bind a new
hardware passkey if all devices are lost.
</p>
<button
type="button"
class="btn-primary"
onclick={`generateRecoveryLink('${user.id}')`}
>
Generate Recovery Link
</button>
<div
id="recovery-link-container"
style="display: none; margin-top: 1rem; padding: 1rem; background: var(--surface-muted); border: 1px solid var(--border-subtle); border-radius: var(--radius-md);"
>
<p style="margin-top: 0; font-weight: 600; color: var(--text-primary);">
Provide this link to the user:
</p>
<code
id="recovery-link-text"
style="display: block; word-break: break-all; margin-bottom: 0.5rem; color: var(--primary); font-family: monospace; background: var(--surface-card); padding: 0.5rem; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle);"
>
</code>
<p style="margin-bottom: 0; font-size: 0.85rem; color: var(--text-muted);">
Link expires in 24 hours.
</p>
</div>
</div>
{/* Active Sessions */}
<div class="card" style="margin-bottom: 1.5rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 style="margin: 0; color: var(--text-primary);">
Active Sessions
</h3>
<button
type="button"
class="btn-danger"
onclick={`revokeAllSessions('${user.id}')`}
>
Revoke All Sessions
</button>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Session ID</th>
<th>Created</th>
<th>Expires</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{sessions.length === 0
? (
<tr>
<td
colSpan={4}
style="text-align: center; color: var(--text-muted); padding: 1.5rem;"
>
No active sessions.
</td>
</tr>
)
: (
sessions.map((session) => (
<tr key={session.id}>
<td>
<code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); font-family: monospace;">
{session.id.substring(0, 12)}...
</code>
</td>
<td style="color: var(--text-secondary);">
{new Date(session.created_at).toLocaleString()}
</td>
<td style="color: var(--text-secondary);">
{new Date(session.expires_at).toLocaleString()}
</td>
<td>
<button
type="button"
class="btn-danger"
style="padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px;"
onclick={`revokeSession('${session.id}')`}
>
Revoke
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Registered Passkeys */}
<div class="card">
<h3 style="margin: 0 0 1rem 0; color: var(--text-primary);">
Registered Passkeys
</h3>
<div class="table-container">
<table>
<thead>
<tr>
<th>Credential ID</th>
<th>Counter</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{passkeys.length === 0
? (
<tr>
<td
colSpan={3}
style="text-align: center; color: var(--text-muted); padding: 1.5rem;"
>
No registered passkeys.
</td>
</tr>
)
: (
passkeys.map((pk) => (
<tr key={pk.id}>
<td>
<code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); word-break: break-all; font-family: monospace;">
{pk.credential_id.substring(0, 32)}...
</code>
</td>
<td style="color: var(--text-secondary);">
{pk.counter}
</td>
<td>
<button
type="button"
class="btn-danger"
style="padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px;"
onclick={`deletePasskey('${user.id}', '${pk.id}')`}
>
Delete Device
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
<script
dangerouslySetInnerHTML={{
__html: `
const ROLES_CATALOG = ${JSON.stringify(allRoles)};
function updateRoleOptions() {
const appId = document.getElementById('grantAppId').value;
const roleSelect = document.getElementById('grantRole');
roleSelect.innerHTML = '';
const available = ROLES_CATALOG.filter(r => !r.app_id || r.app_id === appId);
if (available.length === 0) {
const opt = document.createElement('option');
opt.value = 'user';
opt.textContent = 'user';
roleSelect.appendChild(opt);
return;
}
available.forEach(r => {
const opt = document.createElement('option');
opt.value = r.name;
opt.textContent = r.name + (r.app_id ? ' (App Custom)' : ' (Global)');
roleSelect.appendChild(opt);
});
}
if (document.getElementById('grantAppId')) {
updateRoleOptions();
}
function showNotice(msg, isError) {
const banner = document.getElementById('status-banner');
banner.textContent = msg;
banner.style.display = 'block';
banner.style.background = isError ? 'var(--danger-bg)' : 'var(--success-bg)';
banner.style.color = isError ? 'var(--danger-text)' : 'var(--success-text)';
banner.style.border = isError ? '1px solid var(--danger-border)' : '1px solid var(--success-border)';
setTimeout(() => { banner.style.display = 'none'; }, 5000);
}
async function handleUpdateProfile(e, userId) {
e.preventDefault();
const displayName = document.getElementById('displayNameInput').value.trim();
try {
const res = await fetch('/api/admin/users/' + userId + '/profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ displayName }),
});
const data = await res.json();
if (res.ok) {
showNotice('User display name saved successfully!', false);
setTimeout(() => window.location.reload(), 600);
} else {
showNotice(data.error || 'Failed to update display name', true);
}
} catch (err) {
showNotice('Network error updating display name', true);
}
}
async function handleGrantAccess(e, userId) {
e.preventDefault();
const appId = document.getElementById('grantAppId').value;
const role = document.getElementById('grantRole').value;
try {
const res = await fetch('/api/admin/users/' + userId + '/grants', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ appId, role }),
});
const data = await res.json();
if (res.ok) {
showNotice('Application access granted successfully!', false);
setTimeout(() => window.location.reload(), 600);
} else {
showNotice(data.error || 'Failed to update application grant', true);
}
} catch (err) {
showNotice('Network error updating grant', true);
}
}
async function revokeGrant(userId, appId, appName) {
if (!confirm('Revoke access to "' + appName + '" for this user?')) return;
try {
const res = await fetch('/api/admin/users/' + userId + '/grants/' + appId, {
method: 'DELETE',
});
if (res.ok) {
showNotice('Access revoked', false);
setTimeout(() => window.location.reload(), 600);
} else {
showNotice(data.error || 'Failed to revoke grant', true);
}
} catch (err) {
showNotice('Network error revoking grant', true);
}
}
async function generateRecoveryLink(userId) {
try {
const res = await fetch('/api/admin/users/' + userId + '/recovery', { method: 'POST' });
const data = await res.json();
if (res.ok) {
const link = window.location.origin + '/recovery?code=' + data.recoveryCode;
document.getElementById('recovery-link-text').textContent = link;
document.getElementById('recovery-link-container').style.display = 'block';
showNotice('Recovery link generated!', false);
} else {
showNotice(data.error || 'Failed to generate link', true);
}
} catch (err) {
showNotice('Network error', true);
}
}
async function revokeSession(sessionId) {
if (!confirm('Revoke this session?')) return;
try {
const res = await fetch('/api/admin/sessions/' + sessionId, { method: 'DELETE' });
if (res.ok) {
showNotice('Session revoked', false);
setTimeout(() => window.location.reload(), 600);
} else {
showNotice('Failed to revoke session', true);
}
} catch (err) {
showNotice('Network error', true);
}
}
async function revokeAllSessions(userId) {
if (!confirm('Revoke ALL sessions for this user? They will be immediately logged out.')) return;
try {
const res = await fetch('/api/admin/users/' + userId + '/sessions', { method: 'DELETE' });
if (res.ok) {
showNotice('All sessions revoked', false);
setTimeout(() => window.location.reload(), 600);
} else {
showNotice('Failed to revoke all sessions', true);
}
} catch (err) {
showNotice('Network error', true);
}
}
async function deletePasskey(userId, passkeyId) {
if (!confirm('Permanently delete this device? The user will no longer be able to log in with it.')) return;
try {
const res = await fetch('/api/admin/users/' + userId + '/passkeys/' + passkeyId, { method: 'DELETE' });
const data = await res.json();
if (res.ok) {
showNotice('Passkey deleted', false);
setTimeout(() => window.location.reload(), 600);
} else {
showNotice(data.error || 'Failed to delete passkey', true);
}
} catch (err) {
showNotice('Network error', true);
}
}
`,
}}
>
</script>
</AdminLayout>
);
};