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
This commit is contained in:
6
product/app/Dockerfile
Normal file
6
product/app/Dockerfile
Normal file
@@ -0,0 +1,6 @@
|
||||
FROM nginx:1.27-alpine
|
||||
COPY product/app/nginx-app.conf /etc/nginx/conf.d/default.conf
|
||||
COPY product/app/index.html /usr/share/nginx/html/index.html
|
||||
COPY product/app/styles.css /usr/share/nginx/html/styles.css
|
||||
COPY product/app/app.js /usr/share/nginx/html/app.js
|
||||
EXPOSE 8080
|
||||
65
product/app/app-k8s.yaml
Normal file
65
product/app/app-k8s.yaml
Normal file
@@ -0,0 +1,65 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: squaremcp-app
|
||||
namespace: fetcherpay
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: squaremcp-app
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: squaremcp-app
|
||||
spec:
|
||||
containers:
|
||||
- name: squaremcp-app
|
||||
image: localhost:32000/squaremcp-app@sha256:45d7adfe10efe727ec1f6c1f5a64ad12d9aa426af90145b5f8d7c1a9dbbe9536
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 8080
|
||||
initialDelaySeconds: 3
|
||||
periodSeconds: 10
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: squaremcp-app
|
||||
namespace: fetcherpay
|
||||
spec:
|
||||
selector:
|
||||
app: squaremcp-app
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
targetPort: 8080
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: squaremcp-app-ingress
|
||||
namespace: fetcherpay
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
rules:
|
||||
- host: app.squaremcp.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: squaremcp-app
|
||||
port:
|
||||
number: 80
|
||||
tls:
|
||||
- hosts:
|
||||
- app.squaremcp.com
|
||||
secretName: squaremcp-app-tls
|
||||
532
product/app/app.js
Normal file
532
product/app/app.js
Normal file
@@ -0,0 +1,532 @@
|
||||
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();
|
||||
230
product/app/index.html
Normal file
230
product/app/index.html
Normal file
@@ -0,0 +1,230 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SquareMCP — AI Social Gateway</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- Password Reset Request View -->
|
||||
<div id="reset-request-view" class="view hidden">
|
||||
<div class="auth-card">
|
||||
<div class="logo">
|
||||
<div class="logo-mark">S</div>
|
||||
<h1>Reset Password</h1>
|
||||
<p>Enter your email to receive a reset link</p>
|
||||
</div>
|
||||
<form id="reset-request-form" class="auth-form">
|
||||
<input type="email" name="email" placeholder="Email" required>
|
||||
<button type="submit" class="btn btn-primary">Send Reset Link</button>
|
||||
<p class="error-msg" id="reset-request-error"></p>
|
||||
<p class="success-msg" id="reset-request-success"></p>
|
||||
<a href="#" id="back-to-login" style="color:#888;font-size:13px;text-align:center;display:block;margin-top:12px;">Back to login</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password Reset Confirm View -->
|
||||
<div id="reset-confirm-view" class="view hidden">
|
||||
<div class="auth-card">
|
||||
<div class="logo">
|
||||
<div class="logo-mark">S</div>
|
||||
<h1>New Password</h1>
|
||||
<p>Enter your new password below</p>
|
||||
</div>
|
||||
<form id="reset-confirm-form" class="auth-form">
|
||||
<input type="password" name="password" placeholder="New password (min 8 chars)" required minlength="8">
|
||||
<button type="submit" class="btn btn-primary">Update Password</button>
|
||||
<p class="error-msg" id="reset-confirm-error"></p>
|
||||
<p class="success-msg" id="reset-confirm-success"></p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login View -->
|
||||
<div id="login-view" class="view">
|
||||
<div class="auth-card">
|
||||
<div class="logo">
|
||||
<div class="logo-mark">S</div>
|
||||
<h1>SquareMCP</h1>
|
||||
<p>AI Social Media Gateway</p>
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" data-tab="login">Sign In</button>
|
||||
<button class="tab-btn" data-tab="signup">Create Account</button>
|
||||
</div>
|
||||
<form id="login-form" class="auth-form">
|
||||
<input type="email" name="email" placeholder="Email" required>
|
||||
<input type="password" name="password" placeholder="Password" required minlength="8">
|
||||
<button type="submit" class="btn btn-primary">Sign In</button>
|
||||
<p class="error-msg" id="login-error"></p>
|
||||
</form>
|
||||
<form id="signup-form" class="auth-form hidden">
|
||||
<input type="email" name="email" placeholder="Email" required>
|
||||
<input type="password" name="password" placeholder="Password (min 8 chars)" required minlength="8">
|
||||
<button type="submit" class="btn btn-primary">Create Account</button>
|
||||
<p class="error-msg" id="signup-error"></p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard View -->
|
||||
<div id="dashboard-view" class="view hidden">
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<div class="logo-mark small">S</div>
|
||||
<span class="app-title">SquareMCP</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<nav class="header-nav">
|
||||
<button class="nav-link active" data-view="platforms">Platforms</button>
|
||||
<button class="nav-link" data-view="invoices">Invoices</button>
|
||||
<button class="nav-link hidden" data-view="admin" id="admin-nav">Admin</button>
|
||||
</nav>
|
||||
<span id="user-email" class="user-email"></span>
|
||||
<button id="logout-btn" class="btn btn-ghost">Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="dashboard">
|
||||
<section class="welcome">
|
||||
<h2>Connect your platforms</h2>
|
||||
<p>Link your social accounts to publish, analyze, and manage content from one place.</p>
|
||||
</section>
|
||||
|
||||
<section class="usage-bar" id="usage-bar">
|
||||
<div class="usage-info">
|
||||
<span class="plan-badge" id="plan-badge">Free</span>
|
||||
<span class="usage-text" id="usage-text">0 / 100 calls this month</span>
|
||||
</div>
|
||||
<div class="usage-bar-track"><div class="usage-bar-fill" id="usage-bar-fill" style="width:0%"></div></div>
|
||||
</section>
|
||||
|
||||
<section class="platform-grid">
|
||||
<!-- TikTok -->
|
||||
<div class="platform-card" data-platform="tiktok">
|
||||
<div class="platform-icon" style="background:#000;">🎵</div>
|
||||
<div class="platform-info">
|
||||
<h3>TikTok</h3>
|
||||
<p class="platform-desc">Publish videos and view analytics</p>
|
||||
<span class="status-badge disconnected">Not connected</span>
|
||||
</div>
|
||||
<button class="btn btn-connect" data-platform="tiktok">Connect</button>
|
||||
</div>
|
||||
|
||||
<!-- Facebook -->
|
||||
<div class="platform-card" data-platform="facebook">
|
||||
<div class="platform-icon" style="background:#1877f2;">f</div>
|
||||
<div class="platform-info">
|
||||
<h3>Facebook</h3>
|
||||
<p class="platform-desc">Post to pages and manage content</p>
|
||||
<span class="status-badge disconnected">Not connected</span>
|
||||
</div>
|
||||
<button class="btn btn-connect" data-platform="facebook">Connect</button>
|
||||
</div>
|
||||
|
||||
<!-- Instagram -->
|
||||
<div class="platform-card" data-platform="instagram">
|
||||
<div class="platform-icon" style="background:linear-gradient(45deg,#f09433,#e6683c,#dc2743,#cc2366,#bc1888);">📷</div>
|
||||
<div class="platform-info">
|
||||
<h3>Instagram</h3>
|
||||
<p class="platform-desc">Publish reels and images</p>
|
||||
<span class="status-badge disconnected">Not connected</span>
|
||||
</div>
|
||||
<button class="btn btn-connect" data-platform="instagram">Connect</button>
|
||||
</div>
|
||||
|
||||
<!-- LinkedIn -->
|
||||
<div class="platform-card" data-platform="linkedin">
|
||||
<div class="platform-icon" style="background:#0a66c2;">in</div>
|
||||
<div class="platform-info">
|
||||
<h3>LinkedIn</h3>
|
||||
<p class="platform-desc">Share posts, images, and videos</p>
|
||||
<span class="status-badge disconnected">Not connected</span>
|
||||
</div>
|
||||
<button class="btn btn-connect" data-platform="linkedin">Connect</button>
|
||||
</div>
|
||||
|
||||
<!-- Twitter/X -->
|
||||
<div class="platform-card" data-platform="twitter">
|
||||
<div class="platform-icon" style="background:#000;">𝕏</div>
|
||||
<div class="platform-info">
|
||||
<h3>Twitter / X</h3>
|
||||
<p class="platform-desc">Tweet with media support</p>
|
||||
<span class="status-badge disconnected">Not connected</span>
|
||||
</div>
|
||||
<button class="btn btn-connect" data-platform="twitter">Connect</button>
|
||||
</div>
|
||||
|
||||
<!-- Telegram -->
|
||||
<div class="platform-card" data-platform="telegram">
|
||||
<div class="platform-icon" style="background:#0088cc;">✈️</div>
|
||||
<div class="platform-info">
|
||||
<h3>Telegram</h3>
|
||||
<p class="platform-desc">Send messages via bot</p>
|
||||
<span class="status-badge disconnected">Not connected</span>
|
||||
</div>
|
||||
<button class="btn btn-connect" data-platform="telegram">Connect</button>
|
||||
</div>
|
||||
|
||||
<!-- Discord -->
|
||||
<div class="platform-card" data-platform="discord">
|
||||
<div class="platform-icon" style="background:#5865f2;">🎮</div>
|
||||
<div class="platform-info">
|
||||
<h3>Discord</h3>
|
||||
<p class="platform-desc">Send messages to channels</p>
|
||||
<span class="status-badge disconnected">Not connected</span>
|
||||
</div>
|
||||
<button class="btn btn-connect" data-platform="discord">Connect</button>
|
||||
</div>
|
||||
|
||||
<!-- WhatsApp -->
|
||||
<div class="platform-card" data-platform="whatsapp">
|
||||
<div class="platform-icon" style="background:#25d366;">💬</div>
|
||||
<div class="platform-info">
|
||||
<h3>WhatsApp</h3>
|
||||
<p class="platform-desc">Business messaging</p>
|
||||
<span class="status-badge disconnected">Not connected</span>
|
||||
</div>
|
||||
<button class="btn btn-connect" data-platform="whatsapp">Connect</button>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div class="platform-card" data-platform="email">
|
||||
<div class="platform-icon" style="background:#ea4335;">✉️</div>
|
||||
<div class="platform-info">
|
||||
<h3>Email</h3>
|
||||
<p class="platform-desc">IMAP/SMTP accounts</p>
|
||||
<span class="status-badge disconnected">Not connected</span>
|
||||
</div>
|
||||
<button class="btn btn-connect" data-platform="email">Connect</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="invoices-section hidden" id="invoices-section">
|
||||
<h3>Invoices</h3>
|
||||
<div id="invoices-list" class="invoices-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="admin-section hidden" id="admin-section">
|
||||
<h3>Admin Panel</h3>
|
||||
<div id="admin-customers" class="admin-customers"></div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Connection Modal -->
|
||||
<div id="connect-modal" class="modal hidden">
|
||||
<div class="modal-backdrop"></div>
|
||||
<div class="modal-content">
|
||||
<button class="modal-close">×</button>
|
||||
<div id="modal-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
15
product/app/nginx-app.conf
Normal file
15
product/app/nginx-app.conf
Normal file
@@ -0,0 +1,15 @@
|
||||
server {
|
||||
listen 8080;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
610
product/app/styles.css
Normal file
610
product/app/styles.css
Normal file
@@ -0,0 +1,610 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0f0f10;
|
||||
--surface: #1a1a1b;
|
||||
--surface-hover: #222223;
|
||||
--border: #2a2a2b;
|
||||
--text: #e5e5e5;
|
||||
--text-secondary: #888;
|
||||
--accent: #10a37f;
|
||||
--accent-hover: #0d8c6d;
|
||||
--danger: #dc2626;
|
||||
--radius: 12px;
|
||||
--shadow: 0 4px 24px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* Views */
|
||||
.view {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Auth */
|
||||
#login-view {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.logo {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.logo-mark {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: linear-gradient(135deg, #25f4ee, #fe2c55);
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 28px;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
.logo-mark.small {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 18px;
|
||||
border-radius: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.logo h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.logo p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
background: var(--bg);
|
||||
padding: 4px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.auth-form input {
|
||||
padding: 12px 14px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.auth-form input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.auth-form input::placeholder {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-connect {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-connect:hover {
|
||||
background: var(--surface-hover);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
/* Dashboard */
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 24px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.user-email {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
.welcome {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.welcome h2 {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.welcome p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.platform-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.platform-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
transition: border-color 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.platform-card:hover {
|
||||
border-color: #3a3a3b;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.platform-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.platform-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.platform-info h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.platform-desc {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.status-badge.connected {
|
||||
background: rgba(16, 163, 127, 0.15);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.status-badge.disconnected {
|
||||
background: rgba(136, 136, 136, 0.1);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 32px;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Connection form in modal */
|
||||
.connect-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.connect-form h3 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.connect-form p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.connect-form label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.connect-form input {
|
||||
padding: 12px 14px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.connect-form input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.connect-form .btn-primary {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.instructions {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.instructions code {
|
||||
background: #2a2a2b;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'SF Mono', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.platform-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.auth-card {
|
||||
padding: 28px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Usage bar */
|
||||
.usage-bar {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.usage-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.plan-badge {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.usage-text {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.usage-bar-track {
|
||||
height: 6px;
|
||||
background: var(--bg);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.usage-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 3px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
.usage-bar-fill.warning {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.usage-bar-fill.danger {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
/* Header nav */
|
||||
.header-nav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Invoices */
|
||||
.invoices-section, .admin-section {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.invoices-section h3, .admin-section h3 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.invoices-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.invoice-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.invoice-item .inv-num {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.invoice-item .inv-period {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.invoice-item .inv-amount {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.invoice-item .inv-status {
|
||||
padding: 3px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.inv-status.draft { background: rgba(136,136,136,0.1); color: #888; }
|
||||
.inv-status.sent { background: rgba(245,158,11,0.15); color: #f59e0b; }
|
||||
.inv-status.paid { background: rgba(16,163,127,0.15); color: var(--accent); }
|
||||
.inv-status.overdue { background: rgba(220,38,38,0.15); color: #dc2626; }
|
||||
|
||||
/* Admin */
|
||||
.admin-customers {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
text-align: left;
|
||||
padding: 12px 16px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.admin-table td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.admin-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.admin-table .btn-small {
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Password reset */
|
||||
.success-msg {
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
min-height: 18px;
|
||||
}
|
||||
Reference in New Issue
Block a user