- Replace inline script in AdminRolesPage with extracted AdminRolesScript component - Remove unused JSX import aliases and format AdminUserDetailsPage
339 lines
12 KiB
TypeScript
339 lines
12 KiB
TypeScript
import { AdminLayout } from "./AdminLayout.tsx";
|
|
import { AdminTable } from "./admin/AdminTable.tsx";
|
|
import { GrantDrawer } from "./admin/drawers/GrantDrawer.tsx";
|
|
import { AdminUserDetailsScript } from "./admin/AdminUserDetailsScript.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;"
|
|
>
|
|
← 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 */}
|
|
<GrantDrawer user={user} allApps={allApps} />
|
|
|
|
<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>
|
|
|
|
<AdminUserDetailsScript allRoles={allRoles} />
|
|
</AdminLayout>
|
|
);
|
|
};
|