auth-yes/ui/components/AdminUserDetailsPage.tsx

541 lines
20 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: 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 class="table-container" style="margin-top: 1.25rem;">
<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: var(--danger); 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 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>
))
)}
</tbody>
</table>
</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>
);
};