460 lines
16 KiB
XML
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;"
|
|
>
|
|
← 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>
|
|
);
|
|
};
|