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