auth-yes/ui/components/AdminUserDetailsPage.tsx

460 lines
16 KiB
XML

import { AdminLayout } from "./AdminLayout.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: 4px; font-size: 0.9rem;"
/>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<div>
<h2 style="margin: 0; border: none; padding: 0;">
User Profile: {user.username}
</h2>
<span style="font-size: 0.85rem; color: #6c757d;">
UUID: {user.id}
</span>
</div>
<a
href="/admin/users"
style="color: #007bff; text-decoration: none; font-weight: 500;"
>
&larr; Back to Users
</a>
</div>
{/* Application RBAC Access Matrix */}
<div class="card" style="border-left: 4px solid #0d6efd;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<h3 style="margin: 0;">Application Access & RBAC Grants</h3>
<p style="color: #6c757d; 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: 1rem; background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px;">
<h4 style="margin: 0 0 0.5rem 0; font-size: 0.9rem;">
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.2rem;">
Application
</label>
<select
id="grantAppId"
onchange="updateRoleOptions()"
required
style="width: 100%; padding: 0.45rem; border: 1px solid #ced4da; border-radius: 4px; background: white;"
>
{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.2rem;">
Assigned Role
</label>
<select
id="grantRole"
required
style="width: 100%; padding: 0.45rem; border: 1px solid #ced4da; border-radius: 4px; background: white;"
>
{/* Dynamically populated */}
</select>
</div>
<button
type="submit"
class="btn-action btn-success"
style="padding: 0.5rem 1rem; height: fit-content;"
>
Save Grant
</button>
</form>
</div>
<div class="table-container" style="margin-top: 1rem;">
<table>
<thead>
<tr>
<th>Application Name</th>
<th>SPIFFE Workload ID</th>
<th>Assigned Role</th>
<th>Granted At</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{grants.length === 0
? (
<tr>
<td
colspan={5}
style="text-align: center; color: #dc3545; padding: 1.5rem;"
>
No application permissions granted (User is blocked from
all subsidiary apps).
</td>
</tr>
)
: (
grants.map((grant) => (
<tr key={grant.id}>
<td>
<strong>{grant.app_name}</strong>
</td>
<td>
<code style="background: #e9ecef; padding: 0.2rem 0.4rem; border-radius: 3px; font-size: 0.8rem; color: #0d6efd;">
{grant.spiffe_id}
</code>
</td>
<td>
<span
class={`badge ${
grant.role === "admin"
? "badge-suspended"
: "badge-info"
}`}
>
{grant.role}
</span>
</td>
<td style="font-size: 0.85rem;">
{new Date(grant.created_at).toLocaleDateString()}
</td>
<td>
<button
type="button"
class="btn-action btn-warning"
onclick={`revokeGrant('${user.id}', '${grant.app_id}', '${grant.app_name}')`}
>
Revoke Access
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Out-of-band Recovery */}
<div class="card">
<h3>Out-of-Band Account Recovery</h3>
<p style="color: #6c757d; font-size: 0.9rem;">
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-action btn-success"
onclick={`generateRecoveryLink('${user.id}')`}
>
Generate Recovery Link
</button>
<div
id="recovery-link-container"
style="display: none; margin-top: 1rem; padding: 1rem; background: #f8f9fa; border: 1px solid #ced4da; border-radius: 4px;"
>
<p style="margin-top: 0; font-weight: 500;">
Provide this link to the user:
</p>
<code
id="recovery-link-text"
style="display: block; word-break: break-all; margin-bottom: 0.5rem; color: #d63384;"
>
</code>
<p style="margin-bottom: 0; font-size: 0.85rem; color: #6c757d;">
Link expires in 24 hours.
</p>
</div>
</div>
{/* Active Sessions */}
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h3 style="margin: 0;">Active Sessions</h3>
<button
type="button"
class="btn-action btn-warning"
onclick={`revokeAllSessions('${user.id}')`}
>
Revoke All Sessions
</button>
</div>
<div class="table-container" style="margin-top: 1rem;">
<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: #6c757d;">
No active sessions.
</td>
</tr>
)
: (
sessions.map((session) => (
<tr key={session.id}>
<td>
<code style="background: #f8f9fa; padding: 0.2rem 0.4rem; border-radius: 3px;">
{session.id.substring(0, 8)}...
</code>
</td>
<td>{new Date(session.created_at).toLocaleString()}</td>
<td>{new Date(session.expires_at).toLocaleString()}</td>
<td>
<button
type="button"
class="btn-action btn-warning"
onclick={`revokeSession('${session.id}')`}
>
Revoke
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Registered Passkeys */}
<div class="card">
<h3 style="margin-top: 0;">Registered Passkeys</h3>
<div class="table-container" style="margin-top: 1rem;">
<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: #6c757d;">
No registered passkeys.
</td>
</tr>
)
: (
passkeys.map((pk) => (
<tr key={pk.id}>
<td>
<code style="background: #f8f9fa; padding: 0.2rem 0.4rem; border-radius: 3px; word-break: break-all;">
{pk.credential_id.substring(0, 32)}...
</code>
</td>
<td>{pk.counter}</td>
<td>
<button
type="button"
class="btn-action btn-warning"
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);
});
}
// Initial populate
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 ? '#f8d7da' : '#d1e7dd';
banner.style.color = isError ? '#842029' : '#0f5132';
banner.style.border = isError ? '1px solid #f5c2c7' : '1px solid #badbcc';
setTimeout(() => { banner.style.display = 'none'; }, 6000);
}
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(), 800);
} 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(), 800);
} else {
const data = await res.json();
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(), 800);
} 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(), 800);
} 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(), 800);
} else {
showNotice(data.error || 'Failed to delete passkey', true);
}
} catch (err) {
showNotice('Network error', true);
}
}
`,
}}
/>
</AdminLayout>
);
};