/**
* Admin Dashboard UI
*
* Dark theme with Jezweb styling.
*/
import type { AdminUser } from './middleware';
import { renderConnectionDetails } from '@jezweb/mcp-ui';
/**
* Escape HTML content to prevent XSS (server-side for template literals)
*/
function escapeHtmlContent(str: string | undefined | null): string {
if (!str) return '';
return str.toString()
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
}
/**
* Escape HTML attribute value to prevent XSS (server-side for template literals)
*/
function escapeHtmlAttr(str: string | undefined | null): string {
if (!str) return '';
return str.toString()
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
interface ServerInfo {
name: string;
version: string;
description: string;
}
interface DashboardOptions {
enableChat?: boolean;
/** Connection details for MCP clients */
connection?: {
serverUrl: string;
serverName: string;
};
}
export function getAdminDashboard(session: AdminUser, serverInfo: ServerInfo, options: DashboardOptions = {}): string {
const { enableChat = false } = options;
const isAdmin = session.role === 'admin';
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin - ${escapeHtmlContent(serverInfo.name)}</title>
<link rel="icon" href="https://www.jezweb.com.au/wp-content/uploads/2020/03/favicon-100x100.png">
<style>
:root {
--background: #09090b;
--foreground: #fafafa;
--card: #18181b;
--muted: #27272a;
--muted-foreground: #a1a1aa;
--border: #27272a;
--primary: #14b8a6;
--primary-foreground: #042f2e;
--accent: #3b82f6;
--destructive: #ef4444;
--success: #10b981;
--radius: 0.75rem;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--background);
color: var(--foreground);
min-height: 100vh;
line-height: 1.6;
}
header {
background: var(--card);
border-bottom: 1px solid var(--border);
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
header h1 {
font-size: 1.25rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
header h1 span {
color: var(--muted-foreground);
font-weight: normal;
}
.user-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.user-info img {
width: 32px;
height: 32px;
border-radius: 50%;
}
.user-info span {
color: var(--muted-foreground);
}
/* Desktop: Side-by-side layout with embedded chat */
.admin-layout {
display: flex;
min-height: calc(100vh - 60px);
}
.main-content {
flex: 1;
max-width: 900px;
padding: 2rem;
overflow-y: auto;
}
.grid {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
/* Desktop: Embedded chat panel */
.chat-sidebar {
width: 420px;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
display: flex;
flex-direction: column;
height: calc(100vh - 100px);
position: sticky;
top: 80px;
margin: 1rem 1.5rem 1rem 0;
overflow: hidden;
}
/* Mobile: Hide sidebar, show toggle */
@media (max-width: 1024px) {
.admin-layout {
flex-direction: column;
}
.main-content {
max-width: 100%;
padding: 1rem;
}
.chat-sidebar {
display: none;
}
}
/* Desktop: Hide toggle button */
@media (min-width: 1025px) {
.chat-toggle {
display: none;
}
.chat-panel {
display: none;
}
}
.scrollable-list {
max-height: 400px;
overflow-y: auto;
}
.scrollable-list::-webkit-scrollbar {
width: 6px;
}
.scrollable-list::-webkit-scrollbar-track {
background: var(--muted);
border-radius: 3px;
}
.scrollable-list::-webkit-scrollbar-thumb {
background: var(--primary);
border-radius: 3px;
}
.item-count {
font-size: 0.75rem;
color: var(--muted-foreground);
margin-left: 0.5rem;
}
/* Expandable Items */
.expandable-item {
background: var(--muted);
border-radius: 0.5rem;
margin-bottom: 0.5rem;
overflow: hidden;
}
.expandable-header {
padding: 0.75rem 1rem;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.5rem;
transition: background 0.2s;
}
.expandable-header:hover {
background: rgba(20, 184, 166, 0.1);
}
.expandable-header .item-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
color: var(--primary);
}
.expandable-header .item-title .chevron {
width: 16px;
height: 16px;
transition: transform 0.2s;
}
.expandable-item.open .chevron {
transform: rotate(90deg);
}
.expandable-header .item-desc {
font-size: 0.85rem;
color: var(--muted-foreground);
margin-top: 0.25rem;
}
.expandable-header .badges {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.expandable-header .badge-small {
font-size: 0.65rem;
padding: 0.15rem 0.4rem;
border-radius: 0.25rem;
background: rgba(59, 130, 246, 0.1);
color: var(--accent);
}
.expandable-header .badge-auth {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
.expandable-header .badge-user-auth {
background: rgba(245, 158, 11, 0.1);
color: #f59e0b;
}
.tool-auth-note {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
margin-bottom: 1rem;
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.2);
border-radius: 0.375rem;
color: #f59e0b;
font-size: 0.85rem;
}
.tool-auth-note svg {
flex-shrink: 0;
}
.expandable-body {
display: none;
padding: 1rem;
border-top: 1px solid var(--border);
background: var(--background);
}
.expandable-item.open .expandable-body {
display: block;
}
/* Tool Tester Form */
.tool-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.tool-form-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.tool-form-field label {
font-size: 0.8rem;
color: var(--muted-foreground);
display: flex;
align-items: center;
gap: 0.25rem;
}
.tool-form-field label .required {
color: var(--destructive);
}
.tool-form-field input,
.tool-form-field select,
.tool-form-field textarea {
background: var(--muted);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 0.5rem;
color: var(--foreground);
font-size: 0.9rem;
}
.tool-form-field textarea {
min-height: 60px;
resize: vertical;
}
.tool-form-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.tool-result {
margin-top: 0.75rem;
padding: 0.75rem;
border-radius: 0.5rem;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.8rem;
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow-y: auto;
}
.tool-result.success {
background: rgba(16, 185, 129, 0.1);
border: 1px solid var(--success);
}
.tool-result.error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid var(--destructive);
}
/* Resource/Prompt Preview */
.preview-content {
background: var(--muted);
border-radius: 0.5rem;
padding: 0.75rem;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.8rem;
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow-y: auto;
}
.preview-content code {
background: transparent;
}
.preview-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
/* Filter Tabs */
.filter-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filter-tab {
padding: 0.35rem 0.75rem;
background: var(--muted);
border: 1px solid var(--border);
border-radius: 1rem;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s;
}
.filter-tab:hover,
.filter-tab.active {
background: var(--primary);
color: var(--primary-foreground);
border-color: var(--primary);
}
/* Conversation History */
.chat-history-toggle {
padding: 0.5rem 1rem;
background: var(--muted);
border-top: 1px solid var(--border);
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.85rem;
color: var(--muted-foreground);
}
.chat-history-toggle:hover {
background: rgba(20, 184, 166, 0.1);
color: var(--foreground);
}
.chat-history-toggle svg {
width: 14px;
height: 14px;
transition: transform 0.2s;
}
.chat-history-toggle.open svg {
transform: rotate(180deg);
}
.chat-history-list {
display: none;
max-height: 200px;
overflow-y: auto;
background: var(--background);
border-top: 1px solid var(--border);
}
.chat-history-list.open {
display: block;
}
.chat-history-item {
padding: 0.5rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8rem;
border-bottom: 1px solid var(--border);
cursor: pointer;
}
.chat-history-item:hover {
background: var(--muted);
}
.chat-history-item.active {
background: rgba(20, 184, 166, 0.1);
border-left: 2px solid var(--primary);
}
.chat-history-item .session-info {
flex: 1;
min-width: 0;
}
.chat-history-item .session-title {
color: var(--foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-history-item .session-meta {
color: var(--muted-foreground);
font-size: 0.7rem;
}
.chat-history-item .delete-btn {
padding: 0.15rem 0.35rem;
font-size: 0.7rem;
opacity: 0;
transition: opacity 0.2s;
}
.chat-history-item:hover .delete-btn {
opacity: 1;
}
.chat-history-empty {
padding: 1rem;
text-align: center;
color: var(--muted-foreground);
font-size: 0.8rem;
}
/* Model Details Panel */
.model-details-toggle {
padding: 0.5rem 1rem;
background: var(--muted);
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8rem;
color: var(--muted-foreground);
border-radius: 0.5rem;
margin: 0.5rem 0;
}
.model-details-toggle:hover {
background: rgba(20, 184, 166, 0.1);
color: var(--foreground);
}
.model-details-toggle svg {
width: 14px;
height: 14px;
transition: transform 0.2s;
}
.model-details-toggle.open svg {
transform: rotate(180deg);
}
.model-details-panel {
display: none;
background: var(--background);
border: 1px solid var(--border);
border-radius: 0.5rem;
margin-bottom: 0.5rem;
padding: 0.75rem;
font-size: 0.8rem;
}
.model-details-panel.open {
display: block;
}
.model-details-panel .detail-row {
display: flex;
justify-content: space-between;
padding: 0.35rem 0;
border-bottom: 1px solid var(--border);
}
.model-details-panel .detail-row:last-of-type {
border-bottom: none;
}
.model-details-panel .detail-label {
color: var(--muted-foreground);
}
.model-details-panel .detail-value {
color: var(--foreground);
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.75rem;
}
.model-details-panel .detail-description {
padding-top: 0.5rem;
color: var(--muted-foreground);
font-style: italic;
line-height: 1.4;
border-top: 1px solid var(--border);
margin-top: 0.35rem;
}
.model-details-hidden {
display: none !important;
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
}
.card h2 {
font-size: 1rem;
margin-bottom: 1rem;
color: var(--primary);
display: flex;
align-items: center;
gap: 0.5rem;
}
.card h2 svg {
width: 20px;
height: 20px;
}
.info-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1rem;
}
.info-grid dt {
color: var(--muted-foreground);
}
.info-grid dd {
font-family: 'SF Mono', Monaco, monospace;
}
.token-list {
list-style: none;
}
.token-item {
background: var(--muted);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 0.75rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.token-item:last-child { margin-bottom: 0; }
.token-info h3 {
font-size: 0.9rem;
margin-bottom: 0.25rem;
}
.token-info code {
font-size: 0.8rem;
color: var(--muted-foreground);
font-family: 'SF Mono', Monaco, monospace;
}
.token-info small {
display: block;
color: var(--muted-foreground);
font-size: 0.75rem;
margin-top: 0.25rem;
}
button {
background: var(--primary);
color: var(--primary-foreground);
border: none;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
cursor: pointer;
font-weight: 500;
transition: opacity 0.2s;
}
button:hover { opacity: 0.9; }
button.secondary {
background: var(--muted);
color: var(--foreground);
}
button.danger {
background: var(--destructive);
color: white;
}
button.small {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
}
/* Form element base styles for modals */
.modal-body input,
.modal-body select,
.modal-body textarea {
background: var(--muted);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
color: var(--foreground);
font-size: 0.9rem;
transition: border-color 0.2s, box-shadow 0.2s;
}
.modal-body input:focus,
.modal-body select:focus,
.modal-body textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(20, 184, 166, 0.2);
}
.modal-body input::placeholder,
.modal-body textarea::placeholder {
color: var(--muted-foreground);
}
.modal-body label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: var(--muted-foreground);
margin-bottom: 0.35rem;
}
.modal-body .form-group {
margin-bottom: 1rem;
}
.modal-body .form-row {
display: flex;
gap: 0.75rem;
}
.modal-body .form-row > div {
flex: 1;
}
.modal-body .form-hint {
font-size: 0.75rem;
color: var(--muted-foreground);
margin-top: 0.25rem;
}
.modal-body code {
background: var(--background);
padding: 0.15rem 0.4rem;
border-radius: 0.25rem;
font-size: 0.8rem;
}
.modal-body h4 {
font-size: 0.9rem;
font-weight: 600;
color: var(--foreground);
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}
.new-token-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.new-token-form input {
flex: 1;
background: var(--muted);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
color: var(--foreground);
font-size: 0.9rem;
}
.new-token-form input::placeholder {
color: var(--muted-foreground);
}
.new-token-form select {
flex: 0 0 auto;
min-width: 140px;
background: var(--muted);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
color: var(--foreground);
font-size: 0.9rem;
cursor: pointer;
}
.new-token-form select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(20, 184, 166, 0.2);
}
.new-token-modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.8);
display: none;
align-items: center;
justify-content: center;
z-index: 100;
}
.new-token-modal.show { display: flex; }
.modal-content {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 2rem;
max-width: 500px;
width: 90%;
}
.modal-content h3 {
margin-bottom: 1rem;
color: var(--success);
}
.modal-content .token-display {
background: var(--muted);
padding: 1rem;
border-radius: 0.5rem;
font-family: 'SF Mono', Monaco, monospace;
word-break: break-all;
margin: 1rem 0;
}
.modal-content .warning {
color: #f59e0b;
font-size: 0.9rem;
margin-bottom: 1rem;
}
.modal-content .actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.empty {
color: var(--muted-foreground);
text-align: center;
padding: 2rem;
}
.badge {
display: inline-block;
background: rgba(20, 184, 166, 0.1);
color: var(--primary);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
margin-left: 0.5rem;
}
.loading { opacity: 0.5; pointer-events: none; }
/* Organization Modal Styles */
.modal {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
}
.modal-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.8);
}
.modal .modal-content {
position: relative;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
max-width: 600px;
width: 90%;
max-height: 85vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border);
}
.modal-header h3 {
margin: 0;
color: var(--foreground);
}
.modal-close {
background: none;
border: none;
color: var(--muted-foreground);
font-size: 1.5rem;
cursor: pointer;
padding: 0;
line-height: 1;
}
.modal-close:hover {
color: var(--foreground);
}
.modal-body {
padding: 1.5rem;
overflow-y: auto;
}
.role-select {
background: var(--muted);
border: 1px solid var(--border);
border-radius: 0.25rem;
padding: 0.25rem 0.5rem;
color: var(--foreground);
font-size: 0.75rem;
}
/* AI Chat Panel */
.chat-toggle {
position: fixed;
bottom: 2rem;
right: 2rem;
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--primary);
color: var(--primary-foreground);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 50;
transition: transform 0.2s;
}
.chat-toggle:hover { transform: scale(1.1); }
.chat-toggle svg { width: 24px; height: 24px; }
.chat-panel {
position: fixed;
right: 0;
top: 0;
width: 400px;
height: 100vh;
background: var(--card);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
z-index: 100;
transform: translateX(100%);
transition: transform 0.3s ease;
}
.chat-panel.open { transform: translateX(0); }
.chat-header {
padding: 1rem;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.chat-header h3 {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
color: var(--primary);
}
.chat-header h3 svg { width: 20px; height: 20px; }
.chat-settings {
padding: 0.75rem 1rem;
background: var(--muted);
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.chat-settings select {
flex: 1;
min-width: 120px;
background: var(--background);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 0.5rem;
color: var(--foreground);
font-size: 0.8rem;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.chat-message {
max-width: 85%;
padding: 0.75rem 1rem;
border-radius: 1rem;
font-size: 0.9rem;
line-height: 1.5;
}
.chat-message.user {
background: var(--primary);
color: var(--primary-foreground);
align-self: flex-end;
border-bottom-right-radius: 0.25rem;
}
.chat-message.assistant {
background: var(--muted);
align-self: flex-start;
border-bottom-left-radius: 0.25rem;
}
.chat-message.tool-call,
.chat-message.tool-result {
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.75rem;
align-self: stretch;
max-width: 100%;
min-width: 250px;
padding: 0.75rem 1rem;
/* CRITICAL: Prevent collapse - use fixed min-height */
min-height: 80px;
box-sizing: border-box;
}
.chat-message.tool-call {
background: rgba(59, 130, 246, 0.1);
border: 1px solid var(--accent);
}
.chat-message.tool-result {
background: rgba(16, 185, 129, 0.1);
border: 1px solid var(--success);
}
.chat-message.tool-result.error {
background: rgba(239, 68, 68, 0.1);
border-color: var(--destructive);
}
.chat-message.tool-call strong,
.chat-message.tool-result strong {
display: block;
margin-bottom: 0.25rem;
font-size: 0.75rem;
}
.chat-message.tool-call pre,
.chat-message.tool-result pre {
display: block;
white-space: pre-wrap;
word-break: break-word;
margin: 0.5rem 0 0;
padding: 0.5rem;
background: rgba(0, 0, 0, 0.2);
border-radius: 0.25rem;
font-size: 0.7rem;
line-height: 1.4;
/* Let content determine height, with reasonable max */
min-height: 2rem;
max-height: 150px;
overflow-y: auto;
}
.chat-message pre {
white-space: pre-wrap;
word-break: break-word;
margin: 0.5rem 0 0;
max-height: 200px;
overflow-y: auto;
}
/* Markdown styling in chat */
.chat-message code {
background: var(--muted);
padding: 0.15rem 0.35rem;
border-radius: 0.25rem;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.85em;
}
.chat-message.assistant code {
background: var(--background);
}
.chat-message strong {
color: var(--primary);
}
.chat-message ul, .chat-message ol {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.chat-message li {
margin: 0.25rem 0;
}
.chat-message p {
margin: 0.5rem 0;
}
.chat-message p:first-child {
margin-top: 0;
}
.chat-message p:last-child {
margin-bottom: 0;
}
.chat-message h1, .chat-message h2, .chat-message h3,
.chat-message h4, .chat-message h5, .chat-message h6 {
color: var(--primary);
margin: 0.75rem 0 0.5rem;
font-weight: 600;
}
.chat-message h1:first-child, .chat-message h2:first-child,
.chat-message h3:first-child {
margin-top: 0;
}
.chat-message h1 { font-size: 1.25rem; }
.chat-message h2 { font-size: 1.1rem; }
.chat-message h3 { font-size: 1rem; }
.chat-message h4, .chat-message h5, .chat-message h6 { font-size: 0.95rem; }
/* Markdown tables */
.chat-message table {
border-collapse: collapse;
width: 100%;
margin: 0.5rem 0;
font-size: 0.85rem;
}
.chat-message th, .chat-message td {
border: 1px solid var(--border);
padding: 0.4rem 0.6rem;
text-align: left;
}
.chat-message th {
background: var(--muted);
font-weight: 600;
color: var(--primary);
}
.chat-message tr:nth-child(even) {
background: rgba(255, 255, 255, 0.02);
}
.chat-suggestions {
padding: 0.75rem 1rem;
border-top: 1px solid var(--border);
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.chat-suggestion {
background: var(--muted);
border: 1px solid var(--border);
border-radius: 1rem;
padding: 0.35rem 0.75rem;
font-size: 0.8rem;
color: var(--foreground);
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.chat-suggestion:hover {
background: var(--primary);
color: var(--primary-foreground);
border-color: var(--primary);
}
.chat-suggestion.test-all {
background: rgba(20, 184, 166, 0.15);
border-color: var(--primary);
color: var(--primary);
}
.chat-suggestion.test-all:hover {
background: var(--primary);
color: var(--primary-foreground);
}
.chat-input-area {
padding: 1rem;
border-top: 1px solid var(--border);
display: flex;
gap: 0.5rem;
}
.chat-input-area textarea {
flex: 1;
background: var(--muted);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 0.75rem;
color: var(--foreground);
font-size: 0.9rem;
resize: none;
min-height: 44px;
max-height: 120px;
}
.chat-input-area textarea::placeholder {
color: var(--muted-foreground);
}
.chat-input-area button {
align-self: flex-end;
}
.typing-indicator {
display: flex;
gap: 4px;
padding: 0.75rem 1rem;
background: var(--muted);
border-radius: 1rem;
align-self: flex-start;
}
.typing-indicator span {
width: 8px;
height: 8px;
background: var(--muted-foreground);
border-radius: 50%;
animation: typing 1.4s infinite ease-in-out;
}
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-6px); }
}
/* User Management Table */
.users-search {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.users-search input {
flex: 1;
background: var(--muted);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
color: var(--foreground);
font-size: 0.9rem;
}
.users-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.users-table th,
.users-table td {
padding: 0.75rem 0.5rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
.users-table th {
color: var(--muted-foreground);
font-weight: 500;
font-size: 0.75rem;
text-transform: uppercase;
}
.users-table tr:hover {
background: rgba(255, 255, 255, 0.02);
}
.user-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
vertical-align: middle;
margin-right: 0.5rem;
}
.user-email {
color: var(--muted-foreground);
font-size: 0.75rem;
}
.role-badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 1rem;
font-size: 0.7rem;
font-weight: 500;
}
.role-badge.admin {
background: rgba(239, 68, 68, 0.15);
color: #f87171;
}
.role-badge.user {
background: rgba(59, 130, 246, 0.15);
color: #60a5fa;
}
.status-badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 1rem;
font-size: 0.7rem;
}
.status-badge.active {
background: rgba(16, 185, 129, 0.15);
color: #34d399;
}
.status-badge.banned {
background: rgba(239, 68, 68, 0.15);
color: #f87171;
}
.user-actions {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.user-actions button {
padding: 0.2rem 0.4rem;
font-size: 0.7rem;
}
.users-pagination {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
.users-pagination span {
color: var(--muted-foreground);
font-size: 0.8rem;
}
.impersonation-banner {
background: rgba(245, 158, 11, 0.15);
border: 1px solid #f59e0b;
color: #fbbf24;
padding: 0.5rem 1rem;
margin-bottom: 1rem;
border-radius: 0.5rem;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.85rem;
}
.impersonation-banner button {
background: #f59e0b;
color: #000;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
@media (max-width: 768px) {
.grid { grid-template-columns: 1fr; }
header { flex-direction: column; gap: 1rem; }
.chat-panel { width: 100%; }
.users-table { font-size: 0.75rem; }
.users-table th, .users-table td { padding: 0.5rem 0.25rem; }
}
</style>
</head>
<body>
<header>
<h1>
${escapeHtmlContent(serverInfo.name)} <span>v${escapeHtmlContent(serverInfo.version)}</span>
<span class="badge">Admin</span>
</h1>
<div class="user-info">
${session.image ? `<img src="${escapeHtmlAttr(session.image)}" alt="">` : ''}
<span>${escapeHtmlContent(session.name)} (${escapeHtmlContent(session.email)})</span>
<button class="secondary small" onclick="logout()">Log out</button>
</div>
</header>
<div class="admin-layout">
<main class="main-content">
<div class="grid">
<!-- Server Info - full width -->
<div class="card">
<h2>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="8" x="2" y="2" rx="2" ry="2"/><rect width="20" height="8" x="2" y="14" rx="2" ry="2"/><line x1="6" x2="6.01" y1="6" y2="6"/><line x1="6" x2="6.01" y1="18" y2="18"/></svg>
Server Info
</h2>
<dl class="info-grid">
<dt>Name</dt>
<dd>${escapeHtmlContent(serverInfo.name)}</dd>
<dt>Version</dt>
<dd>${escapeHtmlContent(serverInfo.version)}</dd>
<dt>Description</dt>
<dd style="font-family: inherit;">${escapeHtmlContent(serverInfo.description)}</dd>
</dl>
</div>
${options.connection ? `
<!-- Connection Details -->
${renderConnectionDetails({ serverUrl: options.connection.serverUrl, serverName: options.connection.serverName, compact: true })}
` : ''}
<!-- Tools - full width with category filters -->
<div class="card">
<h2>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
Tools<span class="item-count" id="tools-count"></span>
</h2>
<div class="filter-tabs" id="tools-filters">
<span class="filter-tab active" data-filter="all">All</span>
</div>
<div class="scrollable-list" id="tools-list"><div class="empty">Loading...</div></div>
</div>
<!-- Resources - full width -->
<div class="card">
<h2>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2"/><path d="M18 14h-8"/><path d="M15 18h-5"/><path d="M10 6h8v4h-8V6Z"/></svg>
Resources<span class="item-count" id="resources-count"></span>
</h2>
<div class="scrollable-list" id="resources-list"><div class="empty">Loading...</div></div>
</div>
<!-- Prompts - full width -->
<div class="card">
<h2>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
Prompts<span class="item-count" id="prompts-count"></span>
</h2>
<div class="scrollable-list" id="prompts-list"><div class="empty">Loading...</div></div>
</div>
<!-- API Keys - full width -->
<div class="card">
<h2>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
API Keys
<span class="item-count" id="api-keys-count"></span>
</h2>
<p style="color: var(--muted-foreground); font-size: 0.85rem; margin-bottom: 1rem;">
API keys allow programmatic access to the MCP server without OAuth. Use for CLI tools, scripts, and integrations.
</p>
<form class="new-token-form" onsubmit="createApiKey(event)">
<input type="text" id="api-key-name" placeholder="Key name (e.g., Production CLI)">
<button type="submit">Create Key</button>
</form>
<ul class="token-list" id="api-keys-list">
<li class="empty">Loading...</li>
</ul>
</div>
<!-- Connected Accounts - full width -->
<div class="card">
<h2>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" x2="3" y1="12" y2="12"/></svg>
Connected Accounts
<span class="item-count" id="connected-accounts-count"></span>
</h2>
<p style="color: var(--muted-foreground); font-size: 0.85rem; margin-bottom: 1rem;">
OAuth connections for Google, Microsoft, and GitHub. These enable API access for calendar, email, and other integrations.
</p>
<ul class="token-list" id="connected-accounts-list">
<li class="empty">Loading...</li>
</ul>
<div style="margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border);">
<p style="color: var(--muted-foreground); font-size: 0.85rem; margin-bottom: 0.5rem;">
<strong>How to connect accounts:</strong>
</p>
<ul style="color: var(--muted-foreground); font-size: 0.85rem; padding-left: 1.5rem; margin: 0;">
<li>Use your MCP client (Claude.ai, Claude Desktop) to initiate OAuth</li>
<li>Sign in with the Google account you want to connect</li>
<li>To switch accounts, disconnect above and re-authenticate</li>
</ul>
</div>
</div>
<!-- User Management - full width -->
<div class="card">
<h2>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
User Management
</h2>
<!-- Impersonation banner (shown when impersonating) -->
<div class="impersonation-banner" id="impersonation-banner" style="display:none;">
<span>You are impersonating <strong id="impersonated-user"></strong></span>
<button onclick="stopImpersonating()">Stop Impersonating</button>
</div>
<div class="users-search">
<input type="text" id="users-search" placeholder="Search by email..." onkeydown="if(event.key==='Enter')loadUsers()">
<button onclick="loadUsers()">Search</button>
</div>
<div class="scrollable-list" style="max-height: 500px;">
<table class="users-table">
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="users-list">
<tr><td colspan="5" class="empty">Loading...</td></tr>
</tbody>
</table>
</div>
<div class="users-pagination">
<span id="users-pagination-info">Showing 0 users</span>
<div>
<button class="secondary small" id="users-prev" onclick="loadUsers('prev')" disabled>Prev</button>
<button class="secondary small" id="users-next" onclick="loadUsers('next')" disabled>Next</button>
</div>
</div>
</div>
</div>
</main>
${enableChat ? `<!-- Desktop: Embedded Chat Sidebar -->
<aside class="chat-sidebar" id="chat-sidebar">
<div class="chat-header">
<h3>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
AI Tool Tester
</h3>
<button class="secondary small" onclick="newChatSession()">New</button>
</div>
<div class="chat-settings">
<select id="sidebar-provider" onchange="updateSidebarModels()">
<option value="">Loading providers...</option>
</select>
<select id="sidebar-model" onchange="updateModelDetails('sidebar')">
<option value="">Select model...</option>
</select>
</div>
<!-- Model Details Toggle -->
<div class="model-details-toggle model-details-hidden" id="sidebar-model-details-toggle" onclick="toggleModelDetails('sidebar')">
<span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:12px;height:12px;margin-right:4px;vertical-align:middle;"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
Model Details
</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg>
</div>
<div class="model-details-panel" id="sidebar-model-details-panel">
<div class="detail-row">
<span class="detail-label">Context</span>
<span class="detail-value" id="sidebar-detail-context">-</span>
</div>
<div class="detail-row">
<span class="detail-label">Input</span>
<span class="detail-value" id="sidebar-detail-input">-</span>
</div>
<div class="detail-row">
<span class="detail-label">Output</span>
<span class="detail-value" id="sidebar-detail-output">-</span>
</div>
<div class="detail-row">
<span class="detail-label">Provider</span>
<span class="detail-value" id="sidebar-detail-provider">-</span>
</div>
<div class="detail-row" id="sidebar-detail-date-row">
<span class="detail-label">Released</span>
<span class="detail-value" id="sidebar-detail-date">-</span>
</div>
<div class="detail-description" id="sidebar-detail-description"></div>
</div>
<!-- Conversation History Toggle -->
<div class="chat-history-toggle" id="history-toggle" onclick="toggleChatHistory()">
<span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:12px;height:12px;margin-right:4px;vertical-align:middle;"><path d="M3 3v5h5"/><path d="M3.05 13A9 9 0 1 0 6 5.3L3 8"/><path d="M12 7v5l4 2"/></svg>
History <span id="history-count">(0)</span>
</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg>
</div>
<div class="chat-history-list" id="history-list">
<div class="chat-history-empty">No previous conversations</div>
</div>
<div class="chat-messages" id="sidebar-messages">
<div class="chat-message assistant">
Hi! I'm the AI tool tester. I can help you test the MCP tools on this server. Click a suggestion below or type your own message.
</div>
</div>
<div class="chat-suggestions" id="sidebar-suggestions">
<span class="chat-suggestion" onclick="useSidebarSuggestion('What tools are available?')">List tools</span>
<span class="chat-suggestion" onclick="useSidebarSuggestion('Test the hello tool with name Admin')">Test hello</span>
<span class="chat-suggestion test-all" onclick="useSidebarSuggestion('Run a comprehensive test of all available tools')">Test All</span>
</div>
<div class="chat-input-area">
<textarea id="sidebar-input" placeholder="Test a tool..." rows="1" onkeydown="handleSidebarKeydown(event)"></textarea>
<button onclick="sendSidebarMessage()">Send</button>
</div>
</aside>` : ''}
</div>
${enableChat ? `<!-- Chat Toggle Button -->
<button class="chat-toggle" onclick="toggleChat()" title="AI Chat">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><path d="M13 8H7"/><path d="M17 12H7"/></svg>
</button>
<!-- Chat Panel -->
<div class="chat-panel" id="chat-panel">
<div class="chat-header">
<h3>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
AI Tool Tester
</h3>
<div style="display: flex; gap: 0.5rem;">
<button class="secondary small" onclick="newChatSession()">New</button>
<button class="secondary small" onclick="toggleChat()">Close</button>
</div>
</div>
<div class="chat-settings">
<select id="chat-provider" onchange="updateChatModel()">
<option value="">Loading providers...</option>
</select>
<select id="chat-model" onchange="updateModelDetails('chat')">
<option value="">Select model...</option>
</select>
</div>
<!-- Model Details Toggle (Mobile) -->
<div class="model-details-toggle model-details-hidden" id="chat-model-details-toggle" onclick="toggleModelDetails('chat')">
<span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:12px;height:12px;margin-right:4px;vertical-align:middle;"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
Model Details
</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg>
</div>
<div class="model-details-panel" id="chat-model-details-panel">
<div class="detail-row">
<span class="detail-label">Context</span>
<span class="detail-value" id="chat-detail-context">-</span>
</div>
<div class="detail-row">
<span class="detail-label">Input</span>
<span class="detail-value" id="chat-detail-input">-</span>
</div>
<div class="detail-row">
<span class="detail-label">Output</span>
<span class="detail-value" id="chat-detail-output">-</span>
</div>
<div class="detail-row">
<span class="detail-label">Provider</span>
<span class="detail-value" id="chat-detail-provider">-</span>
</div>
<div class="detail-row" id="chat-detail-date-row">
<span class="detail-label">Released</span>
<span class="detail-value" id="chat-detail-date">-</span>
</div>
<div class="detail-description" id="chat-detail-description"></div>
</div>
<div class="chat-messages" id="chat-messages">
<div class="chat-message assistant">
Hi! I'm the AI tool tester. I can help you test the MCP tools on this server. Click a suggestion below or type your own message.
</div>
</div>
<div class="chat-suggestions" id="chat-suggestions">
<span class="chat-suggestion" onclick="useSuggestion('What tools are available?')">List tools</span>
<span class="chat-suggestion" onclick="useSuggestion('Test the hello tool with name Admin')">Test hello</span>
<span class="chat-suggestion" onclick="useSuggestion('Get the current time')">Get time</span>
<span class="chat-suggestion" onclick="useSuggestion('Generate a UUID')">Generate UUID</span>
<span class="chat-suggestion test-all" onclick="useSuggestion('Run a comprehensive test of all available tools and report the results')">Test All Tools</span>
</div>
<div class="chat-input-area">
<textarea id="chat-input" placeholder="Test a tool..." rows="1" onkeydown="handleChatKeydown(event)"></textarea>
<button onclick="sendChatMessage()">Send</button>
</div>
</div>` : ''}
<!-- New API Key Modal -->
<div class="new-token-modal" id="api-key-modal">
<div class="modal-content">
<h3>API Key Created</h3>
<p class="warning">Copy this API key now. It will not be shown again!</p>
<div class="token-display" id="new-api-key-value"></div>
<div class="actions">
<button onclick="copyApiKey()">Copy</button>
<button class="secondary" onclick="closeApiKeyModal()">Close</button>
</div>
</div>
</div>
<script>
// HTML escape utility to prevent XSS
function escapeHtml(str) {
if (!str) return '';
return str.toString()
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// Load data on page load
document.addEventListener('DOMContentLoaded', () => {
loadTools();
loadResources();
loadPrompts();
loadApiKeys();
loadConnectedAccounts();
loadUsers();
// Event delegation for filter tabs (prevents XSS from category names)
const filtersContainer = document.getElementById('tools-filters');
if (filtersContainer) {
filtersContainer.addEventListener('click', (e) => {
const tab = e.target.closest('.filter-tab');
if (tab && tab.dataset.filter) {
filterTools(tab.dataset.filter);
}
});
}
// Event delegation for API key delete buttons (prevents XSS from key names)
const apiKeysList = document.getElementById('api-keys-list');
if (apiKeysList) {
apiKeysList.addEventListener('click', (e) => {
const btn = e.target.closest('.delete-api-key-btn');
if (btn) {
const id = btn.dataset.keyId;
const name = btn.dataset.keyName;
deleteApiKey(id, name);
}
});
}
});
// Store tools data for filtering
let allToolsData = [];
async function loadTools() {
try {
const res = await fetch('/api/admin/tools-metadata');
const data = await res.json();
allToolsData = data.tools || [];
const list = document.getElementById('tools-list');
const count = document.getElementById('tools-count');
const filtersContainer = document.getElementById('tools-filters');
if (allToolsData.length) {
count.textContent = '(' + allToolsData.length + ')';
// Build category filters (using data attributes, event delegation handles clicks)
const categories = ['all', ...new Set(allToolsData.map(t => t.category).filter(Boolean))];
filtersContainer.innerHTML = categories.map(cat =>
'<span class="filter-tab' + (cat === 'all' ? ' active' : '') + '" data-filter="' + escapeHtml(cat) + '">' +
(cat === 'all' ? 'All' : escapeHtml(cat.charAt(0).toUpperCase() + cat.slice(1))) + '</span>'
).join('');
renderToolsList(allToolsData);
} else {
count.textContent = '(0)';
list.innerHTML = '<div class="empty">No tools</div>';
}
} catch (e) {
console.error('Failed to load tools:', e);
document.getElementById('tools-list').innerHTML = '<div class="empty">Error loading tools</div>';
}
}
function filterTools(category) {
// Update active filter tab
document.querySelectorAll('#tools-filters .filter-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.filter === category);
});
// Filter and render
const filtered = category === 'all'
? allToolsData
: allToolsData.filter(t => t.category === category);
renderToolsList(filtered);
}
function renderToolsList(tools) {
const list = document.getElementById('tools-list');
if (!tools.length) {
list.innerHTML = '<div class="empty">No tools in this category</div>';
return;
}
list.innerHTML = tools.map(t => {
const badges = [];
if (t.category) badges.push('<span class="badge-small">' + escapeHtml(t.category) + '</span>');
// Check if tool requires auth
if (t.requiresAuth) {
badges.push('<span class="badge-small badge-auth">Auth</span>');
}
// Build form fields from inputSchema
const formFields = buildToolFormFields(t);
const safeName = escapeHtml(t.name);
// Warning note for auth-required tools
const authNote = t.requiresAuth
? '<div class="tool-auth-note">' +
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>' +
'Requires authentication. Test via MCP client (Claude.ai).' +
'</div>'
: '';
return '<div class="expandable-item" data-tool="' + safeName + '">' +
'<div class="expandable-header" onclick="toggleExpandable(this)">' +
'<div>' +
'<div class="item-title">' +
'<svg class="chevron" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m9 18 6-6-6-6"/></svg>' +
safeName +
'</div>' +
'<div class="item-desc">' + escapeHtml(t.description) + '</div>' +
'</div>' +
'<div class="badges">' + badges.join('') + '</div>' +
'</div>' +
'<div class="expandable-body">' +
authNote +
'<div class="tool-form" id="form-' + safeName + '">' +
formFields +
'<div class="tool-form-actions">' +
'<button onclick="executeTool(\\'' + safeName + '\\')">Execute</button>' +
'<button class="secondary" onclick="clearToolForm(\\'' + safeName + '\\')">Clear</button>' +
'</div>' +
'</div>' +
'<div class="tool-result" id="result-' + safeName + '" style="display:none;"></div>' +
'</div>' +
'</div>';
}).join('');
}
function buildToolFormFields(tool) {
const schema = tool.inputSchema;
if (!schema || !schema.properties) {
return '<div style="color: var(--muted-foreground); font-size: 0.85rem;">No parameters required</div>';
}
const props = schema.properties;
const required = schema.required || [];
return Object.entries(props).map(([name, prop]) => {
const isRequired = required.includes(name);
const desc = prop.description || '';
const type = prop.type || 'string';
let input = '';
if (prop.enum) {
input = '<select id="field-' + tool.name + '-' + name + '">' +
'<option value="">Select...</option>' +
prop.enum.map(v => '<option value="' + v + '">' + v + '</option>').join('') +
'</select>';
} else if (type === 'number' || type === 'integer') {
input = '<input type="number" id="field-' + tool.name + '-' + name + '" placeholder="' + desc + '">';
} else if (type === 'boolean') {
input = '<select id="field-' + tool.name + '-' + name + '">' +
'<option value="">Select...</option>' +
'<option value="true">true</option>' +
'<option value="false">false</option>' +
'</select>';
} else {
// Default to text input, use textarea for long descriptions
const isLong = desc.length > 50 || name.includes('text') || name.includes('content');
if (isLong) {
input = '<textarea id="field-' + tool.name + '-' + name + '" placeholder="' + desc + '"></textarea>';
} else {
input = '<input type="text" id="field-' + tool.name + '-' + name + '" placeholder="' + desc + '">';
}
}
return '<div class="tool-form-field">' +
'<label>' + name + (isRequired ? '<span class="required">*</span>' : '') + '</label>' +
input +
'</div>';
}).join('');
}
function toggleExpandable(header) {
const item = header.parentElement;
item.classList.toggle('open');
}
function clearToolForm(toolName) {
const form = document.getElementById('form-' + toolName);
if (form) {
form.querySelectorAll('input, select, textarea').forEach(el => el.value = '');
}
const result = document.getElementById('result-' + toolName);
if (result) result.style.display = 'none';
}
async function executeTool(toolName) {
const tool = allToolsData.find(t => t.name === toolName);
if (!tool) return;
const resultDiv = document.getElementById('result-' + toolName);
if (!resultDiv) return;
resultDiv.style.display = 'block';
resultDiv.className = 'tool-result';
resultDiv.textContent = 'Executing...';
// Gather form values
const args = {};
const schema = tool.inputSchema;
if (schema && schema.properties) {
Object.keys(schema.properties).forEach(name => {
const el = document.getElementById('field-' + toolName + '-' + name);
if (el && el.value) {
const prop = schema.properties[name];
if (prop.type === 'number' || prop.type === 'integer') {
args[name] = Number(el.value);
} else if (prop.type === 'boolean') {
args[name] = el.value === 'true';
} else {
args[name] = el.value;
}
}
});
}
try {
const res = await fetch('/api/admin/tools/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: toolName, args }),
});
const data = await res.json();
if (!data.success) {
resultDiv.className = 'tool-result error';
resultDiv.textContent = 'Error: ' + (data.error || 'Unknown error');
} else {
resultDiv.className = 'tool-result success';
// Format the result - handle both text content arrays and raw data
const result = data.result;
if (result && result.content && Array.isArray(result.content)) {
// MCP tool result format: { content: [{ type: 'text', text: '...' }] }
const text = result.content.map(c => c.text || '').join('\\n');
resultDiv.textContent = text;
} else {
resultDiv.textContent = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
}
}
} catch (e) {
resultDiv.className = 'tool-result error';
resultDiv.textContent = 'Error: ' + e.message;
}
}
let allResourcesData = [];
async function loadResources() {
try {
const res = await fetch('/api/admin/resources');
const data = await res.json();
allResourcesData = data.resources || [];
const list = document.getElementById('resources-list');
const count = document.getElementById('resources-count');
if (allResourcesData.length) {
count.textContent = '(' + allResourcesData.length + ')';
list.innerHTML = allResourcesData.map(r => {
const safeName = escapeHtml(r.name);
const safeUri = escapeHtml(r.uri);
return '<div class="expandable-item" data-resource="' + safeName + '">' +
'<div class="expandable-header" onclick="toggleExpandable(this)">' +
'<div>' +
'<div class="item-title">' +
'<svg class="chevron" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m9 18 6-6-6-6"/></svg>' +
safeName +
'</div>' +
'<div class="item-desc">' + escapeHtml(r.description) + '</div>' +
'</div>' +
'<div class="badges">' +
'<span class="badge-small">' + escapeHtml(r.mimeType || 'text/plain') + '</span>' +
'</div>' +
'</div>' +
'<div class="expandable-body">' +
'<div style="color: var(--muted-foreground); font-size: 0.8rem; margin-bottom: 0.5rem;">URI: <code>' + safeUri + '</code></div>' +
'<div class="preview-content" id="resource-preview-' + safeName + '">Click to load content...</div>' +
'<div class="preview-actions">' +
'<button class="small" onclick="loadResourceContent(\\'' + safeUri + '\\', \\'' + safeName + '\\')">Load Content</button>' +
'<button class="small secondary" onclick="copyResourceContent(\\'' + safeName + '\\')">Copy</button>' +
'</div>' +
'</div>' +
'</div>';
}).join('');
} else {
count.textContent = '(0)';
list.innerHTML = '<div class="empty">No resources</div>';
}
} catch (e) {
console.error('Failed to load resources:', e);
document.getElementById('resources-list').innerHTML = '<div class="empty">Error loading</div>';
}
}
async function loadResourceContent(uri, name) {
const preview = document.getElementById('resource-preview-' + name);
preview.textContent = 'Loading...';
// Resources are served by the MCP server, we'd need an API endpoint for this
// For now, show the URI and description
const resource = allResourcesData.find(r => r.name === name);
if (resource) {
preview.textContent = JSON.stringify({
name: resource.name,
uri: resource.uri,
description: resource.description,
mimeType: resource.mimeType,
note: 'Resource content would be loaded via MCP protocol'
}, null, 2);
}
}
function copyResourceContent(name) {
const preview = document.getElementById('resource-preview-' + name);
navigator.clipboard.writeText(preview.textContent).then(() => {
alert('Content copied to clipboard!');
});
}
let allPromptsData = [];
async function loadPrompts() {
try {
const res = await fetch('/api/admin/prompts');
const data = await res.json();
allPromptsData = data.prompts || [];
const list = document.getElementById('prompts-list');
const count = document.getElementById('prompts-count');
if (allPromptsData.length) {
count.textContent = '(' + allPromptsData.length + ')';
list.innerHTML = allPromptsData.map(p => {
const formFields = buildPromptFormFields(p);
const safeName = escapeHtml(p.name);
return '<div class="expandable-item" data-prompt="' + safeName + '">' +
'<div class="expandable-header" onclick="toggleExpandable(this)">' +
'<div>' +
'<div class="item-title">' +
'<svg class="chevron" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m9 18 6-6-6-6"/></svg>' +
safeName +
'</div>' +
'<div class="item-desc">' + escapeHtml(p.description) + '</div>' +
'</div>' +
'</div>' +
'<div class="expandable-body">' +
'<div class="tool-form" id="prompt-form-' + safeName + '">' +
formFields +
'<div class="tool-form-actions">' +
'<button onclick="previewPrompt(\\'' + safeName + '\\')">Preview</button>' +
'<button class="secondary" onclick="clearPromptForm(\\'' + safeName + '\\')">Clear</button>' +
'</div>' +
'</div>' +
'<div class="preview-content" id="prompt-preview-' + safeName + '" style="display:none; margin-top: 0.75rem;"></div>' +
'</div>' +
'</div>';
}).join('');
} else {
count.textContent = '(0)';
list.innerHTML = '<div class="empty">No prompts</div>';
}
} catch (e) {
console.error('Failed to load prompts:', e);
document.getElementById('prompts-list').innerHTML = '<div class="empty">Error loading</div>';
}
}
function buildPromptFormFields(prompt) {
const schema = prompt.inputSchema;
if (!schema || !schema.properties) {
return '<div style="color: var(--muted-foreground); font-size: 0.85rem;">No parameters</div>';
}
const props = schema.properties;
const required = schema.required || [];
return Object.entries(props).map(([name, prop]) => {
const isRequired = required.includes(name);
const desc = prop.description || '';
const type = prop.type || 'string';
let input = '';
if (prop.enum) {
input = '<select id="prompt-field-' + prompt.name + '-' + name + '">' +
'<option value="">Select...</option>' +
prop.enum.map(v => '<option value="' + v + '">' + v + '</option>').join('') +
'</select>';
} else {
const isLong = name.includes('content') || name.includes('text') || desc.length > 50;
if (isLong) {
input = '<textarea id="prompt-field-' + prompt.name + '-' + name + '" placeholder="' + desc + '"></textarea>';
} else {
input = '<input type="text" id="prompt-field-' + prompt.name + '-' + name + '" placeholder="' + desc + '">';
}
}
return '<div class="tool-form-field">' +
'<label>' + name + (isRequired ? '<span class="required">*</span>' : '') + '</label>' +
input +
'</div>';
}).join('');
}
function clearPromptForm(promptName) {
const form = document.getElementById('prompt-form-' + promptName);
if (form) {
form.querySelectorAll('input, select, textarea').forEach(el => el.value = '');
}
const preview = document.getElementById('prompt-preview-' + promptName);
if (preview) preview.style.display = 'none';
}
function previewPrompt(promptName) {
const prompt = allPromptsData.find(p => p.name === promptName);
if (!prompt) return;
const previewDiv = document.getElementById('prompt-preview-' + promptName);
previewDiv.style.display = 'block';
// Gather form values
const args = {};
const schema = prompt.inputSchema;
if (schema && schema.properties) {
Object.keys(schema.properties).forEach(name => {
const el = document.getElementById('prompt-field-' + promptName + '-' + name);
if (el && el.value) {
args[name] = el.value;
}
});
}
// Generate preview (this would ideally call the prompt API)
let preview = 'Prompt: ' + promptName + '\\n\\nParameters:\\n';
Object.entries(args).forEach(([k, v]) => {
preview += ' ' + k + ': ' + v + '\\n';
});
preview += '\\n(Actual prompt rendering requires MCP protocol call)';
previewDiv.textContent = preview;
}
// ===== API Key Management =====
async function loadApiKeys() {
try {
const res = await fetch('/api/admin/api-keys');
const data = await res.json();
const list = document.getElementById('api-keys-list');
const countEl = document.getElementById('api-keys-count');
if (data.keys?.length) {
countEl.textContent = '(' + data.keys.length + ')';
list.innerHTML = data.keys.map(k => \`
<li class="token-item">
<div class="token-info">
<h3>\${escapeHtml(k.name)}</h3>
<code>\${escapeHtml(k.start || '****')}...</code>
<small>
Created \${new Date(k.createdAt).toLocaleDateString()}
\${k.lastRequest ? ' · Last used ' + new Date(k.lastRequest).toLocaleDateString() : ''}
\${k.expiresAt ? ' · Expires ' + new Date(k.expiresAt).toLocaleDateString() : ''}
\${!k.enabled ? ' · <span style="color: var(--destructive)">Disabled</span>' : ''}
</small>
</div>
<button class="danger small delete-api-key-btn" data-key-id="\${escapeHtml(k.id)}" data-key-name="\${escapeHtml(k.name)}">Delete</button>
</li>
\`).join('');
} else {
countEl.textContent = '(0)';
list.innerHTML = '<li class="empty">No API keys. Create one above.</li>';
}
} catch (e) {
document.getElementById('api-keys-list').innerHTML = '<li class="empty">Error loading API keys</li>';
document.getElementById('api-keys-count').textContent = '';
}
}
async function createApiKey(e) {
e.preventDefault();
const nameInput = document.getElementById('api-key-name');
const name = nameInput.value.trim() || 'Unnamed Key';
try {
const res = await fetch('/api/admin/api-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!res.ok) throw new Error('Failed to create API key');
const data = await res.json();
// Show modal with full key
document.getElementById('new-api-key-value').textContent = data.key.key;
document.getElementById('api-key-modal').classList.add('show');
// Clear input and reload list
nameInput.value = '';
loadApiKeys();
} catch (e) {
alert('Error creating API key: ' + e.message);
}
}
async function deleteApiKey(id, name) {
if (!confirm('Delete API key "' + name + '"? This cannot be undone.')) return;
try {
const res = await fetch('/api/admin/api-keys/' + id, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete API key');
loadApiKeys();
} catch (e) {
alert('Error deleting API key: ' + e.message);
}
}
function copyApiKey() {
const key = document.getElementById('new-api-key-value').textContent;
navigator.clipboard.writeText(key).then(() => {
alert('API key copied to clipboard!');
});
}
function closeApiKeyModal() {
document.getElementById('api-key-modal').classList.remove('show');
}
// ===== Connected Accounts Management =====
// Note: All dynamic values sanitized via escapeHtml() to prevent XSS
async function loadConnectedAccounts() {
try {
const res = await fetch('/api/admin/connected-accounts');
const data = await res.json();
const list = document.getElementById('connected-accounts-list');
const countEl = document.getElementById('connected-accounts-count');
if (data.accounts?.length) {
countEl.textContent = '(' + data.accounts.length + ')';
list.innerHTML = data.accounts.map(acc => {
const alias = acc.alias !== 'default' ? ' (' + escapeHtml(acc.alias) + ')' : '';
const displayName = acc.displayName ? escapeHtml(acc.displayName) : '';
const statusIcon = acc.isExpired ? '⚠️' : '✅';
const statusText = acc.isExpired ? 'Expired' : 'Active';
const statusClass = acc.isExpired ? 'color: var(--destructive)' : 'color: var(--success)';
const scopes = acc.scopes?.slice(0, 2).join(', ') + (acc.scopes?.length > 2 ? ' (+' + (acc.scopes.length - 2) + ')' : '');
return \`
<li class="token-item">
<div class="token-info">
<h3>\${escapeHtml(acc.provider)}\${alias}</h3>
<code>\${displayName}</code>
<small>
<span style="\${statusClass}">\${statusIcon} \${statusText}</span>
· Connected \${new Date(acc.connectedAt).toLocaleDateString()}
\${acc.expiresAt ? ' · Expires ' + new Date(acc.expiresAt).toLocaleDateString() : ''}
\${scopes ? '<br>Scopes: ' + escapeHtml(scopes) : ''}
</small>
</div>
<button class="danger small disconnect-account-btn"
data-provider="\${escapeHtml(acc.provider)}"
data-alias="\${escapeHtml(acc.alias)}">Disconnect</button>
</li>
\`;
}).join('');
} else {
countEl.textContent = '(0)';
list.innerHTML = '<li class="empty">No connected accounts. Connect via OAuth flow.</li>';
}
} catch (e) {
document.getElementById('connected-accounts-list').innerHTML = '<li class="empty">Error loading accounts</li>';
document.getElementById('connected-accounts-count').textContent = '';
}
}
async function disconnectAccount(provider, alias) {
const aliasDisplay = alias !== 'default' ? ' (' + alias + ')' : '';
if (!confirm('Disconnect ' + provider + aliasDisplay + '? You will need to re-authenticate to use this account.')) return;
try {
const res = await fetch('/api/admin/connected-accounts/' + encodeURIComponent(provider) + '/' + encodeURIComponent(alias), {
method: 'DELETE',
});
if (!res.ok) throw new Error('Failed to disconnect account');
loadConnectedAccounts();
} catch (e) {
alert('Error disconnecting account: ' + e.message);
}
}
// Event delegation for disconnect buttons
document.addEventListener('click', function(e) {
const btn = e.target.closest('.disconnect-account-btn');
if (btn) {
const provider = btn.dataset.provider;
const alias = btn.dataset.alias || 'default';
disconnectAccount(provider, alias);
}
});
async function logout() {
await fetch('/api/admin/logout', { method: 'POST' });
window.location.href = '/';
}
// ===== User Management =====
let usersData = { users: [], total: 0, offset: 0 };
const USERS_LIMIT = 20;
// Event delegation for user action buttons (prevents XSS)
document.addEventListener('click', function(e) {
const btn = e.target.closest('[data-user-action]');
if (!btn) return;
const action = btn.dataset.userAction;
const userId = btn.dataset.userId;
const userName = btn.dataset.userName || '';
switch (action) {
case 'make-admin':
setUserRole(userId, 'admin');
break;
case 'remove-admin':
setUserRole(userId, 'user');
break;
case 'ban':
banUser(userId);
break;
case 'unban':
unbanUser(userId);
break;
case 'impersonate':
impersonateUser(userId, userName);
break;
}
});
async function loadUsers(direction) {
try {
let offset = usersData.offset;
if (direction === 'prev') {
offset = Math.max(0, offset - USERS_LIMIT);
} else if (direction === 'next') {
offset = offset + USERS_LIMIT;
} else {
offset = 0;
}
const searchEl = document.getElementById('users-search');
const search = searchEl ? searchEl.value.trim() : '';
const params = new URLSearchParams({
limit: USERS_LIMIT.toString(),
offset: offset.toString(),
});
if (search) params.set('search', search);
const res = await fetch('/api/admin/users?' + params);
if (!res.ok) throw new Error('Failed to load users');
const data = await res.json();
usersData = { users: data.users || [], total: data.total || 0, offset: offset };
renderUsersTable();
} catch (e) {
console.error('Failed to load users:', e);
const tbody = document.getElementById('users-list');
if (tbody) {
tbody.textContent = '';
const tr = document.createElement('tr');
const td = document.createElement('td');
td.colSpan = 5;
td.className = 'empty';
td.textContent = 'Error loading users';
tr.appendChild(td);
tbody.appendChild(tr);
}
}
}
function renderUsersTable() {
const tbody = document.getElementById('users-list');
const paginationInfo = document.getElementById('users-pagination-info');
const prevBtn = document.getElementById('users-prev');
const nextBtn = document.getElementById('users-next');
if (!tbody) return;
tbody.textContent = '';
if (!usersData.users.length) {
const tr = document.createElement('tr');
const td = document.createElement('td');
td.colSpan = 5;
td.className = 'empty';
td.textContent = 'No users found';
tr.appendChild(td);
tbody.appendChild(tr);
if (paginationInfo) paginationInfo.textContent = 'Showing 0 users';
if (prevBtn) prevBtn.disabled = true;
if (nextBtn) nextBtn.disabled = true;
return;
}
usersData.users.forEach(function(u) {
const tr = document.createElement('tr');
// User cell with avatar
const tdUser = document.createElement('td');
if (u.image) {
const img = document.createElement('img');
img.className = 'user-avatar';
img.src = u.image;
img.alt = '';
tdUser.appendChild(img);
} else {
const span = document.createElement('span');
span.className = 'user-avatar';
span.style.cssText = 'display:inline-block;background:var(--muted);text-align:center;line-height:28px;';
span.textContent = (u.name || u.email || '?')[0].toUpperCase();
tdUser.appendChild(span);
}
const nameSpan = document.createElement('span');
nameSpan.textContent = u.name || '-';
tdUser.appendChild(nameSpan);
const emailDiv = document.createElement('div');
emailDiv.className = 'user-email';
emailDiv.textContent = u.email || '';
tdUser.appendChild(emailDiv);
tr.appendChild(tdUser);
// Role cell
const tdRole = document.createElement('td');
const roleBadge = document.createElement('span');
roleBadge.className = 'role-badge ' + (u.role === 'admin' ? 'admin' : 'user');
roleBadge.textContent = u.role === 'admin' ? 'Admin' : 'User';
tdRole.appendChild(roleBadge);
tr.appendChild(tdRole);
// Status cell
const tdStatus = document.createElement('td');
const statusBadge = document.createElement('span');
statusBadge.className = 'status-badge ' + (u.banned ? 'banned' : 'active');
statusBadge.textContent = u.banned ? 'Banned' : 'Active';
tdStatus.appendChild(statusBadge);
tr.appendChild(tdStatus);
// Created cell
const tdCreated = document.createElement('td');
tdCreated.textContent = u.createdAt ? new Date(u.createdAt).toLocaleDateString() : '-';
tr.appendChild(tdCreated);
// Actions cell
const tdActions = document.createElement('td');
const actionsDiv = document.createElement('div');
actionsDiv.className = 'user-actions';
// Role toggle button
const roleBtn = document.createElement('button');
roleBtn.className = u.role === 'admin' ? 'small secondary' : 'small';
roleBtn.textContent = u.role === 'admin' ? 'Remove Admin' : 'Make Admin';
roleBtn.dataset.userAction = u.role === 'admin' ? 'remove-admin' : 'make-admin';
roleBtn.dataset.userId = u.id;
actionsDiv.appendChild(roleBtn);
// Ban/unban button
const banBtn = document.createElement('button');
if (u.banned) {
banBtn.className = 'small';
banBtn.textContent = 'Unban';
banBtn.dataset.userAction = 'unban';
} else {
banBtn.className = 'small danger';
banBtn.textContent = 'Ban';
banBtn.dataset.userAction = 'ban';
}
banBtn.dataset.userId = u.id;
actionsDiv.appendChild(banBtn);
// Impersonate button
const impBtn = document.createElement('button');
impBtn.className = 'small secondary';
impBtn.textContent = 'Impersonate';
impBtn.dataset.userAction = 'impersonate';
impBtn.dataset.userId = u.id;
impBtn.dataset.userName = u.email || u.name || '';
actionsDiv.appendChild(impBtn);
tdActions.appendChild(actionsDiv);
tr.appendChild(tdActions);
tbody.appendChild(tr);
});
// Update pagination
const start = usersData.offset + 1;
const end = Math.min(usersData.offset + usersData.users.length, usersData.total);
if (paginationInfo) paginationInfo.textContent = 'Showing ' + start + '-' + end + ' of ' + usersData.total + ' users';
if (prevBtn) prevBtn.disabled = usersData.offset === 0;
if (nextBtn) nextBtn.disabled = usersData.offset + USERS_LIMIT >= usersData.total;
}
async function setUserRole(userId, role) {
if (!confirm('Set user role to "' + role + '"?')) return;
try {
const res = await fetch('/api/admin/users/' + encodeURIComponent(userId) + '/role', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: role }),
});
if (!res.ok) throw new Error('Failed to set role');
loadUsers();
} catch (e) {
alert('Error: ' + e.message);
}
}
async function banUser(userId) {
const reason = prompt('Ban reason (optional):');
if (reason === null) return;
try {
const res = await fetch('/api/admin/users/' + encodeURIComponent(userId) + '/ban', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason: reason || undefined }),
});
if (!res.ok) throw new Error('Failed to ban user');
loadUsers();
} catch (e) {
alert('Error: ' + e.message);
}
}
async function unbanUser(userId) {
if (!confirm('Unban this user?')) return;
try {
const res = await fetch('/api/admin/users/' + encodeURIComponent(userId) + '/unban', {
method: 'POST',
});
if (!res.ok) throw new Error('Failed to unban user');
loadUsers();
} catch (e) {
alert('Error: ' + e.message);
}
}
async function impersonateUser(userId, userName) {
if (!confirm('Impersonate ' + userName + '? You will be logged in as this user.')) return;
try {
const res = await fetch('/api/admin/users/' + encodeURIComponent(userId) + '/impersonate', {
method: 'POST',
});
if (!res.ok) throw new Error('Failed to impersonate');
const banner = document.getElementById('impersonation-banner');
const impersonatedEl = document.getElementById('impersonated-user');
if (banner) banner.style.display = 'flex';
if (impersonatedEl) impersonatedEl.textContent = userName;
alert('Now impersonating ' + userName + '. Session has changed.');
window.location.reload();
} catch (e) {
alert('Error: ' + e.message);
}
}
async function stopImpersonating() {
try {
const res = await fetch('/api/admin/stop-impersonating', {
method: 'POST',
});
if (!res.ok) throw new Error('Failed to stop impersonating');
const banner = document.getElementById('impersonation-banner');
if (banner) banner.style.display = 'none';
alert('Stopped impersonating. Returning to your session.');
window.location.reload();
} catch (e) {
alert('Error: ' + e.message);
}
}
// ===== Markdown Parser =====
function parseMarkdown(text) {
if (!text) return '';
// Escape HTML first
let html = text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
// Code blocks (triple backticks) - must be before other transforms
html = html.replace(/\x60\x60\x60([\\s\\S]*?)\x60\x60\x60/g, '<pre><code>$1</code></pre>');
// Inline code (single backticks)
html = html.replace(/\x60([^\x60]+)\x60/g, '<code>$1</code>');
// Tables - must be before headings (since | might conflict)
// Match markdown tables: header row, separator row, data rows
html = html.replace(/^(\\|.+\\|\\n)(\\|[-:| ]+\\|\\n)((?:\\|.+\\|\\n?)+)/gm, function(match, headerRow, separator, bodyRows) {
// Parse header
const headers = headerRow.trim().split('|').filter(c => c.trim());
let tableHtml = '<table><thead><tr>';
headers.forEach(h => {
tableHtml += '<th>' + h.trim() + '</th>';
});
tableHtml += '</tr></thead><tbody>';
// Parse body rows
const rows = bodyRows.trim().split('\\n');
rows.forEach(row => {
if (!row.trim()) return;
const cells = row.split('|').filter(c => c.trim() !== '');
tableHtml += '<tr>';
cells.forEach(cell => {
tableHtml += '<td>' + cell.trim() + '</td>';
});
tableHtml += '</tr>';
});
tableHtml += '</tbody></table>';
return tableHtml;
});
// Headings (must be at start of line)
html = html.replace(/^###### (.+)$/gm, '<h6>$1</h6>');
html = html.replace(/^##### (.+)$/gm, '<h5>$1</h5>');
html = html.replace(/^#### (.+)$/gm, '<h4>$1</h4>');
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
// Bold (**text** or __text__)
html = html.replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>');
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
// Italic (*text* or _text_) - but not inside words
html = html.replace(/(?<![\\w])\\*([^*]+)\\*(?![\\w])/g, '<em>$1</em>');
// Unordered lists (- item or * item)
html = html.replace(/^[\\-\\*] (.+)$/gm, '<li>$1</li>');
// Ordered lists (1. item)
html = html.replace(/^\\d+\\. (.+)$/gm, '<li>$1</li>');
// Wrap consecutive <li> in <ul>
html = html.replace(/(<li>[^<]*<\\/li>\\n?)+/g, function(match) {
return '<ul>' + match + '</ul>';
});
// Paragraphs - split by double newline
const parts = html.split(/\\n\\n+/);
html = parts.map(p => {
p = p.trim();
if (!p) return '';
// Don't wrap block elements
if (p.startsWith('<h') || p.startsWith('<ul') || p.startsWith('<ol') || p.startsWith('<pre') || p.startsWith('<table')) {
return p;
}
return '<p>' + p.replace(/\\n/g, '<br>') + '</p>';
}).join('');
return html;
}
// ===== AI Chat Functions =====
let chatSessionId = null;
let modelsData = { models: [], providers: [] };
// Provider display names
const PROVIDER_NAMES = {
'cloudflare': 'Workers AI (Free)',
'anthropic': 'Anthropic',
'google-ai-studio': 'Google AI',
'openai': 'OpenAI',
'groq': 'Groq',
'mistral': 'Mistral',
'deepseek': 'DeepSeek',
'cohere': 'Cohere',
'grok': 'xAI (Grok)',
};
// Load models from dynamic endpoint
async function loadModels() {
try {
const res = await fetch('/api/admin/models');
const data = await res.json();
modelsData = data;
// Populate both sidebar and mobile panel dropdowns
populateProviderDropdown('sidebar-provider');
populateProviderDropdown('chat-provider');
} catch (e) {
console.error('Failed to load models:', e);
}
}
// ===== Conversation History =====
let chatSessions = [];
async function loadChatHistory() {
try {
const res = await fetch('/api/admin/chat/sessions');
const data = await res.json();
chatSessions = data.sessions || [];
const countEl = document.getElementById('history-count');
const listEl = document.getElementById('history-list');
countEl.textContent = '(' + chatSessions.length + ')';
if (chatSessions.length === 0) {
listEl.innerHTML = '<div class="chat-history-empty">No previous conversations</div>';
return;
}
listEl.innerHTML = chatSessions.map(session => {
const date = new Date(session.updatedAt || session.createdAt);
const dateStr = date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
const title = session.title || 'Conversation';
const msgCount = session.messageCount || 0;
const isActive = session.id === chatSessionId;
return '<div class="chat-history-item' + (isActive ? ' active' : '') + '" onclick="loadSession(\\'' + session.id + '\\')">' +
'<div class="session-info">' +
'<div class="session-title">' + title + '</div>' +
'<div class="session-meta">' + dateStr + ' • ' + msgCount + ' msgs</div>' +
'</div>' +
'<button class="delete-btn danger small" onclick="event.stopPropagation(); deleteSession(\\'' + session.id + '\\')">Delete</button>' +
'</div>';
}).join('');
} catch (e) {
console.error('Failed to load chat history:', e);
}
}
function toggleChatHistory() {
const toggle = document.getElementById('history-toggle');
const list = document.getElementById('history-list');
toggle.classList.toggle('open');
list.classList.toggle('open');
// Load history when opening
if (list.classList.contains('open')) {
loadChatHistory();
}
}
async function loadSession(sessionId) {
try {
const res = await fetch('/api/admin/chat/' + sessionId);
if (!res.ok) throw new Error('Failed to load session');
const data = await res.json();
const session = data.session;
chatSessionId = session.id;
// Set provider and model
if (session.provider) {
document.getElementById('sidebar-provider').value = session.provider;
updateSidebarModels();
}
if (session.model) {
document.getElementById('sidebar-model').value = session.model;
}
// Render messages
const messagesContainer = document.getElementById('sidebar-messages');
messagesContainer.innerHTML = '';
session.messages.forEach(msg => {
if (msg.role === 'system') return; // Skip system messages
if (msg.toolCalls) {
// Tool call message
msg.toolCalls.forEach(tc => {
const div = document.createElement('div');
div.className = 'chat-message tool-call';
div.innerHTML = '<strong>Tool Call: ' + escapeHtml(tc.name) + '</strong><pre>' + escapeHtml(JSON.stringify(tc.arguments, null, 2)) + '</pre>';
messagesContainer.appendChild(div);
});
}
if (msg.toolResults) {
// Tool result message
msg.toolResults.forEach(tr => {
const isError = !!tr.error;
const div = document.createElement('div');
div.className = 'chat-message tool-result' + (isError ? ' error' : '');
const content = tr.error || (typeof tr.result === 'string' ? tr.result : JSON.stringify(tr.result, null, 2));
div.innerHTML = '<strong>' + (isError ? 'Error' : 'Result') + ': ' + escapeHtml(tr.name) + '</strong><pre>' + escapeHtml(content) + '</pre>';
messagesContainer.appendChild(div);
});
}
if (msg.content) {
addMessage(messagesContainer, msg.role, msg.content);
}
});
messagesContainer.scrollTop = messagesContainer.scrollHeight;
// Update active state in history
loadChatHistory();
// Close history panel
document.getElementById('history-toggle').classList.remove('open');
document.getElementById('history-list').classList.remove('open');
} catch (e) {
console.error('Failed to load session:', e);
alert('Failed to load session');
}
}
async function deleteSession(sessionId) {
if (!confirm('Delete this conversation?')) return;
try {
const res = await fetch('/api/admin/chat/' + sessionId, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete');
// If deleting current session, clear it
if (sessionId === chatSessionId) {
newChatSession();
}
loadChatHistory();
} catch (e) {
console.error('Failed to delete session:', e);
alert('Failed to delete session');
}
}
function populateProviderDropdown(selectId) {
const select = document.getElementById(selectId);
if (!select) return;
// Extract provider IDs - providers can be objects {id, name} or strings
const providerIds = modelsData.providers.map(p => typeof p === 'object' ? p.id : p);
// Always include cloudflare first (free), then others
const providers = ['cloudflare', ...providerIds.filter(p => p !== 'cloudflare')];
select.innerHTML = providers.map(p => {
// Use name from provider object or fallback to PROVIDER_NAMES
const providerObj = modelsData.providers.find(prov => (typeof prov === 'object' ? prov.id : prov) === p);
const name = (typeof providerObj === 'object' ? providerObj.name : null) || PROVIDER_NAMES[p] || p;
return '<option value="' + p + '">' + name + '</option>';
}).join('');
// Trigger model update
if (selectId === 'sidebar-provider') {
updateSidebarModels();
} else {
updateChatModel();
}
}
function updateSidebarModels() {
const provider = document.getElementById('sidebar-provider').value;
const modelSelect = document.getElementById('sidebar-model');
populateModelDropdown(provider, modelSelect);
updateModelDetails('sidebar');
}
function updateChatModel() {
const provider = document.getElementById('chat-provider').value;
const modelSelect = document.getElementById('chat-model');
populateModelDropdown(provider, modelSelect);
updateModelDetails('chat');
}
function populateModelDropdown(provider, selectElement) {
if (!selectElement) return;
const models = modelsData.models.filter(m => m.gatewayProvider === provider || m.provider === provider);
if (models.length === 0) {
selectElement.innerHTML = '<option value="">No models available</option>';
return;
}
selectElement.innerHTML = models.map(m => {
// Clean up model name for display
let label = m.name || m.id;
if (m.id.startsWith('@cf/')) {
label = m.id.replace('@cf/', '').replace('meta/', '').replace('qwen/', '');
}
// Add pricing if available
let suffix = '';
if (m.pricing && m.pricing.prompt) {
const cost = m.pricing.prompt;
if (cost < 1) {
suffix = ' ($' + cost.toFixed(3) + '/M)';
} else {
suffix = ' ($' + cost.toFixed(2) + '/M)';
}
}
return '<option value="' + m.id + '">' + label + suffix + '</option>';
}).join('');
}
function toggleModelDetails(prefix) {
const toggle = document.getElementById(prefix + '-model-details-toggle');
const panel = document.getElementById(prefix + '-model-details-panel');
if (toggle) toggle.classList.toggle('open');
if (panel) panel.classList.toggle('open');
}
function updateModelDetails(prefix) {
const modelSelect = document.getElementById(prefix + '-model');
const toggle = document.getElementById(prefix + '-model-details-toggle');
const panel = document.getElementById(prefix + '-model-details-panel');
if (!modelSelect || !modelSelect.value) {
// No model selected - hide toggle
if (toggle) toggle.classList.add('model-details-hidden');
if (panel) {
panel.classList.remove('open');
if (toggle) toggle.classList.remove('open');
}
return;
}
// Find model data
const model = modelsData.models.find(m => m.id === modelSelect.value);
if (!model) {
if (toggle) toggle.classList.add('model-details-hidden');
return;
}
// Show toggle
if (toggle) toggle.classList.remove('model-details-hidden');
// Format context length
let contextK = '-';
if (model.contextLength) {
contextK = model.contextLength >= 1000
? Math.round(model.contextLength / 1000) + 'k tokens'
: model.contextLength + ' tokens';
}
// Format pricing
const formatPrice = function(price) {
if (!price || price === 0) return 'Free';
if (price < 0.01) return '$' + price.toFixed(4) + '/M';
if (price < 1) return '$' + price.toFixed(3) + '/M';
return '$' + price.toFixed(2) + '/M';
};
// Update fields
const setEl = function(id, val) {
const el = document.getElementById(id);
if (el) el.textContent = val;
};
setEl(prefix + '-detail-context', contextK);
setEl(prefix + '-detail-input', formatPrice(model.pricing?.prompt));
setEl(prefix + '-detail-output', formatPrice(model.pricing?.completion));
setEl(prefix + '-detail-provider', PROVIDER_NAMES[model.gatewayProvider] || model.gatewayProvider || model.provider || '-');
// Date (only for OpenRouter models - hide row if not available)
const dateRow = document.getElementById(prefix + '-detail-date-row');
const dateValue = document.getElementById(prefix + '-detail-date');
if (model.created && !model.id.startsWith('@cf/') && !model.id.startsWith('@hf/')) {
if (dateRow) dateRow.style.display = 'flex';
if (dateValue) dateValue.textContent = model.created;
} else {
if (dateRow) dateRow.style.display = 'none';
}
// Description (only for Workers AI)
const descEl = document.getElementById(prefix + '-detail-description');
if (descEl) {
if (model.description) {
descEl.textContent = model.description;
descEl.style.display = 'block';
} else {
descEl.style.display = 'none';
}
}
}
function toggleChat() {
const panel = document.getElementById('chat-panel');
panel.classList.toggle('open');
}
function newChatSession() {
chatSessionId = null;
// Clear both sidebar and mobile panel messages
const sidebarMessages = document.getElementById('sidebar-messages');
const panelMessages = document.getElementById('chat-messages');
const resetHtml = '<div class="chat-message assistant">Hi! I am the AI tool tester. I can help you test the MCP tools on this server. Try asking me to "list available tools" or "test the hello tool with name John".</div>';
if (sidebarMessages) sidebarMessages.innerHTML = resetHtml;
if (panelMessages) panelMessages.innerHTML = resetHtml;
}
// Sidebar (desktop) handlers
function handleSidebarKeydown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendSidebarMessage();
}
}
function useSidebarSuggestion(text) {
document.getElementById('sidebar-input').value = text;
sendSidebarMessage();
}
async function sendSidebarMessage() {
const input = document.getElementById('sidebar-input');
const message = input.value.trim();
if (!message) return;
input.value = '';
const messagesContainer = document.getElementById('sidebar-messages');
const provider = document.getElementById('sidebar-provider').value;
const model = document.getElementById('sidebar-model').value;
await sendMessage(message, messagesContainer, provider, model);
}
// Mobile panel handlers
function handleChatKeydown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendChatMessage();
}
}
function useSuggestion(text) {
document.getElementById('chat-input').value = text;
sendChatMessage();
}
async function sendChatMessage() {
const input = document.getElementById('chat-input');
const message = input.value.trim();
if (!message) return;
input.value = '';
const messagesContainer = document.getElementById('chat-messages');
const provider = document.getElementById('chat-provider').value;
const model = document.getElementById('chat-model').value;
await sendMessage(message, messagesContainer, provider, model);
}
// Shared message sending logic
function addMessage(container, role, content, extraClass = '') {
const div = document.createElement('div');
div.className = 'chat-message ' + role + ' ' + extraClass;
if (role === 'assistant' && content) {
div.innerHTML = parseMarkdown(content);
} else {
div.textContent = content;
}
container.appendChild(div);
container.scrollTop = container.scrollHeight;
return div;
}
function addTypingIndicator(container) {
const div = document.createElement('div');
div.className = 'typing-indicator';
div.id = 'typing-indicator-' + container.id;
div.innerHTML = '<span></span><span></span><span></span>';
container.appendChild(div);
container.scrollTop = container.scrollHeight;
}
function removeTypingIndicator(container) {
const indicator = document.getElementById('typing-indicator-' + container.id);
if (indicator) indicator.remove();
}
async function sendMessage(message, messagesContainer, provider, model) {
addMessage(messagesContainer, 'user', message);
addTypingIndicator(messagesContainer);
try {
const res = await fetch('/api/admin/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId: chatSessionId,
message,
provider,
model,
}),
});
if (!res.ok) {
removeTypingIndicator(messagesContainer);
addMessage(messagesContainer, 'assistant', 'Error: ' + (await res.text()));
return;
}
// Process SSE stream
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let contentDiv = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('event: ')) {
continue;
}
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
removeTypingIndicator(messagesContainer);
if (data.content) {
if (!contentDiv) {
contentDiv = addMessage(messagesContainer, 'assistant', '');
contentDiv._rawContent = '';
}
contentDiv._rawContent += data.content;
contentDiv.innerHTML = parseMarkdown(contentDiv._rawContent);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
if (data.name && data.arguments !== undefined) {
const argsStr = escapeHtml(JSON.stringify(data.arguments, null, 2));
const div = addMessage(messagesContainer, 'tool-call', '', 'tool-call');
div.innerHTML = '<strong>Tool Call: ' + escapeHtml(data.name) + '</strong><pre>' + argsStr + '</pre>';
}
if (data.result !== undefined || data.error) {
const isError = !!data.error;
const content = escapeHtml(data.error || (typeof data.result === 'string' ? data.result : JSON.stringify(data.result, null, 2)));
const div = addMessage(messagesContainer, 'tool-result', '', isError ? 'tool-result error' : 'tool-result');
div.innerHTML = '<strong>' + (isError ? 'Error' : 'Result') + ': ' + escapeHtml(data.name || '') + '</strong><pre>' + content + '</pre>';
contentDiv = null;
}
if (data.sessionId) {
chatSessionId = data.sessionId;
}
} catch (e) {
console.error('Failed to parse SSE data:', e);
}
}
}
}
} catch (e) {
removeTypingIndicator(messagesContainer);
addMessage(messagesContainer, 'assistant', 'Error: ' + e.message);
}
}
// Load models and history count on page load
document.addEventListener('DOMContentLoaded', () => {
loadModels();
loadChatHistory(); // Load count even when collapsed
});
</script>
</body>
</html>`;
}