Files
hermes-mcp/product/app/app.js
Garfield a5e4c55885 feat(saas): full SquareMCP SaaS platform v1
- JWT auth with bcrypt password hashing, cookie sessions, forgot/reset password
- Per-user encrypted credential storage (Redis + AES-256-GCM) for all 9 platforms
- Usage tracking with monthly limits per plan (free/starter/growth/enterprise)
- Invoice generation and retrieval (admin + user views)
- Admin panel with customer listing (role-based access)
- Web app UI at app.squaremcp.com — login, dashboard, connections, usage, invoices
- Unified auth middleware: API key, OAuth Bearer, and JWT cookie support
- Facebook Graph API fixes: published_posts endpoint, photo/video post support
- TikTok sandbox compliance: SELF_ONLY privacy for unaudited apps
- URL verification files for TikTok app review
2026-05-13 08:42:33 -04:00

533 lines
17 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 invoicesSection = document.getElementById('invoices-section');
const adminSection = document.getElementById('admin-section');
const adminNav = document.getElementById('admin-nav');
// 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.',
},
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');
invoicesSection.classList.toggle('hidden', view !== 'invoices');
adminSection.classList.toggle('hidden', view !== 'admin');
});
});
// 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.plan === 'enterprise'; // simplistic admin check
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();
});
// 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 data = await apiGet('/api/connections');
const connections = data.connections || {};
document.querySelectorAll('.platform-card').forEach(card => {
const platform = card.dataset.platform;
const badge = card.querySelector('.status-badge');
const btn = card.querySelector('.btn-connect');
if (connections[platform]) {
badge.textContent = 'Connected';
badge.classList.remove('disconnected');
badge.classList.add('connected');
btn.textContent = 'Manage';
} else {
badge.textContent = 'Not connected';
badge.classList.remove('connected');
badge.classList.add('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>';
}
}
// 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;
}
try {
const data = await apiGet('/api/auth/me');
if (data.id) {
currentUser = data;
isAdmin = data.plan === 'enterprise';
if (isAdmin) adminNav.classList.remove('hidden');
showDashboard();
} else {
showLogin();
}
} catch {
showLogin();
}
}
// Init
checkSession();