auth-yes/ui/components/AdminInvitesPage.tsx
google-labs-jules[bot] 4c7d8d4af7 feat(ui): decompose admin pages into reusable AdminTable and AdminModal components
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>
2026-08-26 00:34:29 +00:00

697 lines
27 KiB
TypeScript

import { AdminLayout } from "./AdminLayout.tsx";
import { AdminTable } from "./admin/AdminTable.tsx";
import { AdminModal } from "./admin/AdminModal.tsx";
export const AdminInvitesPage = ({
invites,
apps,
allRoles = [],
}: {
invites: any[];
apps: any[];
allRoles?: any[];
}) => {
return (
<AdminLayout
title="Invite & Onboarding Tokens"
currentPath="/admin/invites"
>
<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: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
<div>
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
Invite & Onboarding Tokens
</h1>
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
Issue single-use, team limited-use, or campaign registration tokens.
</p>
</div>
<div style="display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap;">
{/* Instant Search Bar */}
<div style="position: relative; min-width: 220px;">
<input
type="text"
id="inviteSearchInput"
placeholder="Search invite tokens..."
oninput="filterInvitesList()"
style="width: 100%; padding: 0.5rem 1rem 0.5rem 2.25rem; font-size: 0.875rem;"
/>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
style="position: absolute; left: 0.75rem; top: 50%; transform: translateY(-50%); color: var(--text-muted); pointer-events: none;"
>
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</div>
<button
type="button"
class="btn-primary"
style="min-height: 40px;"
onclick="toggleCreateInviteForm()"
>
+ Generate Token
</button>
</div>
</div>
<AdminModal
id="createInviteModal"
title="Generate User Onboarding Token"
onClose="toggleCreateInviteForm()"
>
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1.25rem 0;">
Configure time bounds, usage capacity, role assignments, and initial
account activation status.
</p>
<form id="createInviteForm" onsubmit="handleCreateInvite(event)">
{/* Row 1: Type & Target App */}
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; margin-bottom: 1rem;">
<div>
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
Token Provisioning Type *
</label>
<select
id="inviteType"
onchange="handleInviteTypeChange()"
style="width: 100%;"
>
<option value="site_scoped">
Type 2: Site-Scoped Token (Pre-Authorized for App)
</option>
<option value="global_admin">
Type 1: Global Admin Token (Full System Access)
</option>
<option value="open_pending">
Type 3: General Open Token (Unassigned Access)
</option>
</select>
</div>
<div id="appSelectContainer">
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
Target Application *
</label>
<select
id="inviteAppId"
onchange="updateInviteRoleOptions()"
style="width: 100%;"
>
{apps.map((app) => (
<option value={app.id}>
{app.name} ({app.spiffe_id})
</option>
))}
</select>
</div>
</div>
{/* Row 2: Usage Limits & Assigned Role */}
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1rem;">
<div>
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
Usage Capacity *
</label>
<select
id="inviteUsageType"
onchange="handleUsageTypeChange()"
style="width: 100%;"
>
<option value="single">
Single-Use (1 Person - Max Security)
</option>
<option value="limited">
Limited Multi-Use (Cap at N People)
</option>
<option value="unlimited">
Unlimited Time-Bound (Campaign / Beta)
</option>
</select>
</div>
<div id="maxUsesContainer" style="display: none;">
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
Max Registrations *
</label>
<input
type="number"
id="inviteMaxUses"
value="5"
min="2"
max="1000"
style="width: 100%;"
/>
</div>
<div id="roleSelectContainer">
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
Assigned Role *
</label>
<select
id="inviteRole"
style="width: 100%;"
>
{/* Dynamically populated */}
</select>
</div>
</div>
{/* Row 3: Expiration, Custom Code, Activation Toggle */}
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 1.25rem; align-items: flex-end;">
<div>
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
Expires In (Days)
</label>
<input
type="number"
id="inviteExpiresInDays"
value="7"
min="1"
max="30"
style="width: 100%;"
/>
</div>
<div>
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; color: var(--text-secondary);">
Custom Code (Optional)
</label>
<input
type="text"
id="inviteCustomCode"
placeholder="Leave blank to auto-generate"
style="width: 100%;"
/>
</div>
<div style="padding-bottom: 0.5rem;">
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; font-weight: 600; cursor: pointer; color: var(--text-primary);">
<input
type="checkbox"
id="inviteAutoActivate"
checked
style="width: 18px; height: 18px;"
/>
Auto-Activate Account
</label>
</div>
</div>
<div style="display: flex; gap: 0.75rem;">
<button type="submit" class="btn-primary" style="min-height: 42px;">
Create Invite Token
</button>
<button
type="button"
class="btn-outline"
onclick="toggleCreateInviteForm()"
style="min-height: 42px;"
>
Cancel
</button>
</div>
</form>
<div
id="generated-token-banner"
style="display: none; margin-top: 1.25rem; padding: 1rem; background: var(--success-bg); border: 1px solid var(--success-border); border-radius: var(--radius-md);"
>
<strong style="color: var(--success-text);">
Token Created Successfully!
</strong>
<div style="margin-top: 0.5rem; display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<code
id="generatedTokenUrl"
style="padding: 0.5rem 0.75rem; background: var(--surface-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); font-size: 0.85rem; flex: 1; min-width: 220px; word-break: break-all; font-family: monospace; color: var(--primary);"
>
</code>
<button
type="button"
class="btn-primary"
onclick="copyGeneratedTokenUrl()"
>
Copy Link
</button>
</div>
</div>
</AdminModal>
<AdminTable
id="invitesTable"
headers={[
"Invite Code",
"Target App / Scope",
"Role",
"Capacity & Usage",
"Status",
"Expires",
"Actions",
]}
isEmpty={invites.length === 0}
emptyState="No active or historical invite tokens found."
desktopRows={invites.map((inv) => {
const usesCount = inv.uses_count || 0;
const maxUses = inv.max_uses;
const isUnlimited = maxUses === null;
const isExhausted = !isUnlimited && usesCount >= maxUses;
const isExpired = new Date(inv.expires_at) < new Date();
const isActive = !isExhausted && !isExpired;
return (
<tr
key={inv.id}
class="invite-row"
data-search={`${inv.code} ${inv.app_name || ""} ${inv.role}`
.toLowerCase()}
>
<td>
<code style="background: var(--surface-muted); padding: 0.25rem 0.5rem; border-radius: var(--radius-sm); font-weight: 700; font-family: monospace; color: var(--primary);">
{inv.code}
</code>
</td>
<td style="font-size: 0.85rem;">
{inv.app_name || (
<span style="color: var(--text-muted); font-style: italic;">
Global / Open
</span>
)}
</td>
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
{inv.role}
</td>
<td style="font-size: 0.85rem;">
{isUnlimited ? "Unlimited" : `${usesCount} / ${maxUses}`}
</td>
<td>
{isExhausted && (
<span class="badge badge-secondary">Exhausted</span>
)}
{isExpired && !isExhausted && (
<span class="badge badge-danger">Expired</span>
)}
{isActive && <span class="badge badge-success">Active</span>}
</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">
{new Date(inv.expires_at).toLocaleDateString()}
</td>
<td>
<div style="display: flex; gap: 0.35rem; flex-wrap: wrap;">
{isActive && (
<button
type="button"
class="btn-primary"
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
onclick={`copyInviteLink('${inv.code}')`}
>
Copy Link
</button>
)}
{usesCount > 0 && (
<button
type="button"
class="btn-outline"
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
onclick={`showRedemptionsModal('${inv.id}', '${inv.code}')`}
>
Claimed ({usesCount})
</button>
)}
{isActive && (
<button
type="button"
class="btn-danger"
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 32px;"
onclick={`revokeInvite('${inv.id}', '${inv.code}')`}
>
Revoke
</button>
)}
</div>
</td>
</tr>
);
})}
mobileCards={invites.map((inv) => {
const usesCount = inv.uses_count || 0;
const maxUses = inv.max_uses;
const isUnlimited = maxUses === null;
const isExhausted = !isUnlimited && usesCount >= maxUses;
const isExpired = new Date(inv.expires_at) < new Date();
const isActive = !isExhausted && !isExpired;
return (
<div
class="card invite-card"
key={inv.id}
data-search={`${inv.code} ${inv.app_name || ""} ${inv.role}`
.toLowerCase()}
style="margin-bottom: 0; padding: 1rem;"
>
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem;">
<div>
<code style="background: var(--surface-muted); padding: 0.25rem 0.5rem; border-radius: var(--radius-sm); font-weight: 700; font-family: monospace; font-size: 1rem; color: var(--primary);">
{inv.code}
</code>
<div style="font-size: 0.8rem; color: var(--text-muted); margin-top: 0.25rem;">
{inv.app_name || "Global / Open"} Role:{" "}
<strong>{inv.role}</strong>
</div>
</div>
{isExhausted && (
<span class="badge badge-secondary">Exhausted</span>
)}
{isExpired && !isExhausted && (
<span class="badge badge-danger">Expired</span>
)}
{isActive && <span class="badge badge-success">Active</span>}
</div>
<div style="margin-bottom: 0.75rem;">
<div style="font-size: 0.8rem; color: var(--text-secondary); display: flex; justify-content: space-between; margin-bottom: 0.25rem;">
<span>
Capacity:{" "}
{isUnlimited ? "Unlimited" : `${usesCount} / ${maxUses}`}
</span>
<span>
Expires: {new Date(inv.expires_at).toLocaleDateString()}
</span>
</div>
{!isUnlimited && (
<div style="background: var(--surface-muted); border-radius: 3px; height: 6px; width: 100%; overflow: hidden;">
<div
style={`background: ${
isExhausted ? "var(--text-muted)" : "var(--success)"
}; height: 100%; width: ${
Math.min(100, (usesCount / maxUses) * 100)
}%;`}
/>
</div>
)}
</div>
<div style="display: flex; gap: 0.5rem;">
{isActive && (
<button
type="button"
class="btn-primary"
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.85rem;"
onclick={`copyInviteLink('${inv.code}')`}
>
Copy Link
</button>
)}
{usesCount > 0 && (
<button
type="button"
class="btn-outline"
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.85rem;"
onclick={`showRedemptionsModal('${inv.id}', '${inv.code}')`}
>
Claimed ({usesCount})
</button>
)}
{isActive && (
<button
type="button"
class="btn-danger"
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.85rem;"
onclick={`revokeInvite('${inv.id}', '${inv.code}')`}
>
Revoke
</button>
)}
</div>
</div>
);
})}
/>
{/* Redemptions Modal */}
<AdminModal
id="redemptions-modal"
title={
<span>
Users Claimed:{" "}
<code id="modal-invite-code" style="color: var(--primary);">
</code>
</span>
}
onClose="closeRedemptionsModal()"
>
<div
id="modal-redemptions-content"
style="max-height: 350px; overflow-y: auto;"
>
<p style="color: var(--text-muted); font-size: 0.9rem;">
Loading claimed users...
</p>
</div>
<div style="text-align: right; margin-top: 1rem;">
<button
type="button"
class="btn-outline"
onclick="closeRedemptionsModal()"
>
Close
</button>
</div>
</AdminModal>
<style>
{`
@media (min-width: 768px) {
.desktop-only { display: block !important; }
.mobile-only { display: none !important; }
}
@media (max-width: 767px) {
.desktop-only { display: none !important; }
.mobile-only { display: flex !important; }
}
`}
</style>
<script
dangerouslySetInnerHTML={{
__html: `
const ROLES_CATALOG = ${JSON.stringify(allRoles)};
function updateInviteRoleOptions() {
const appSelect = document.getElementById('inviteAppId');
const roleSelect = document.getElementById('inviteRole');
if (!appSelect || !roleSelect) return;
const appId = appSelect.value;
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('inviteAppId')) {
updateInviteRoleOptions();
}
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);
}
function toggleCreateInviteForm() {
const el = document.getElementById('createInviteModal');
el.style.display = el.style.display === 'none' ? 'flex' : 'none';
if (el.style.display === 'flex') {
updateInviteRoleOptions();
}
}
function handleInviteTypeChange() {
const type = document.getElementById('inviteType').value;
const appContainer = document.getElementById('appSelectContainer');
const roleContainer = document.getElementById('roleSelectContainer');
if (type === 'global_admin' || type === 'open_pending') {
appContainer.style.display = 'none';
roleContainer.style.display = 'none';
} else {
appContainer.style.display = 'block';
roleContainer.style.display = 'block';
updateInviteRoleOptions();
}
}
function handleUsageTypeChange() {
const usage = document.getElementById('inviteUsageType').value;
const maxUsesContainer = document.getElementById('maxUsesContainer');
maxUsesContainer.style.display = usage === 'limited' ? 'block' : 'none';
}
function filterInvitesList() {
const query = (document.getElementById('inviteSearchInput')?.value || '').toLowerCase().trim();
document.querySelectorAll('.invite-row').forEach(r => {
const text = r.getAttribute('data-search') || '';
r.style.display = text.includes(query) ? '' : 'none';
});
document.querySelectorAll('.invite-card').forEach(c => {
const text = c.getAttribute('data-search') || '';
c.style.display = text.includes(query) ? '' : 'none';
});
}
async function handleCreateInvite(e) {
e.preventDefault();
const type = document.getElementById('inviteType').value;
let appId = null;
let role = 'user';
if (type === 'global_admin') {
role = 'admin';
} else if (type === 'open_pending') {
role = 'user';
} else {
appId = document.getElementById('inviteAppId').value;
role = document.getElementById('inviteRole').value;
}
const usageLimitType = document.getElementById('inviteUsageType').value;
const maxUses = usageLimitType === 'limited' ? parseInt(document.getElementById('inviteMaxUses').value) || 5 : null;
const autoActivate = document.getElementById('inviteAutoActivate').checked;
const expiresInDays = parseInt(document.getElementById('inviteExpiresInDays').value) || 7;
const customCode = document.getElementById('inviteCustomCode').value.trim();
try {
const res = await fetch('/api/admin/invites/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
appId,
role,
usageLimitType,
maxUses,
autoActivate,
expiresInDays,
customCode: customCode || undefined,
}),
});
const data = await res.json();
if (res.ok) {
const regUrl = window.location.origin + '/register?code=' + data.inviteCode;
document.getElementById('generatedTokenUrl').textContent = regUrl;
document.getElementById('generated-token-banner').style.display = 'block';
showNotice('Invite token created successfully!', false);
setTimeout(() => { window.location.reload(); }, 2500);
} else {
showNotice(data.error || 'Failed to create invite token', true);
}
} catch (err) {
showNotice('Network error creating invite', true);
}
}
function copyGeneratedTokenUrl() {
const text = document.getElementById('generatedTokenUrl').textContent;
navigator.clipboard.writeText(text);
showNotice('Registration URL copied to clipboard!', false);
}
function copyInviteLink(code) {
const url = window.location.origin + '/register?code=' + code;
navigator.clipboard.writeText(url);
showNotice('Registration link copied: ' + url, false);
}
async function revokeInvite(inviteId, code) {
if (!confirm('Revoke invite code "' + code + '"?')) return;
try {
const res = await fetch('/api/admin/invites/' + inviteId, {
method: 'DELETE',
});
if (res.ok) {
showNotice('Invite token revoked', false);
setTimeout(() => window.location.reload(), 600);
} else {
const data = await res.json();
showNotice(data.error || 'Failed to revoke invite', true);
}
} catch (err) {
showNotice('Network error', true);
}
}
async function showRedemptionsModal(inviteId, code) {
const modal = document.getElementById('redemptions-modal');
const codeEl = document.getElementById('modal-invite-code');
const contentEl = document.getElementById('modal-redemptions-content');
codeEl.textContent = code;
contentEl.innerHTML = '<p style="color: var(--text-muted);">Loading...</p>';
modal.style.display = 'flex';
try {
const res = await fetch('/api/admin/invites/' + inviteId + '/redemptions');
const data = await res.json();
if (res.ok && data.redemptions && data.redemptions.length > 0) {
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';
html += '<thead><tr>';
html += '<th style="padding: 0.5rem;">Username</th>';
html += '<th style="padding: 0.5rem;">Status</th>';
html += '<th style="padding: 0.5rem;">Redeemed At</th>';
html += '</tr></thead><tbody>';
data.redemptions.forEach(r => {
html += '<tr>';
html += '<td style="padding: 0.5rem;"><strong style="color: var(--text-primary); font-family: monospace;">@' + r.username + '</strong></td>';
html += '<td style="padding: 0.5rem;"><span class="badge badge-' + (r.account_status === 'active' ? 'success' : 'warning') + '">' + r.account_status + '</span></td>';
html += '<td style="padding: 0.5rem; color: var(--text-secondary);">' + new Date(r.redeemed_at).toLocaleString() + '</td>';
html += '</tr>';
});
html += '</tbody></table>';
contentEl.innerHTML = html;
} else {
contentEl.innerHTML = '<p style="color: var(--text-muted); text-align: center; padding: 1rem;">No users have redeemed this token yet.</p>';
}
} catch (err) {
contentEl.innerHTML = '<p style="color: var(--danger);">Failed to load redemption details.</p>';
}
}
function closeRedemptionsModal() {
document.getElementById('redemptions-modal').style.display = 'none';
}
`,
}}
/>
</AdminLayout>
);
};