- Replace single 'Connect to Claude / ChatGPT' button with a modal picker offering Claude.ai web, Claude Desktop, Codex CLI, and ChatGPT/GPT Actions. - Add /oauth/connect-claude-ai backend route that redirects to Anthropic's official https://claude.ai/api/mcp/auth_callback OAuth callback. - Update MCP callback result page with browser-specific instructions for Claude.ai web, Claude Desktop, ChatGPT/GPT Actions, and Codex CLI. - Deploy new app and hermes images to K8s.
780 lines
26 KiB
JavaScript
780 lines
26 KiB
JavaScript
const API_BASE = 'https://hermes.squaremcp.com';
|
|
|
|
// State
|
|
let currentUser = null;
|
|
let isAdmin = false;
|
|
|
|
// DOM refs
|
|
const loginView = document.getElementById('login-view');
|
|
const resetRequestView = document.getElementById('reset-request-view');
|
|
const resetConfirmView = document.getElementById('reset-confirm-view');
|
|
const dashboardView = document.getElementById('dashboard-view');
|
|
const loginForm = document.getElementById('login-form');
|
|
const signupForm = document.getElementById('signup-form');
|
|
const resetRequestForm = document.getElementById('reset-request-form');
|
|
const resetConfirmForm = document.getElementById('reset-confirm-form');
|
|
const loginError = document.getElementById('login-error');
|
|
const signupError = document.getElementById('signup-error');
|
|
const tabBtns = document.querySelectorAll('.tab-btn');
|
|
const userEmail = document.getElementById('user-email');
|
|
const logoutBtn = document.getElementById('logout-btn');
|
|
const modal = document.getElementById('connect-modal');
|
|
const modalBody = document.getElementById('modal-body');
|
|
const modalClose = document.querySelector('.modal-close');
|
|
const modalBackdrop = document.querySelector('.modal-backdrop');
|
|
const navLinks = document.querySelectorAll('.nav-link');
|
|
const platformGrid = document.querySelector('.platform-grid');
|
|
const analyticsSection = document.getElementById('analytics-section');
|
|
const invoicesSection = document.getElementById('invoices-section');
|
|
const adminSection = document.getElementById('admin-section');
|
|
const adminNav = document.getElementById('admin-nav');
|
|
|
|
let platformChartInstance = null;
|
|
let dailyChartInstance = null;
|
|
|
|
// Platform config
|
|
const PLATFORM_CONFIG = {
|
|
tiktok: {
|
|
name: 'TikTok',
|
|
type: 'oauth',
|
|
oauthUrl: 'https://tiktok.squaremcp.com/auth/tiktok/start',
|
|
scopes: 'user.info.basic,user.info.profile,user.info.stats,video.list,video.publish',
|
|
},
|
|
facebook: {
|
|
name: 'Facebook',
|
|
type: 'token',
|
|
fields: [
|
|
{ key: 'accessToken', label: 'Page Access Token', type: 'password' },
|
|
{ key: 'pageId', label: 'Page ID', type: 'text' },
|
|
],
|
|
help: 'Get your Page Access Token from the Facebook Graph API Explorer. Run GET /me/accounts and copy the access_token for your page.',
|
|
},
|
|
instagram: {
|
|
name: 'Instagram',
|
|
type: 'token',
|
|
fields: [
|
|
{ key: 'accessToken', label: 'Instagram Access Token', type: 'password' },
|
|
{ key: 'businessAccountId', label: 'Business Account ID', type: 'text' },
|
|
],
|
|
help: 'Your Instagram account must be a Business/Creator account connected to a Facebook Page. Get the token from Graph API Explorer with instagram_basic scope.',
|
|
},
|
|
linkedin: {
|
|
name: 'LinkedIn',
|
|
type: 'token',
|
|
fields: [
|
|
{ key: 'accessToken', label: 'LinkedIn Access Token', type: 'password' },
|
|
],
|
|
help: 'Generate an access token from the LinkedIn Developer Portal with w_member_social scope.',
|
|
},
|
|
twitter: {
|
|
name: 'Twitter / X',
|
|
type: 'token',
|
|
fields: [
|
|
{ key: 'accessToken', label: 'Access Token', type: 'password' },
|
|
{ key: 'accessTokenSecret', label: 'Access Token Secret', type: 'password' },
|
|
{ key: 'apiKey', label: 'API Key', type: 'password' },
|
|
{ key: 'apiSecret', label: 'API Secret', type: 'password' },
|
|
],
|
|
help: 'Get all four values from the Twitter Developer Portal under Keys and Tokens.',
|
|
},
|
|
telegram: {
|
|
name: 'Telegram',
|
|
type: 'token',
|
|
fields: [
|
|
{ key: 'accessToken', label: 'Bot Token', type: 'password' },
|
|
],
|
|
help: 'Message @BotFather on Telegram and create a new bot. Copy the token it gives you.',
|
|
},
|
|
discord: {
|
|
name: 'Discord',
|
|
type: 'token',
|
|
fields: [
|
|
{ key: 'accessToken', label: 'Bot Token', type: 'password' },
|
|
],
|
|
help: 'Go to discord.com/developers/applications → New Application → Bot → Reset Token.',
|
|
},
|
|
whatsapp: {
|
|
name: 'WhatsApp',
|
|
type: 'token',
|
|
fields: [
|
|
{ key: 'accessToken', label: 'Access Token', type: 'password' },
|
|
{ key: 'phoneNumberId', label: 'Phone Number ID', type: 'text' },
|
|
{ key: 'businessAccountId', label: 'Business Account ID', type: 'text' },
|
|
],
|
|
help: 'From Meta Business Settings → WhatsApp → API Setup. You need a verified business phone number.',
|
|
},
|
|
slack: {
|
|
name: 'Slack',
|
|
type: 'token',
|
|
fields: [
|
|
{ key: 'accessToken', label: 'Bot Token', type: 'password' },
|
|
{ key: 'channelId', label: 'Default Channel ID', type: 'text' },
|
|
],
|
|
help: 'Go to api.slack.com/apps → Create App → OAuth & Permissions → install to workspace. Copy the Bot User OAuth Token.',
|
|
},
|
|
email: {
|
|
name: 'Email',
|
|
type: 'token',
|
|
fields: [
|
|
{ key: 'host', label: 'IMAP Host', type: 'text' },
|
|
{ key: 'port', label: 'IMAP Port', type: 'text' },
|
|
{ key: 'user', label: 'Email Address', type: 'email' },
|
|
{ key: 'password', label: 'App Password', type: 'password' },
|
|
{ key: 'smtpHost', label: 'SMTP Host', type: 'text' },
|
|
{ key: 'smtpPort', label: 'SMTP Port', type: 'text' },
|
|
],
|
|
help: 'Use an app-specific password (not your login password). Common hosts: Gmail=smtp.gmail.com, Yahoo=smtp.mail.yahoo.com.',
|
|
},
|
|
};
|
|
|
|
// Auth helpers
|
|
async function apiPost(path, body) {
|
|
const res = await fetch(`${API_BASE}${path}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify(body),
|
|
});
|
|
return res.json();
|
|
}
|
|
|
|
async function apiGet(path) {
|
|
const res = await fetch(`${API_BASE}${path}`, { credentials: 'include' });
|
|
return res.json();
|
|
}
|
|
|
|
// Views
|
|
function hideAllViews() {
|
|
loginView.classList.add('hidden');
|
|
resetRequestView.classList.add('hidden');
|
|
resetConfirmView.classList.add('hidden');
|
|
dashboardView.classList.add('hidden');
|
|
}
|
|
|
|
function showLogin() {
|
|
hideAllViews();
|
|
loginView.classList.remove('hidden');
|
|
}
|
|
|
|
function showDashboard() {
|
|
hideAllViews();
|
|
dashboardView.classList.remove('hidden');
|
|
userEmail.textContent = currentUser?.email || '';
|
|
updateConnectionStatus();
|
|
updateUsage();
|
|
loadInvoices();
|
|
if (isAdmin) loadAdminPanel();
|
|
}
|
|
|
|
// Tab switching
|
|
tabBtns.forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
tabBtns.forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
const tab = btn.dataset.tab;
|
|
if (tab === 'login') {
|
|
loginForm.classList.remove('hidden');
|
|
signupForm.classList.add('hidden');
|
|
} else {
|
|
loginForm.classList.add('hidden');
|
|
signupForm.classList.remove('hidden');
|
|
}
|
|
});
|
|
});
|
|
|
|
// Navigation
|
|
navLinks.forEach(link => {
|
|
link.addEventListener('click', () => {
|
|
navLinks.forEach(l => l.classList.remove('active'));
|
|
link.classList.add('active');
|
|
const view = link.dataset.view;
|
|
platformGrid.classList.toggle('hidden', view !== 'platforms');
|
|
document.querySelector('.welcome').classList.toggle('hidden', view !== 'platforms');
|
|
document.querySelector('.usage-bar').classList.toggle('hidden', view !== 'platforms');
|
|
analyticsSection.classList.toggle('hidden', view !== 'analytics');
|
|
invoicesSection.classList.toggle('hidden', view !== 'invoices');
|
|
adminSection.classList.toggle('hidden', view !== 'admin');
|
|
document.getElementById('webhooks-section').classList.toggle('hidden', view !== 'webhooks');
|
|
if (view === 'analytics') loadAnalytics();
|
|
if (view === 'invoices') loadInvoices();
|
|
if (view === 'admin') loadAdminPanel();
|
|
if (view === 'webhooks') loadWebhookConfig();
|
|
});
|
|
});
|
|
|
|
// Login
|
|
loginForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
loginError.textContent = '';
|
|
const fd = new FormData(loginForm);
|
|
const data = await apiPost('/api/auth/login', {
|
|
email: fd.get('email'),
|
|
password: fd.get('password'),
|
|
});
|
|
if (data.error) {
|
|
loginError.textContent = data.error;
|
|
return;
|
|
}
|
|
currentUser = data;
|
|
isAdmin = data.role === 'admin';
|
|
// If we were sent here from an OAuth flow, redirect back
|
|
const returnTo = new URLSearchParams(window.location.search).get('return_to');
|
|
if (returnTo && returnTo.startsWith('https://hermes.squaremcp.com/')) {
|
|
window.location.href = returnTo;
|
|
return;
|
|
}
|
|
showDashboard();
|
|
});
|
|
|
|
// Signup
|
|
signupForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
signupError.textContent = '';
|
|
const fd = new FormData(signupForm);
|
|
const data = await apiPost('/api/auth/signup', {
|
|
email: fd.get('email'),
|
|
password: fd.get('password'),
|
|
});
|
|
if (data.error) {
|
|
signupError.textContent = data.error;
|
|
return;
|
|
}
|
|
currentUser = data;
|
|
isAdmin = false;
|
|
showDashboard();
|
|
});
|
|
|
|
// Logout
|
|
logoutBtn.addEventListener('click', async () => {
|
|
await apiPost('/api/auth/logout', {});
|
|
currentUser = null;
|
|
isAdmin = false;
|
|
showLogin();
|
|
});
|
|
|
|
// Connect MCP Client — show picker for Claude.ai / ChatGPT / desktop / CLI
|
|
document.getElementById('connect-mcp-btn')?.addEventListener('click', () => {
|
|
openModal(renderMcpClientPicker());
|
|
});
|
|
|
|
function renderMcpClientPicker() {
|
|
return `
|
|
<div class="mcp-picker">
|
|
<h3>Connect an AI client</h3>
|
|
<p class="picker-subtitle">Choose where you want to use SquareMCP tools.</p>
|
|
|
|
<div class="picker-option">
|
|
<div class="picker-meta">
|
|
<div class="picker-title">Claude.ai (web)</div>
|
|
<div class="picker-desc">Use SquareMCP directly in your browser at claude.ai.</div>
|
|
</div>
|
|
<a class="btn btn-primary" href="${API_BASE}/oauth/connect-claude-ai" target="_blank" rel="noopener" onclick="window.closeMcpPicker && window.closeMcpPicker()">Connect</a>
|
|
</div>
|
|
|
|
<div class="picker-option">
|
|
<div class="picker-meta">
|
|
<div class="picker-title">Claude Desktop</div>
|
|
<div class="picker-desc">macOS / Windows app with local MCP config.</div>
|
|
</div>
|
|
<button class="btn btn-primary" data-connect="claude-desktop">Connect</button>
|
|
</div>
|
|
|
|
<div class="picker-option">
|
|
<div class="picker-meta">
|
|
<div class="picker-title">Codex CLI / OpenCode</div>
|
|
<div class="picker-desc">Terminal-based agents (OpenAI, opencode).</div>
|
|
</div>
|
|
<button class="btn btn-primary" data-connect="codex">Connect</button>
|
|
</div>
|
|
|
|
<div class="picker-option">
|
|
<div class="picker-meta">
|
|
<div class="picker-title">ChatGPT (web)</div>
|
|
<div class="picker-desc">Copy the OpenAPI spec URL for GPT Actions.</div>
|
|
</div>
|
|
<button class="btn btn-secondary" data-connect="chatgpt">Get URL</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
window.closeMcpPicker = closeModal;
|
|
|
|
modalBody.addEventListener('click', (e) => {
|
|
const btn = e.target.closest('[data-connect]');
|
|
if (!btn) return;
|
|
const type = btn.dataset.connect;
|
|
if (type === 'claude-desktop' || type === 'codex') {
|
|
window.open(`${API_BASE}/oauth/connect-mcp`, '_blank', 'width=560,height=600,noopener');
|
|
} else if (type === 'chatgpt') {
|
|
openModal(`
|
|
<div class="mcp-picker">
|
|
<h3>ChatGPT / GPT Actions</h3>
|
|
<p class="picker-subtitle">ChatGPT browser does not yet support native MCP. Use this OpenAPI spec URL in a GPT Action:</p>
|
|
<div class="token-box" style="margin:16px 0;">${API_BASE}/openapi.json</div>
|
|
<p class="picker-subtitle">Set authentication to <strong>Bearer token</strong> and paste your API key from <em>Settings → API Keys</em>.</p>
|
|
<button class="btn btn-primary" onclick="window.open('${API_BASE}/openapi.json','_blank')">Open spec</button>
|
|
</div>
|
|
`);
|
|
}
|
|
});
|
|
|
|
// Password reset request
|
|
resetRequestForm?.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const errorEl = document.getElementById('reset-request-error');
|
|
const successEl = document.getElementById('reset-request-success');
|
|
errorEl.textContent = '';
|
|
successEl.textContent = '';
|
|
const fd = new FormData(resetRequestForm);
|
|
const data = await apiPost('/api/auth/forgot-password', { email: fd.get('email') });
|
|
if (data.resetUrl) {
|
|
successEl.textContent = `Reset link: ${data.resetUrl}`;
|
|
} else {
|
|
successEl.textContent = data.message;
|
|
}
|
|
});
|
|
|
|
document.getElementById('back-to-login')?.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
showLogin();
|
|
});
|
|
|
|
// Password reset confirm
|
|
resetConfirmForm?.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const errorEl = document.getElementById('reset-confirm-error');
|
|
const successEl = document.getElementById('reset-confirm-success');
|
|
errorEl.textContent = '';
|
|
successEl.textContent = '';
|
|
const fd = new FormData(resetConfirmForm);
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const token = urlParams.get('token');
|
|
if (!token) {
|
|
errorEl.textContent = 'Missing reset token';
|
|
return;
|
|
}
|
|
const data = await apiPost('/api/auth/reset-password', {
|
|
token,
|
|
password: fd.get('password'),
|
|
});
|
|
if (data.error) {
|
|
errorEl.textContent = data.error;
|
|
return;
|
|
}
|
|
successEl.textContent = 'Password updated! Redirecting to login...';
|
|
setTimeout(() => {
|
|
window.location.href = '/';
|
|
}, 2000);
|
|
});
|
|
|
|
// Add forgot password link to login form
|
|
const forgotLink = document.createElement('a');
|
|
forgotLink.href = '#';
|
|
forgotLink.textContent = 'Forgot password?';
|
|
forgotLink.style.cssText = 'color:#888;font-size:13px;text-align:center;display:block;margin-top:8px;';
|
|
forgotLink.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
hideAllViews();
|
|
resetRequestView.classList.remove('hidden');
|
|
});
|
|
loginForm.appendChild(forgotLink);
|
|
|
|
// Connection status
|
|
async function updateConnectionStatus() {
|
|
try {
|
|
const [connData, healthData] = await Promise.all([
|
|
apiGet('/api/connections').catch(() => ({ connections: {} })),
|
|
apiGet('/api/health/platforms').catch(() => ({ health: [] })),
|
|
]);
|
|
const connections = connData.connections || {};
|
|
const healthMap = {};
|
|
(healthData.health || []).forEach(h => { healthMap[h.platform] = h.status; });
|
|
|
|
document.querySelectorAll('.platform-card').forEach(card => {
|
|
const platform = card.dataset.platform;
|
|
const badge = card.querySelector('.status-badge');
|
|
const btn = card.querySelector('.btn-connect');
|
|
const health = healthMap[platform];
|
|
|
|
if (health === 'healthy') {
|
|
badge.textContent = 'Connected';
|
|
badge.className = 'status-badge connected';
|
|
btn.textContent = 'Manage';
|
|
} else if (health === 'expired') {
|
|
badge.textContent = 'Token expired';
|
|
badge.className = 'status-badge expired';
|
|
btn.textContent = 'Reconnect';
|
|
} else if (connections[platform]) {
|
|
badge.textContent = 'Connected';
|
|
badge.className = 'status-badge connected';
|
|
btn.textContent = 'Manage';
|
|
} else {
|
|
badge.textContent = 'Not connected';
|
|
badge.className = 'status-badge disconnected';
|
|
btn.textContent = 'Connect';
|
|
}
|
|
});
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
// Usage
|
|
async function updateUsage() {
|
|
try {
|
|
const data = await apiGet('/api/usage');
|
|
const badge = document.getElementById('plan-badge');
|
|
const text = document.getElementById('usage-text');
|
|
const fill = document.getElementById('usage-bar-fill');
|
|
badge.textContent = data.plan;
|
|
if (data.monthlyLimit === -1) {
|
|
text.textContent = `${data.used} calls (unlimited)`;
|
|
fill.style.width = '5%';
|
|
fill.className = 'usage-bar-fill';
|
|
} else {
|
|
text.textContent = `${data.used} / ${data.monthlyLimit} calls this month`;
|
|
const pct = Math.min(100, (data.used / data.monthlyLimit) * 100);
|
|
fill.style.width = pct + '%';
|
|
fill.className = 'usage-bar-fill' + (pct > 90 ? ' danger' : pct > 70 ? ' warning' : '');
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
// Invoices
|
|
async function loadInvoices() {
|
|
try {
|
|
const data = await apiGet('/api/invoices');
|
|
const list = document.getElementById('invoices-list');
|
|
const invoices = data.invoices || [];
|
|
if (!invoices.length) {
|
|
list.innerHTML = '<p style="color:#888;font-size:14px;">No invoices yet.</p>';
|
|
return;
|
|
}
|
|
list.innerHTML = invoices.map(inv => `
|
|
<div class="invoice-item">
|
|
<div>
|
|
<div class="inv-num">${inv.invoice_number}</div>
|
|
<div class="inv-period">${inv.period_start} → ${inv.period_end}</div>
|
|
</div>
|
|
<div style="text-align:right;">
|
|
<div class="inv-amount">$${inv.amount}</div>
|
|
<span class="inv-status ${inv.status}">${inv.status}</span>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
} catch {
|
|
document.getElementById('invoices-list').innerHTML = '<p style="color:#888;font-size:14px;">Failed to load invoices.</p>';
|
|
}
|
|
}
|
|
|
|
// Analytics
|
|
const PLATFORM_COLORS = {
|
|
email: '#ea4335', linkedin: '#0a66c2', twitter: '#000000',
|
|
instagram: '#e1306c', facebook: '#1877f2', tiktok: '#010101',
|
|
whatsapp: '#25d366', telegram: '#0088cc', discord: '#5865f2', slack: '#4a154b',
|
|
snapchat: '#fffc00', obsidian: '#7c3aed',
|
|
};
|
|
|
|
async function loadAnalytics() {
|
|
const emptyEl = document.getElementById('analytics-empty');
|
|
try {
|
|
const [usageData, dailyData] = await Promise.all([
|
|
apiGet('/api/usage'),
|
|
apiGet('/api/usage/daily'),
|
|
]);
|
|
|
|
const breakdown = usageData.breakdown || {};
|
|
const daily = dailyData.daily || [];
|
|
|
|
const hasData = Object.keys(breakdown).length > 0 || daily.length > 0;
|
|
emptyEl.classList.toggle('hidden', hasData);
|
|
document.querySelector('.charts-grid').classList.toggle('hidden', !hasData);
|
|
|
|
if (!hasData) return;
|
|
|
|
// Platform breakdown chart
|
|
const platformLabels = Object.keys(breakdown);
|
|
const platformCounts = platformLabels.map(p => breakdown[p]);
|
|
const platformColors = platformLabels.map(p => PLATFORM_COLORS[p] || '#888');
|
|
|
|
if (platformChartInstance) platformChartInstance.destroy();
|
|
platformChartInstance = new Chart(document.getElementById('platform-chart'), {
|
|
type: 'bar',
|
|
data: {
|
|
labels: platformLabels,
|
|
datasets: [{
|
|
label: 'Calls',
|
|
data: platformCounts,
|
|
backgroundColor: platformColors,
|
|
borderRadius: 6,
|
|
}],
|
|
},
|
|
options: {
|
|
indexAxis: 'y',
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: { legend: { display: false } },
|
|
scales: {
|
|
x: { ticks: { color: '#888' }, grid: { color: '#2a2a2b' } },
|
|
y: { ticks: { color: '#e5e5e5' }, grid: { display: false } },
|
|
},
|
|
},
|
|
});
|
|
|
|
// Daily activity chart
|
|
const dailyLabels = daily.map(d => d.date);
|
|
const dailyCounts = daily.map(d => Number(d.count));
|
|
|
|
if (dailyChartInstance) dailyChartInstance.destroy();
|
|
dailyChartInstance = new Chart(document.getElementById('daily-chart'), {
|
|
type: 'bar',
|
|
data: {
|
|
labels: dailyLabels,
|
|
datasets: [{
|
|
label: 'Calls',
|
|
data: dailyCounts,
|
|
backgroundColor: '#10a37f',
|
|
borderRadius: 4,
|
|
}],
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: { legend: { display: false } },
|
|
scales: {
|
|
x: { ticks: { color: '#888', maxRotation: 45 }, grid: { color: '#2a2a2b' } },
|
|
y: { ticks: { color: '#888' }, grid: { color: '#2a2a2b' }, beginAtZero: true },
|
|
},
|
|
},
|
|
});
|
|
} catch {
|
|
emptyEl.classList.remove('hidden');
|
|
emptyEl.querySelector('p').textContent = 'Failed to load analytics.';
|
|
document.querySelector('.charts-grid').classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
// Admin panel
|
|
async function loadAdminPanel() {
|
|
try {
|
|
const data = await apiGet('/api/admin/customers');
|
|
const container = document.getElementById('admin-customers');
|
|
const customers = data.customers || [];
|
|
container.innerHTML = `
|
|
<table class="admin-table">
|
|
<thead><tr><th>Email</th><th>Plan</th><th>Role</th><th>Active</th><th>Joined</th><th>Actions</th></tr></thead>
|
|
<tbody>
|
|
${customers.map(c => `
|
|
<tr>
|
|
<td>${c.email}</td>
|
|
<td>${c.plan}</td>
|
|
<td>${c.role}</td>
|
|
<td>${c.active ? 'Yes' : 'No'}</td>
|
|
<td>${new Date(c.created_at).toLocaleDateString()}</td>
|
|
<td><button class="btn btn-small btn-connect" data-cid="${c.id}" onclick="generateInvoice('${c.id}')">Invoice</button></td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
`;
|
|
} catch {
|
|
document.getElementById('admin-customers').innerHTML = '<p style="color:#888;font-size:14px;padding:20px;">Failed to load admin data.</p>';
|
|
}
|
|
}
|
|
|
|
window.generateInvoice = async function(customerId) {
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/admin/customers/${customerId}/invoice`, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
});
|
|
const data = await res.json();
|
|
if (data.error) {
|
|
alert(data.error);
|
|
return;
|
|
}
|
|
alert(`Invoice ${data.invoice_number} created for $${data.amount}`);
|
|
loadAdminPanel();
|
|
} catch {
|
|
alert('Failed to generate invoice');
|
|
}
|
|
};
|
|
|
|
// Modal
|
|
function openModal(html) {
|
|
modalBody.innerHTML = html;
|
|
modal.classList.remove('hidden');
|
|
}
|
|
|
|
function closeModal() {
|
|
modal.classList.add('hidden');
|
|
modalBody.innerHTML = '';
|
|
}
|
|
|
|
modalClose.addEventListener('click', closeModal);
|
|
modalBackdrop.addEventListener('click', closeModal);
|
|
|
|
// Platform connection
|
|
function renderConnectForm(platform) {
|
|
const cfg = PLATFORM_CONFIG[platform];
|
|
if (!cfg) return '';
|
|
|
|
if (cfg.type === 'oauth') {
|
|
const state = btoa(JSON.stringify({ platform, user: currentUser?.id }));
|
|
const url = `${cfg.oauthUrl}?state=${encodeURIComponent(state)}&scope=${encodeURIComponent(cfg.scopes)}`;
|
|
return `
|
|
<div class="connect-form">
|
|
<h3>Connect ${cfg.name}</h3>
|
|
<p>You'll be redirected to ${cfg.name} to authorize SquareMCP.</p>
|
|
<div class="instructions">${cfg.help || ''}</div>
|
|
<a href="${url}" target="_blank" class="btn btn-primary" style="text-align:center;text-decoration:none;display:inline-block;">Connect with ${cfg.name}</a>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
const fieldsHtml = cfg.fields.map(f => `
|
|
<label>${f.label}</label>
|
|
<input type="${f.type}" name="${f.key}" placeholder="${f.label}" required>
|
|
`).join('');
|
|
|
|
return `
|
|
<form class="connect-form" data-platform="${platform}">
|
|
<h3>Connect ${cfg.name}</h3>
|
|
<p>Paste your credentials below. They are encrypted and stored securely.</p>
|
|
${fieldsHtml}
|
|
<div class="instructions">${cfg.help}</div>
|
|
<button type="submit" class="btn btn-primary">Save Connection</button>
|
|
<p class="error-msg" id="connect-error"></p>
|
|
</form>
|
|
`;
|
|
}
|
|
|
|
document.querySelectorAll('.btn-connect').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const platform = btn.dataset.platform;
|
|
openModal(renderConnectForm(platform));
|
|
|
|
const form = modalBody.querySelector('form.connect-form');
|
|
if (form) {
|
|
form.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const errorEl = document.getElementById('connect-error');
|
|
errorEl.textContent = '';
|
|
errorEl.style.color = '#dc2626';
|
|
const fd = new FormData(form);
|
|
const credentials = {};
|
|
for (const [key, value] of fd.entries()) {
|
|
credentials[key] = value;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/connect/${platform}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify(credentials),
|
|
});
|
|
const data = await res.json();
|
|
if (data.error) {
|
|
errorEl.textContent = data.error;
|
|
return;
|
|
}
|
|
errorEl.textContent = 'Connected successfully!';
|
|
errorEl.style.color = '#10a37f';
|
|
setTimeout(() => {
|
|
closeModal();
|
|
updateConnectionStatus();
|
|
}, 1000);
|
|
} catch (err) {
|
|
errorEl.textContent = 'Network error. Please try again.';
|
|
}
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
// Check session on load
|
|
async function checkSession() {
|
|
// Check for reset token in URL
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
if (urlParams.get('token')) {
|
|
hideAllViews();
|
|
resetConfirmView.classList.remove('hidden');
|
|
return;
|
|
}
|
|
|
|
// If we were sent here from an OAuth flow, redirect back after confirming session
|
|
const returnTo = urlParams.get('return_to');
|
|
|
|
try {
|
|
const data = await apiGet('/api/auth/me');
|
|
if (data.id) {
|
|
currentUser = data;
|
|
isAdmin = data.role === 'admin';
|
|
if (isAdmin) adminNav.classList.remove('hidden');
|
|
// Already logged in — bounce back to the OAuth authorize URL if present
|
|
if (returnTo && returnTo.startsWith('https://hermes.squaremcp.com/')) {
|
|
window.location.href = returnTo;
|
|
return;
|
|
}
|
|
showDashboard();
|
|
} else {
|
|
showLogin();
|
|
}
|
|
} catch {
|
|
showLogin();
|
|
}
|
|
}
|
|
|
|
// Webhook config
|
|
async function loadWebhookConfig() {
|
|
try {
|
|
const data = await apiGet('/api/webhooks/config');
|
|
const display = document.getElementById('webhook-url-display');
|
|
const deleteBtn = document.getElementById('webhook-delete-btn');
|
|
const input = document.getElementById('webhook-url-input');
|
|
if (data.webhookUrl) {
|
|
display.textContent = data.webhookUrl;
|
|
deleteBtn.classList.remove('hidden');
|
|
input.value = data.webhookUrl;
|
|
} else {
|
|
display.textContent = 'No webhook configured';
|
|
deleteBtn.classList.add('hidden');
|
|
input.value = '';
|
|
}
|
|
document.getElementById('webhook-secret-box').classList.add('hidden');
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
document.getElementById('webhook-form')?.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const url = document.getElementById('webhook-url-input').value.trim();
|
|
const data = await apiPost('/api/webhooks/config', { webhook_url: url });
|
|
if (data.error) { alert(data.error); return; }
|
|
document.getElementById('webhook-url-display').textContent = data.webhookUrl;
|
|
document.getElementById('webhook-delete-btn').classList.remove('hidden');
|
|
if (data.webhookSecret) {
|
|
document.getElementById('webhook-secret-value').textContent = data.webhookSecret;
|
|
document.getElementById('webhook-secret-box').classList.remove('hidden');
|
|
}
|
|
});
|
|
|
|
document.getElementById('webhook-delete-btn')?.addEventListener('click', async () => {
|
|
if (!confirm('Remove webhook? This cannot be undone.')) return;
|
|
await fetch(`${API_BASE}/api/webhooks/config`, { method: 'DELETE', credentials: 'include' });
|
|
loadWebhookConfig();
|
|
});
|
|
|
|
window.copyWebhookSecret = () => {
|
|
const val = document.getElementById('webhook-secret-value').textContent;
|
|
navigator.clipboard.writeText(val).then(() => alert('Secret copied!'));
|
|
};
|
|
|
|
// Init
|
|
checkSession();
|