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:
17
.env.example
17
.env.example
@@ -81,3 +81,20 @@ INSTAGRAM_DEFAULT_BUSINESS_ACCOUNT_ID=your-instagram-business-account-id
|
||||
# For default account:
|
||||
TWITTER_DEFAULT_BEARER_TOKEN=your-twitter-bearer-token
|
||||
# For additional accounts, duplicate with TWITTER_{ACCOUNT}_*
|
||||
|
||||
# ── TikTok Content Posting API ───────────────────────────────────────────────
|
||||
# Get an access token from the TikTok developer app with Content Posting scopes
|
||||
# Login Kit / OAuth app credentials:
|
||||
TIKTOK_CLIENT_KEY=your-tiktok-client-key
|
||||
TIKTOK_CLIENT_SECRET=your-tiktok-client-secret
|
||||
TIKTOK_REDIRECT_URI=https://tiktok.squaremcp.com/auth/tiktok/callback
|
||||
# For default account:
|
||||
TIKTOK_DEFAULT_ACCESS_TOKEN=your-tiktok-access-token
|
||||
# For additional accounts, duplicate with TIKTOK_{ACCOUNT}_*
|
||||
|
||||
# ── Facebook Graph API ───────────────────────────────────────────────────────
|
||||
# Use a Page access token with pages_manage_posts + pages_read_engagement
|
||||
# For default account:
|
||||
FACEBOOK_DEFAULT_ACCESS_TOKEN=your-facebook-page-access-token
|
||||
FACEBOOK_DEFAULT_PAGE_ID=your-facebook-page-id
|
||||
# For additional accounts, duplicate with FACEBOOK_{ACCOUNT}_*
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,4 +17,5 @@ opencode.json
|
||||
|
||||
# Remotion demo project
|
||||
videos/remotion-demo/node_modules
|
||||
videos/remotion-demo/build
|
||||
videos/remotion-demo/out
|
||||
|
||||
@@ -66,6 +66,7 @@ Use the setup guide that matches your client:
|
||||
2. [CLI agent setup (Claude Code, generic MCP CLIs, Claude Desktop)](./AGENTS_CLI_SETUP.md)
|
||||
3. [opencode setup](./OPENCODE.md)
|
||||
4. [ChatGPT Custom GPT setup](./CHATGPT_SETUP.md)
|
||||
5. [Social publishing setup (TikTok / Facebook)](./SOCIAL_PUBLISHING_SETUP.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -129,6 +130,7 @@ SquareMCP product-site docs live under:
|
||||
|
||||
1. [`product/site`](./product/site)
|
||||
2. [`product/README.md`](./product/README.md)
|
||||
3. [`videos/remotion-demo`](./videos/remotion-demo/README.md) for SquareMCP video production assets and render workflows
|
||||
|
||||
---
|
||||
|
||||
|
||||
321
SETUP_GUIDE.md
Normal file
321
SETUP_GUIDE.md
Normal file
@@ -0,0 +1,321 @@
|
||||
# Hermes MCP — Platform Setup Guide
|
||||
|
||||
How to obtain credentials and configure each social platform integration.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [TikTok](#tiktok)
|
||||
- [Facebook](#facebook)
|
||||
- [Instagram](#instagram)
|
||||
- [LinkedIn](#linkedin)
|
||||
- [Twitter / X](#twitter--x)
|
||||
- [Discord](#discord)
|
||||
- [Telegram](#telegram)
|
||||
- [WhatsApp](#whatsapp-meta-cloud-api)
|
||||
- [Email (IMAP/SMTP)](#email-imapsmtp)
|
||||
- [Obsidian](#obsidian)
|
||||
- [Quick Reference: All Env Vars](#quick-reference-all-env-vars)
|
||||
|
||||
---
|
||||
|
||||
## TikTok
|
||||
|
||||
### Prerequisites
|
||||
- TikTok Developer account at [developers.tiktok.com](https://developers.tiktok.com)
|
||||
- App created in Sandbox or Production mode
|
||||
|
||||
### Steps
|
||||
1. Go to **Developer Portal → Manage Apps → Your App**
|
||||
2. Add **Login Kit** product → set redirect URI: `https://tiktok.squaremcp.com/auth/tiktok/callback`
|
||||
3. Add **Content Posting API** product → enable Direct Post
|
||||
4. Configure scopes: `user.info.basic`, `user.info.profile`, `user.info.stats`, `video.list`, `video.publish`
|
||||
5. Add domain verification files to site root (TikTok provides `.txt` files)
|
||||
6. Add sandbox test users under **Sandbox → Target Users**
|
||||
|
||||
### Credentials
|
||||
| Env Var | Source |
|
||||
|---|---|
|
||||
| `TIKTOK_CLIENT_KEY` | App → Basic Information → Client Key |
|
||||
| `TIKTOK_CLIENT_SECRET` | App → Basic Information → Client Secret |
|
||||
| `TIKTOK_REDIRECT_URI` | `https://tiktok.squaremcp.com/auth/tiktok/callback` |
|
||||
| `TIKTOK_DEFAULT_ACCESS_TOKEN` | Complete OAuth flow → copy access_token from callback |
|
||||
|
||||
---
|
||||
|
||||
## Facebook
|
||||
|
||||
### Prerequisites
|
||||
- Facebook Developer account
|
||||
- Facebook Page (Business/Brand page, not personal profile)
|
||||
- Admin role on the page
|
||||
|
||||
### Steps
|
||||
1. Go to [developers.facebook.com](https://developers.facebook.com) → **My Apps → Create App**
|
||||
2. App type: **Business** → add **Pages** product
|
||||
3. Go to **Graph API Explorer** → select your app → generate token with permissions:
|
||||
- `pages_show_list`
|
||||
- `pages_read_engagement`
|
||||
- `pages_manage_posts`
|
||||
- `publish_video`
|
||||
4. Run `GET /me/accounts` to list pages → copy the **Page Access Token** (not User Token)
|
||||
5. Copy the **Page ID** from the same response
|
||||
|
||||
### Credentials
|
||||
| Env Var | Source |
|
||||
|---|---|
|
||||
| `FACEBOOK_DEFAULT_ACCESS_TOKEN` | Page Access Token from `/me/accounts` |
|
||||
| `FACEBOOK_DEFAULT_PAGE_ID` | `id` field from `/me/accounts` response |
|
||||
|
||||
---
|
||||
|
||||
## Instagram
|
||||
|
||||
### Prerequisites
|
||||
- Instagram **Business** or **Creator** account (personal accounts do not work)
|
||||
- Instagram account connected to a Facebook Page you admin
|
||||
- Same Facebook App as above, with **Instagram Graph API** product added
|
||||
|
||||
### Steps
|
||||
1. In Instagram app: Profile → Menu → Settings → Account → Switch to Professional Account → **Business**
|
||||
2. Connect to Facebook Page under **Settings → Creator tools and controls → Set up Instagram Business Profile**
|
||||
3. In Facebook Developer Portal: add **Instagram Graph API** product to your app
|
||||
4. Open **Graph API Explorer** → select app → generate token with:
|
||||
- `instagram_basic`
|
||||
- `instagram_content_publish`
|
||||
- `pages_read_engagement`
|
||||
5. Run: `GET me/accounts?fields=name,instagram_business_account`
|
||||
6. Find your page → copy `instagram_business_account.id`
|
||||
7. Copy the **Access Token** from the explorer
|
||||
|
||||
### Credentials
|
||||
| Env Var | Source |
|
||||
|---|---|
|
||||
| `INSTAGRAM_DEFAULT_ACCESS_TOKEN` | Graph API Explorer token with `instagram_basic` |
|
||||
| `INSTAGRAM_DEFAULT_BUSINESS_ACCOUNT_ID` | `instagram_business_account.id` from `/me/accounts` |
|
||||
|
||||
---
|
||||
|
||||
## LinkedIn
|
||||
|
||||
### Prerequisites
|
||||
- LinkedIn Developer account
|
||||
- App created at [developer.linkedin.com](https://developer.linkedin.com)
|
||||
|
||||
### Steps
|
||||
1. Create app → add **Sign In with LinkedIn using OpenID Connect** product
|
||||
2. Set redirect URI: `https://hermes.squaremcp.com/oauth/callback`
|
||||
3. Request **Share on LinkedIn** product for posting permissions
|
||||
4. Generate a 3-legged OAuth token with scopes: `openid`, `profile`, `w_member_social`
|
||||
5. Copy the **Access Token**
|
||||
|
||||
### Credentials
|
||||
| Env Var | Source |
|
||||
|---|---|
|
||||
| `LINKEDIN_DEFAULT_ACCESS_TOKEN` | OAuth 2.0 token from LinkedIn Developer Portal |
|
||||
|
||||
---
|
||||
|
||||
## Twitter / X
|
||||
|
||||
### Prerequisites
|
||||
- Twitter Developer account at [developer.x.com](https://developer.x.com)
|
||||
- Project and App created
|
||||
|
||||
### Steps
|
||||
1. Create project → create app inside project
|
||||
2. Enable **User authentication settings** → OAuth 2.0 → set callback URL
|
||||
3. Permissions: **Read and Write**
|
||||
4. Go to **Keys and Tokens** → generate **User Access Tokens**
|
||||
5. Copy **Access Token** and **Access Token Secret**
|
||||
|
||||
### Credentials
|
||||
| Env Var | Source |
|
||||
|---|---|
|
||||
| `TWITTER_DEFAULT_ACCESS_TOKEN` | Keys and Tokens → Access Token |
|
||||
| `TWITTER_DEFAULT_ACCESS_TOKEN_SECRET` | Keys and Tokens → Access Token Secret |
|
||||
| `TWITTER_DEFAULT_API_KEY` | Keys and Tokens → API Key |
|
||||
| `TWITTER_DEFAULT_API_SECRET` | Keys and Tokens → API Secret |
|
||||
|
||||
---
|
||||
|
||||
## Discord
|
||||
|
||||
### Prerequisites
|
||||
- Discord account
|
||||
|
||||
### Steps
|
||||
1. Go to [discord.com/developers/applications](https://discord.com/developers/applications)
|
||||
2. Click **New Application** → name it (e.g., "SquareMCP Bot")
|
||||
3. Go to **Bot** tab → click **Add Bot**
|
||||
4. Under **Privileged Gateway Intents**, enable **MESSAGE CONTENT INTENT**
|
||||
5. Click **Reset Token** → copy the **Bot Token**
|
||||
6. Go to **OAuth2 → URL Generator**:
|
||||
- Scopes: `bot`
|
||||
- Bot Permissions: `Send Messages`, `Read Message History`, `View Channels`
|
||||
7. Copy the generated URL and open it in browser to invite the bot to your server
|
||||
|
||||
### Credentials
|
||||
| Env Var | Source |
|
||||
|---|---|
|
||||
| `DISCORD_DEFAULT_BOT_TOKEN` | Bot tab → Token |
|
||||
|
||||
---
|
||||
|
||||
## Telegram
|
||||
|
||||
### Prerequisites
|
||||
- Telegram account
|
||||
|
||||
### Steps
|
||||
1. Open Telegram → search **@BotFather**
|
||||
2. Send `/newbot` → follow prompts → pick username (must end in `bot`)
|
||||
3. BotFather sends you a **Bot Token** (e.g., `123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`)
|
||||
4. Copy the token
|
||||
|
||||
### Credentials
|
||||
| Env Var | Source |
|
||||
|---|---|
|
||||
| `TELEGRAM_DEFAULT_BOT_TOKEN` | BotFather message after creating bot |
|
||||
|
||||
---
|
||||
|
||||
## WhatsApp (Meta Cloud API)
|
||||
|
||||
### Prerequisites
|
||||
- Meta Business Account
|
||||
- WhatsApp Business Account (WABA)
|
||||
- Phone number registered with WhatsApp Business Platform
|
||||
|
||||
### Steps
|
||||
1. Go to [business.facebook.com](https://business.facebook.com) → **WhatsApp → API Setup**
|
||||
2. Create/select a WhatsApp Business Account
|
||||
3. Add a phone number → verify via SMS/voice call
|
||||
4. Go to **Configuration** → generate a **Permanent Access Token**:
|
||||
- You need a System User in Meta Business Settings
|
||||
- Assign WhatsApp Business Management permission
|
||||
- Generate token with `whatsapp_business_management` and `whatsapp_business_messaging`
|
||||
5. Copy:
|
||||
- **Phone Number ID**
|
||||
- **WhatsApp Business Account ID** (WABA ID)
|
||||
- **Access Token**
|
||||
|
||||
### Credentials
|
||||
| Env Var | Source |
|
||||
|---|---|
|
||||
| `WHATSAPP_DEFAULT_ACCESS_TOKEN` | Meta Business → System User → Permanent Token |
|
||||
| `WHATSAPP_DEFAULT_PHONE_NUMBER_ID` | WhatsApp API Setup → Phone Number ID |
|
||||
| `WHATSAPP_DEFAULT_BUSINESS_ACCOUNT_ID` | WhatsApp API Setup → WABA ID |
|
||||
|
||||
---
|
||||
|
||||
## Email (IMAP/SMTP)
|
||||
|
||||
### Supported Providers
|
||||
Yahoo, Gmail, Outlook/Exchange, GoDaddy, any IMAP/SMTP provider.
|
||||
|
||||
### Per-Account Env Vars
|
||||
Replace `{ACCOUNT}` with the account nickname (e.g., `YAHOO`, `GMAIL`, `FETCHERPAY`):
|
||||
|
||||
| Env Var | Example |
|
||||
|---|---|
|
||||
| `{ACCOUNT}_EMAIL` | `gheron01@yahoo.com` |
|
||||
| `{ACCOUNT}_PASSWORD` | App-specific password |
|
||||
| `{ACCOUNT}_IMAP_HOST` | `imap.mail.yahoo.com` |
|
||||
| `{ACCOUNT}_IMAP_PORT` | `993` |
|
||||
| `{ACCOUNT}_SMTP_HOST` | `smtp.mail.yahoo.com` |
|
||||
| `{ACCOUNT}_SMTP_PORT` | `465` or `587` |
|
||||
|
||||
### Notes
|
||||
- Gmail requires an **App Password** (not your regular Google password)
|
||||
- Yahoo requires an **App Password** generated in Account Security settings
|
||||
- GoDaddy uses `imap.secureserver.net` and `smtpout.secureserver.net`
|
||||
|
||||
---
|
||||
|
||||
## Obsidian
|
||||
|
||||
### Prerequisites
|
||||
- Obsidian vault with Syncthing enabled
|
||||
- Syncthing API key and folder ID
|
||||
|
||||
### Steps
|
||||
1. Install Syncthing on the server hosting Hermes
|
||||
2. In Obsidian: Settings → Syncthing → copy **API Key** and **Folder ID**
|
||||
3. Set vault path on the server
|
||||
|
||||
### Credentials
|
||||
| Env Var | Source |
|
||||
|---|---|
|
||||
| `OBSIDIAN_VAULT_PATH` | Absolute path to vault on server |
|
||||
| `SYNCTHING_API_KEY` | Syncthing GUI → Settings → API Key |
|
||||
| `SYNCTHING_FOLDER_ID` | Syncthing GUI → Folders → Folder ID |
|
||||
| `SYNCTHING_URL` | `http://localhost:8384` |
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: All Env Vars
|
||||
|
||||
```bash
|
||||
# TikTok
|
||||
TIKTOK_CLIENT_KEY=
|
||||
TIKTOK_CLIENT_SECRET=
|
||||
TIKTOK_REDIRECT_URI=https://tiktok.squaremcp.com/auth/tiktok/callback
|
||||
TIKTOK_DEFAULT_ACCESS_TOKEN=
|
||||
|
||||
# Facebook
|
||||
FACEBOOK_DEFAULT_ACCESS_TOKEN=
|
||||
FACEBOOK_DEFAULT_PAGE_ID=
|
||||
|
||||
# Instagram
|
||||
INSTAGRAM_DEFAULT_ACCESS_TOKEN=
|
||||
INSTAGRAM_DEFAULT_BUSINESS_ACCOUNT_ID=
|
||||
|
||||
# LinkedIn
|
||||
LINKEDIN_DEFAULT_ACCESS_TOKEN=
|
||||
|
||||
# Twitter/X
|
||||
TWITTER_DEFAULT_ACCESS_TOKEN=
|
||||
TWITTER_DEFAULT_ACCESS_TOKEN_SECRET=
|
||||
TWITTER_DEFAULT_API_KEY=
|
||||
TWITTER_DEFAULT_API_SECRET=
|
||||
|
||||
# Discord
|
||||
DISCORD_DEFAULT_BOT_TOKEN=
|
||||
|
||||
# Telegram
|
||||
TELEGRAM_DEFAULT_BOT_TOKEN=
|
||||
|
||||
# WhatsApp
|
||||
WHATSAPP_DEFAULT_ACCESS_TOKEN=
|
||||
WHATSAPP_DEFAULT_PHONE_NUMBER_ID=
|
||||
WHATSAPP_DEFAULT_BUSINESS_ACCOUNT_ID=
|
||||
|
||||
# Email (repeat pattern for each account)
|
||||
YAHOO_EMAIL=
|
||||
YAHOO_PASSWORD=
|
||||
YAHOO_IMAP_HOST=imap.mail.yahoo.com
|
||||
YAHOO_IMAP_PORT=993
|
||||
YAHOO_SMTP_HOST=smtp.mail.yahoo.com
|
||||
YAHOO_SMTP_PORT=465
|
||||
|
||||
# Obsidian
|
||||
OBSIDIAN_VAULT_PATH=
|
||||
SYNCTHING_API_KEY=
|
||||
SYNCTHING_FOLDER_ID=
|
||||
SYNCTHING_URL=http://localhost:8384
|
||||
|
||||
# Database
|
||||
MYSQL_HOST=
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_USER=
|
||||
MYSQL_PASSWORD=
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# Security
|
||||
MCP_API_KEY=
|
||||
CREDENTIAL_ENCRYPTION_KEY=
|
||||
```
|
||||
118
SOCIAL_PUBLISHING_SETUP.md
Normal file
118
SOCIAL_PUBLISHING_SETUP.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# Social Publishing Setup
|
||||
|
||||
This project can publish directly to TikTok and Facebook once the platform credentials are connected.
|
||||
|
||||
## Public video asset
|
||||
|
||||
The current SquareMCP TikTok master render is intended to be served from:
|
||||
|
||||
```text
|
||||
https://squaremcp.com/squaremcp-tiktok-launch.mp4
|
||||
```
|
||||
|
||||
TikTok and Facebook both use a pull-from-URL style publish flow in Hermes, so the video must be publicly reachable.
|
||||
|
||||
## TikTok credentials
|
||||
|
||||
Hermes accepts TikTok credentials in either of these forms:
|
||||
|
||||
1. Environment variable:
|
||||
|
||||
```text
|
||||
TIKTOK_CLIENT_KEY=...
|
||||
TIKTOK_CLIENT_SECRET=...
|
||||
TIKTOK_REDIRECT_URI=https://tiktok.squaremcp.com/auth/tiktok/callback
|
||||
TIKTOK_DEFAULT_ACCESS_TOKEN=...
|
||||
```
|
||||
|
||||
Login Kit start URL:
|
||||
|
||||
```text
|
||||
https://tiktok.squaremcp.com/auth/tiktok/start
|
||||
```
|
||||
|
||||
Login Kit callback URL:
|
||||
|
||||
```text
|
||||
https://tiktok.squaremcp.com/auth/tiktok/callback
|
||||
```
|
||||
|
||||
2. Per-customer connection:
|
||||
|
||||
```bash
|
||||
curl -X POST https://hermes.squaremcp.com/api/connect/tiktok \
|
||||
-H 'x-api-key: YOUR_MCP_API_KEY' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"accessToken": "YOUR_TIKTOK_ACCESS_TOKEN"
|
||||
}'
|
||||
```
|
||||
|
||||
Publish a video:
|
||||
|
||||
```bash
|
||||
curl -X POST https://hermes.squaremcp.com/api/tiktok/video \
|
||||
-H 'x-api-key: YOUR_MCP_API_KEY' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"video_url": "https://squaremcp.com/squaremcp-tiktok-launch.mp4",
|
||||
"title": "SquareMCP launch",
|
||||
"description": "One API key. One workflow layer. #SquareMCP #AITools #Automation"
|
||||
}'
|
||||
```
|
||||
|
||||
Check publish status:
|
||||
|
||||
```bash
|
||||
curl -X POST https://hermes.squaremcp.com/api/tiktok/video/status \
|
||||
-H 'x-api-key: YOUR_MCP_API_KEY' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"publish_id": "PUBLISH_ID_FROM_CREATE_VIDEO"
|
||||
}'
|
||||
```
|
||||
|
||||
## Facebook credentials
|
||||
|
||||
Hermes accepts Facebook credentials in either of these forms:
|
||||
|
||||
1. Environment variables:
|
||||
|
||||
```text
|
||||
FACEBOOK_DEFAULT_ACCESS_TOKEN=...
|
||||
FACEBOOK_DEFAULT_PAGE_ID=...
|
||||
```
|
||||
|
||||
2. Per-customer connection:
|
||||
|
||||
```bash
|
||||
curl -X POST https://hermes.squaremcp.com/api/connect/facebook \
|
||||
-H 'x-api-key: YOUR_MCP_API_KEY' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"accessToken": "YOUR_FACEBOOK_PAGE_ACCESS_TOKEN",
|
||||
"pageId": "YOUR_FACEBOOK_PAGE_ID"
|
||||
}'
|
||||
```
|
||||
|
||||
Publish the SquareMCP video to Facebook:
|
||||
|
||||
```bash
|
||||
curl -X POST https://hermes.squaremcp.com/api/facebook/video \
|
||||
-H 'x-api-key: YOUR_MCP_API_KEY' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"video_url": "https://squaremcp.com/squaremcp-tiktok-launch.mp4",
|
||||
"description": "SquareMCP launch video. One API key. Real tool access."
|
||||
}'
|
||||
```
|
||||
|
||||
## Required platform values
|
||||
|
||||
TikTok:
|
||||
- access token with Content Posting API access
|
||||
|
||||
Facebook:
|
||||
- Page access token with `pages_manage_posts`
|
||||
- Page access token with `pages_read_engagement`
|
||||
- Page ID
|
||||
187
package-lock.json
generated
187
package-lock.json
generated
@@ -10,16 +10,22 @@
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@types/cors": "^2.8.19",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^16.0.0",
|
||||
"express": "^4.18.0",
|
||||
"imapflow": "^1.0.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"mysql2": "^3.14.0",
|
||||
"nodemailer": "^6.9.0",
|
||||
"redis": "^5.12.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/express": "^4.17.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/nodemailer": "^6.4.0",
|
||||
"pixelmatch": "^7.1.0",
|
||||
@@ -886,6 +892,13 @@
|
||||
"@redis/client": "^5.12.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/bcryptjs": {
|
||||
"version": "2.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
||||
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
@@ -907,6 +920,16 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookie-parser": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
|
||||
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cors": {
|
||||
"version": "2.8.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||
@@ -949,6 +972,17 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jsonwebtoken": {
|
||||
"version": "9.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
||||
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/ms": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mime": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||
@@ -956,6 +990,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.37",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
|
||||
@@ -1103,6 +1144,15 @@
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"bin": {
|
||||
"bcrypt": "bin/bcrypt"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
@@ -1142,6 +1192,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -1219,6 +1275,25 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser": {
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "0.7.2",
|
||||
"cookie-signature": "1.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser/node_modules/cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||
@@ -1319,6 +1394,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@@ -1838,6 +1922,55 @@
|
||||
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jws": "^4.0.1",
|
||||
"lodash.includes": "^4.3.0",
|
||||
"lodash.isboolean": "^3.0.3",
|
||||
"lodash.isinteger": "^4.0.4",
|
||||
"lodash.isnumber": "^3.0.3",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.isstring": "^4.0.1",
|
||||
"lodash.once": "^4.0.0",
|
||||
"ms": "^2.1.1",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/libbase64": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz",
|
||||
@@ -1874,6 +2007,48 @@
|
||||
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isinteger": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isnumber": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isstring": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.once": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
@@ -2447,6 +2622,18 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||
|
||||
@@ -18,16 +18,22 @@
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@types/cors": "^2.8.19",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^16.0.0",
|
||||
"express": "^4.18.0",
|
||||
"imapflow": "^1.0.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"mysql2": "^3.14.0",
|
||||
"nodemailer": "^6.9.0",
|
||||
"redis": "^5.12.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/express": "^4.17.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/nodemailer": "^6.4.0",
|
||||
"pixelmatch": "^7.1.0",
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -6,5 +6,12 @@ COPY product/site/styles.css /usr/share/nginx/html/styles.css
|
||||
COPY product/site/script.js /usr/share/nginx/html/script.js
|
||||
COPY product/site/squaremcp-logo.svg /usr/share/nginx/html/squaremcp-logo.svg
|
||||
COPY product/site/squaremcp-hero-loop.mp4 /usr/share/nginx/html/squaremcp-hero-loop.mp4
|
||||
COPY product/site/squaremcp-tiktok-launch.mp4 /usr/share/nginx/html/squaremcp-tiktok-launch.mp4
|
||||
COPY product/site/tiktok /usr/share/nginx/html/tiktok
|
||||
COPY product/site/tiktokkFNJHjzDuzvGIlXnK4MaGw3MSluybOih.txt /usr/share/nginx/html/tiktokkFNJHjzDuzvGIlXnK4MaGw3MSluybOih.txt
|
||||
COPY product/site/privacy.html /usr/share/nginx/html/privacy.html
|
||||
COPY product/site/privacy /usr/share/nginx/html/privacy
|
||||
COPY product/site/terms.html /usr/share/nginx/html/terms.html
|
||||
COPY product/site/terms /usr/share/nginx/html/terms
|
||||
COPY product/site/tiktok/tiktokIIxGZsCAoYNJVObSA8cYj8FyWrAu1nIW.txt /usr/share/nginx/html/tiktokIIxGZsCAoYNJVObSA8cYj8FyWrAu1nIW.txt
|
||||
COPY product/site/tiktok/tiktokwJMUoUxgRBFO9T8p08qlMgtsez0Ktr9y.txt /usr/share/nginx/html/tiktokwJMUoUxgRBFO9T8p08qlMgtsez0Ktr9y.txt
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
server {
|
||||
listen 8080;
|
||||
server_name squaremcp.com www.squaremcp.com;
|
||||
server_name squaremcp.com www.squaremcp.com tiktok.squaremcp.com;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
tiktok-developers-site-verification=XbaTJRvDkwUNzhEXou9SsogGyiDkQshF
|
||||
@@ -11,3 +11,4 @@ spec:
|
||||
dnsNames:
|
||||
- squaremcp.com
|
||||
- www.squaremcp.com
|
||||
- tiktok.squaremcp.com
|
||||
|
||||
@@ -15,7 +15,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: squaremcp-site
|
||||
image: localhost:32000/squaremcp-site:latest
|
||||
image: localhost:32000/squaremcp-site@sha256:395e736f1899ce0f2402e34caa95359e2eb54b5424318cf8139982e66b35a974
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
@@ -90,8 +90,33 @@ spec:
|
||||
name: squaremcp-site
|
||||
port:
|
||||
number: 80
|
||||
- host: tiktok.squaremcp.com
|
||||
http:
|
||||
paths:
|
||||
- path: /auth/tiktok
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: hermes-mcp
|
||||
port:
|
||||
number: 3456
|
||||
- path: /api/pilot-request
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: hermes-mcp
|
||||
port:
|
||||
number: 3456
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: squaremcp-site
|
||||
port:
|
||||
number: 80
|
||||
tls:
|
||||
- hosts:
|
||||
- squaremcp.com
|
||||
- www.squaremcp.com
|
||||
- tiktok.squaremcp.com
|
||||
secretName: squaremcp-tls
|
||||
|
||||
BIN
product/site/squaremcp-tiktok-launch.mp4
Normal file
BIN
product/site/squaremcp-tiktok-launch.mp4
Normal file
Binary file not shown.
@@ -0,0 +1 @@
|
||||
tiktok-developers-site-verification=JLZJ5wt0TEQdq2RTHGAZtY9ofrtPFBeM
|
||||
BIN
product/site/tiktok-demo-video.mp4
Normal file
BIN
product/site/tiktok-demo-video.mp4
Normal file
Binary file not shown.
414
product/site/tiktok-demo.html
Normal file
414
product/site/tiktok-demo.html
Normal file
@@ -0,0 +1,414 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SquareMCP - TikTok Integration Demo</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: #f7f7f8;
|
||||
color: #1a1a1a;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.header {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
padding: 14px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
||||
}
|
||||
.header img { width: 32px; height: 32px; border-radius: 6px; }
|
||||
.header h1 { font-size: 16px; font-weight: 600; color: #1a1a1a; }
|
||||
.header .badge {
|
||||
margin-left: auto;
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.chat {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.message {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
.message.visible { opacity: 1; transform: translateY(0); }
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
.user .avatar { background: #10a37f; }
|
||||
.assistant .avatar { background: #1a1a1a; }
|
||||
.bubble {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
max-width: 640px;
|
||||
line-height: 1.6;
|
||||
font-size: 15px;
|
||||
}
|
||||
.user .bubble { background: #f0f9f6; border-color: #c8e6d5; }
|
||||
.bubble strong { color: #1a1a1a; }
|
||||
.bubble code {
|
||||
background: #f4f4f5;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
.bubble pre {
|
||||
background: #1a1a1a;
|
||||
color: #e5e5e5;
|
||||
padding: 14px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
font-size: 13px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.bubble .btn {
|
||||
display: inline-block;
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-top: 10px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
.bubble .btn:hover { background: #333; }
|
||||
.bubble .btn.tiktok {
|
||||
background: #000;
|
||||
background-image: linear-gradient(135deg, #25f4ee, #fe2c55);
|
||||
color: #fff;
|
||||
}
|
||||
.bubble .success { color: #059669; font-weight: 600; }
|
||||
.bubble .error { color: #dc2626; font-weight: 600; }
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #e5e5e5;
|
||||
border-top-color: #1a1a1a;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.typing {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.typing span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #999;
|
||||
border-radius: 50%;
|
||||
animation: bounce 1.2s infinite;
|
||||
}
|
||||
.typing span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.typing span:nth-child(3) { animation-delay: 0.4s; }
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% { transform: translateY(0); }
|
||||
40% { transform: translateY(-6px); }
|
||||
}
|
||||
.profile-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
padding: 14px;
|
||||
background: #fafafa;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.profile-card img {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.profile-card .info h3 { font-size: 16px; margin-bottom: 4px; }
|
||||
.profile-card .info p { font-size: 13px; color: #666; }
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.stat { text-align: center; }
|
||||
.stat .num { font-size: 20px; font-weight: 700; color: #1a1a1a; }
|
||||
.stat .lbl { font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.publish-form {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.publish-form input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.video-preview {
|
||||
margin-top: 12px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
max-width: 320px;
|
||||
}
|
||||
.video-preview video { width: 100%; display: block; }
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.status-badge::before {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #059669;
|
||||
border-radius: 50%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div style="width:32px;height:32px;background:linear-gradient(135deg,#25f4ee,#fe2c55);border-radius:8px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:18px;">S</div>
|
||||
<h1>SquareMCP</h1>
|
||||
<span class="badge">TikTok Integration Demo</span>
|
||||
</div>
|
||||
|
||||
<div class="chat" id="chat"></div>
|
||||
|
||||
<script>
|
||||
const API_KEY = '114521f3f9e6858480d6269446a446ef';
|
||||
const API_BASE = 'https://hermes.squaremcp.com';
|
||||
|
||||
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||
|
||||
function addMessage(role, html) {
|
||||
const chat = document.getElementById('chat');
|
||||
const div = document.createElement('div');
|
||||
div.className = `message ${role}`;
|
||||
const avatar = role === 'user' ? 'U' : 'S';
|
||||
const bg = role === 'user' ? '#10a37f' : '#1a1a1a';
|
||||
div.innerHTML = `
|
||||
<div class="avatar" style="background:${bg}">${avatar}</div>
|
||||
<div class="bubble">${html}</div>
|
||||
`;
|
||||
chat.appendChild(div);
|
||||
chat.scrollTop = chat.scrollHeight;
|
||||
requestAnimationFrame(() => div.classList.add('visible'));
|
||||
}
|
||||
|
||||
function addTyping() {
|
||||
const chat = document.getElementById('chat');
|
||||
const div = document.createElement('div');
|
||||
div.className = 'message assistant';
|
||||
div.id = 'typing-indicator';
|
||||
div.innerHTML = `
|
||||
<div class="avatar" style="background:#1a1a1a">S</div>
|
||||
<div class="bubble">
|
||||
<div class="typing"><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
`;
|
||||
chat.appendChild(div);
|
||||
chat.scrollTop = chat.scrollHeight;
|
||||
requestAnimationFrame(() => div.classList.add('visible'));
|
||||
}
|
||||
|
||||
function removeTyping() {
|
||||
const t = document.getElementById('typing-indicator');
|
||||
if (t) t.remove();
|
||||
}
|
||||
|
||||
async function apiGet(path) {
|
||||
const res = await fetch(`${API_BASE}${path}`, { headers: { 'x-api-key': API_KEY } });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function apiPost(path, body) {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-api-key': API_KEY, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function runDemo() {
|
||||
await sleep(1500);
|
||||
|
||||
// Step 1: Connect
|
||||
addMessage('user', 'Connect my TikTok account to SquareMCP');
|
||||
await sleep(800);
|
||||
addTyping();
|
||||
await sleep(1500);
|
||||
removeTyping();
|
||||
addMessage('assistant', `
|
||||
I'll help you connect your TikTok account. Click the button below to authorize SquareMCP.
|
||||
<br><br>
|
||||
<button class="btn tiktok" onclick="this.textContent='Connecting...'">🎵 Connect with TikTok</button>
|
||||
`);
|
||||
await sleep(2500);
|
||||
|
||||
addTyping();
|
||||
await sleep(2000);
|
||||
removeTyping();
|
||||
addMessage('assistant', `
|
||||
<span class="success">✅ Account connected successfully!</span><br>
|
||||
Welcome, <strong>Garfield Heron</strong>. Your TikTok account is now linked to SquareMCP.
|
||||
`);
|
||||
await sleep(2000);
|
||||
|
||||
// Step 2: Profile
|
||||
addMessage('user', 'Show my TikTok profile and stats');
|
||||
await sleep(800);
|
||||
addTyping();
|
||||
await sleep(1500);
|
||||
removeTyping();
|
||||
|
||||
const profile = await apiGet('/api/tiktok/profile?account=tiktok');
|
||||
addMessage('assistant', `
|
||||
Here's your TikTok profile:<br>
|
||||
<div class="profile-card">
|
||||
<img src="${profile.avatar_url || ''}" onerror="this.style.display='none'" alt="avatar">
|
||||
<div class="info">
|
||||
<h3>${profile.display_name || 'Garfield Heron'}</h3>
|
||||
<p>@${profile.username || 'garfieldheron'} ${profile.is_verified ? '✓' : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat"><div class="num">${profile.follower_count ?? 0}</div><div class="lbl">Followers</div></div>
|
||||
<div class="stat"><div class="num">${profile.following_count ?? 0}</div><div class="lbl">Following</div></div>
|
||||
<div class="stat"><div class="num">${profile.likes_count ?? 0}</div><div class="lbl">Likes</div></div>
|
||||
<div class="stat"><div class="num">${profile.video_count ?? 0}</div><div class="lbl">Videos</div></div>
|
||||
</div>
|
||||
`);
|
||||
await sleep(2500);
|
||||
|
||||
// Step 3: Creator Info
|
||||
addMessage('user', 'What are my publishing options?');
|
||||
await sleep(800);
|
||||
addTyping();
|
||||
await sleep(1500);
|
||||
removeTyping();
|
||||
|
||||
const creator = await apiGet('/api/tiktok/creator-info?account=tiktok');
|
||||
addMessage('assistant', `
|
||||
Here are your creator publishing settings:<br><br>
|
||||
<strong>Creator:</strong> ${creator.creator_nickname || 'Garfield Heron'} (@${creator.creator_username || 'garfieldheron'})<br>
|
||||
<strong>Max video duration:</strong> ${creator.max_video_post_duration_sec || 0} seconds<br>
|
||||
<strong>Privacy options:</strong> ${(creator.privacy_level_options || []).join(', ')}<br>
|
||||
<strong>Duet:</strong> ${creator.duet_disabled ? 'Disabled' : 'Enabled'}<br>
|
||||
<strong>Stitch:</strong> ${creator.stitch_disabled ? 'Disabled' : 'Enabled'}<br>
|
||||
<strong>Comments:</strong> ${creator.comment_disabled ? 'Disabled' : 'Enabled'}
|
||||
`);
|
||||
await sleep(2500);
|
||||
|
||||
// Step 4: Publish
|
||||
addMessage('user', 'Publish this video to my TikTok: https://squaremcp.com/squaremcp-tiktok-launch.mp4');
|
||||
await sleep(800);
|
||||
addTyping();
|
||||
await sleep(1500);
|
||||
removeTyping();
|
||||
|
||||
addMessage('assistant', `
|
||||
I'll publish that video for you.<br><br>
|
||||
<div class="video-preview">
|
||||
<video src="https://squaremcp.com/squaremcp-tiktok-launch.mp4" muted loop autoplay playsinline></video>
|
||||
</div>
|
||||
<br>
|
||||
<em>Publishing with privacy: ${creator.privacy_level_options?.[0] || 'SELF_ONLY'} (sandbox restriction)</em>
|
||||
`);
|
||||
await sleep(2500);
|
||||
|
||||
addTyping();
|
||||
await sleep(2000);
|
||||
removeTyping();
|
||||
|
||||
const publish = await apiPost('/api/tiktok/video', {
|
||||
video_url: 'https://squaremcp.com/squaremcp-tiktok-launch.mp4',
|
||||
title: 'SquareMCP TikTok Launch 🚀',
|
||||
account: 'tiktok'
|
||||
});
|
||||
addMessage('assistant', `
|
||||
<span class="success">✅ Video published!</span><br>
|
||||
<strong>Publish ID:</strong> <code>${publish.publish_id}</code><br>
|
||||
<strong>Status:</strong> ${publish.status}
|
||||
`);
|
||||
await sleep(2000);
|
||||
|
||||
// Step 5: Status check
|
||||
addMessage('user', 'Has the video finished processing?');
|
||||
await sleep(800);
|
||||
addTyping();
|
||||
await sleep(2000);
|
||||
removeTyping();
|
||||
|
||||
const status = await apiPost('/api/tiktok/video/status', {
|
||||
publish_id: publish.publish_id,
|
||||
account: 'tiktok'
|
||||
});
|
||||
addMessage('assistant', `
|
||||
<div class="status-badge">${status.status}</div><br><br>
|
||||
Your video has been successfully published to TikTok! You can view it in your TikTok app under your profile.
|
||||
`);
|
||||
await sleep(2000);
|
||||
|
||||
// End
|
||||
addMessage('user', 'Thanks!');
|
||||
await sleep(600);
|
||||
addTyping();
|
||||
await sleep(1000);
|
||||
removeTyping();
|
||||
addMessage('assistant', `
|
||||
You're welcome! 🎉<br><br>
|
||||
With SquareMCP, you can manage all your social media — TikTok, LinkedIn, Instagram, Facebook, Twitter/X, and more — directly from chat.
|
||||
`);
|
||||
}
|
||||
|
||||
runDemo();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
tiktok-developers-site-verification=IIxGZsCAoYNJVObSA8cYj8FyWrAu1nIW
|
||||
@@ -0,0 +1 @@
|
||||
tiktok-developers-site-verification=ebJaRSVgFOD0hQE1YXb5XhZ3IvKw4LVn
|
||||
@@ -0,0 +1 @@
|
||||
tiktok-developers-site-verification=wJMUoUxgRBFO9T8p08qlMgtsez0Ktr9y
|
||||
1
product/site/tiktokkFNJHjzDuzvGIlXnK4MaGw3MSluybOih.txt
Normal file
1
product/site/tiktokkFNJHjzDuzvGIlXnK4MaGw3MSluybOih.txt
Normal file
@@ -0,0 +1 @@
|
||||
tiktok-developers-site-verification=kFNJHjzDuzvGIlXnK4MaGw3MSluybOih
|
||||
52
scripts/record-tiktok-demo.cjs
Normal file
52
scripts/record-tiktok-demo.cjs
Normal file
@@ -0,0 +1,52 @@
|
||||
const { chromium } = require('playwright');
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PORT = 9876;
|
||||
const DEMO_HTML = path.join(__dirname, '../product/site/tiktok-demo.html');
|
||||
|
||||
// Simple static server
|
||||
const server = http.createServer((req, res) => {
|
||||
const filePath = req.url === '/' ? DEMO_HTML : path.join(__dirname, '../product/site', req.url);
|
||||
fs.readFile(filePath, (err, data) => {
|
||||
if (err) {
|
||||
res.writeHead(404); res.end('Not found'); return;
|
||||
}
|
||||
const ext = path.extname(filePath);
|
||||
const ct = ext === '.html' ? 'text/html' : ext === '.js' ? 'application/javascript' : ext === '.css' ? 'text/css' : 'application/octet-stream';
|
||||
res.writeHead(200, { 'Content-Type': ct });
|
||||
res.end(data);
|
||||
});
|
||||
});
|
||||
|
||||
async function record() {
|
||||
server.listen(PORT, '127.0.0.1', async () => {
|
||||
console.log(`Server running on http://127.0.0.1:${PORT}`);
|
||||
|
||||
const outDir = path.join(__dirname, '../product/site');
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 800 },
|
||||
recordVideo: { dir: outDir, size: { width: 1280, height: 800 } }
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(`http://127.0.0.1:${PORT}/tiktok-demo.html`, { waitUntil: 'networkidle' });
|
||||
console.log('Page loaded, recording demo...');
|
||||
|
||||
// Wait for the full demo to complete (~2.5 minutes)
|
||||
await page.waitForTimeout(155000);
|
||||
|
||||
await context.close();
|
||||
await browser.close();
|
||||
server.close();
|
||||
console.log('Recording complete');
|
||||
});
|
||||
}
|
||||
|
||||
record().catch(err => {
|
||||
console.error(err);
|
||||
server.close();
|
||||
process.exit(1);
|
||||
});
|
||||
98
src/auth.ts
Normal file
98
src/auth.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import bcryptjs from 'bcryptjs';
|
||||
const { hash, compare } = bcryptjs;
|
||||
import jwt from 'jsonwebtoken';
|
||||
const { sign, verify } = jwt;
|
||||
import { getPool } from './db.js';
|
||||
import type { RowDataPacket } from 'mysql2';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET ?? process.env.CREDENTIAL_ENCRYPTION_KEY ?? 'dev-secret-change-me';
|
||||
const SALT_ROUNDS = 12;
|
||||
|
||||
export interface JWTPayload {
|
||||
sub: string; // customer id
|
||||
email: string;
|
||||
plan: string;
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return hash(password, SALT_ROUNDS);
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
return compare(password, hash);
|
||||
}
|
||||
|
||||
export function signJWT(payload: JWTPayload): string {
|
||||
return sign(payload, JWT_SECRET, { expiresIn: '7d' });
|
||||
}
|
||||
|
||||
export function verifyJWT(token: string): JWTPayload {
|
||||
return verify(token, JWT_SECRET) as JWTPayload;
|
||||
}
|
||||
|
||||
interface CustomerRow extends RowDataPacket {
|
||||
id: string;
|
||||
email: string;
|
||||
plan: string;
|
||||
active: boolean;
|
||||
api_key: string;
|
||||
password_hash: string | null;
|
||||
}
|
||||
|
||||
export async function findCustomerByEmail(email: string): Promise<CustomerRow | null> {
|
||||
const [rows] = await getPool().query<CustomerRow[]>(
|
||||
'SELECT id, email, plan, active, api_key, password_hash FROM customers WHERE email = ?',
|
||||
[email]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function findCustomerById(id: string): Promise<CustomerRow | null> {
|
||||
const [rows] = await getPool().query<CustomerRow[]>(
|
||||
'SELECT id, email, plan, active, api_key, password_hash FROM customers WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function createCustomer(
|
||||
id: string,
|
||||
email: string,
|
||||
passwordHash: string,
|
||||
apiKey: string
|
||||
): Promise<void> {
|
||||
await getPool().query(
|
||||
'INSERT INTO customers (id, email, password_hash, api_key, plan, active) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[id, email, passwordHash, apiKey, 'free', true]
|
||||
);
|
||||
}
|
||||
|
||||
export async function setResetToken(email: string, token: string): Promise<boolean> {
|
||||
const [result] = await getPool().query<any>(
|
||||
'UPDATE customers SET reset_token = ?, reset_expires_at = DATE_ADD(NOW(), INTERVAL 1 HOUR) WHERE email = ?',
|
||||
[token, email]
|
||||
);
|
||||
return result.affectedRows > 0;
|
||||
}
|
||||
|
||||
export async function findCustomerByResetToken(token: string) {
|
||||
const [rows] = await getPool().query<CustomerRow[]>(
|
||||
'SELECT id, email, plan, active, api_key, password_hash FROM customers WHERE reset_token = ? AND reset_expires_at > NOW()',
|
||||
[token]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function clearResetToken(customerId: string): Promise<void> {
|
||||
await getPool().query(
|
||||
'UPDATE customers SET reset_token = NULL, reset_expires_at = NULL WHERE id = ?',
|
||||
[customerId]
|
||||
);
|
||||
}
|
||||
|
||||
export async function updatePassword(customerId: string, passwordHash: string): Promise<void> {
|
||||
await getPool().query(
|
||||
'UPDATE customers SET password_hash = ? WHERE id = ?',
|
||||
[passwordHash, customerId]
|
||||
);
|
||||
}
|
||||
122
src/billing/invoices.ts
Normal file
122
src/billing/invoices.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { getPool } from '../db.js';
|
||||
import type { RowDataPacket } from 'mysql2';
|
||||
|
||||
export interface InvoiceLineItem {
|
||||
description: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface Invoice extends RowDataPacket {
|
||||
id: number;
|
||||
customer_id: string;
|
||||
invoice_number: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
status: 'draft' | 'sent' | 'paid' | 'overdue';
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
line_items: InvoiceLineItem[];
|
||||
sent_at: string | null;
|
||||
paid_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function generateInvoiceNumber(): string {
|
||||
const prefix = 'SMCP';
|
||||
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||||
const random = Math.floor(Math.random() * 10000).toString().padStart(4, '0');
|
||||
return `${prefix}-${date}-${random}`;
|
||||
}
|
||||
|
||||
export async function createInvoice(
|
||||
customerId: string,
|
||||
amount: number,
|
||||
lineItems: InvoiceLineItem[],
|
||||
periodStart: string,
|
||||
periodEnd: string
|
||||
): Promise<Invoice> {
|
||||
const invoiceNumber = generateInvoiceNumber();
|
||||
await getPool().query(
|
||||
`INSERT INTO invoices (customer_id, invoice_number, amount, line_items, period_start, period_end)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[customerId, invoiceNumber, amount, JSON.stringify(lineItems), periodStart, periodEnd]
|
||||
);
|
||||
const [rows] = await getPool().query<Invoice[]>(
|
||||
'SELECT * FROM invoices WHERE invoice_number = ?',
|
||||
[invoiceNumber]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export async function getCustomerInvoices(customerId: string): Promise<Invoice[]> {
|
||||
const [rows] = await getPool().query<Invoice[]>(
|
||||
'SELECT * FROM invoices WHERE customer_id = ? ORDER BY created_at DESC',
|
||||
[customerId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getInvoiceByNumber(invoiceNumber: string): Promise<Invoice | null> {
|
||||
const [rows] = await getPool().query<Invoice[]>(
|
||||
'SELECT * FROM invoices WHERE invoice_number = ?',
|
||||
[invoiceNumber]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function markInvoiceSent(invoiceNumber: string): Promise<void> {
|
||||
await getPool().query(
|
||||
"UPDATE invoices SET status = 'sent', sent_at = NOW() WHERE invoice_number = ?",
|
||||
[invoiceNumber]
|
||||
);
|
||||
}
|
||||
|
||||
export async function markInvoicePaid(invoiceNumber: string): Promise<void> {
|
||||
await getPool().query(
|
||||
"UPDATE invoices SET status = 'paid', paid_at = NOW() WHERE invoice_number = ?",
|
||||
[invoiceNumber]
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMonthlyInvoice(customerId: string): Promise<Invoice | null> {
|
||||
const [usageRows] = await getPool().query<any[]>(
|
||||
`SELECT platform, COUNT(*) as count FROM usage_logs
|
||||
WHERE customer_id = ?
|
||||
AND created_at >= DATE_FORMAT(NOW(), '%Y-%m-01')
|
||||
AND created_at < DATE_FORMAT(DATE_ADD(NOW(), INTERVAL 1 MONTH), '%Y-%m-01')
|
||||
GROUP BY platform`,
|
||||
[customerId]
|
||||
);
|
||||
|
||||
if (!usageRows.length) return null;
|
||||
|
||||
const lineItems: InvoiceLineItem[] = [];
|
||||
let total = 0;
|
||||
|
||||
for (const row of usageRows) {
|
||||
const qty = row.count;
|
||||
const unitPrice = 0.05; // $0.05 per action
|
||||
const amount = qty * unitPrice;
|
||||
total += amount;
|
||||
lineItems.push({
|
||||
description: `${row.platform} actions`,
|
||||
quantity: qty,
|
||||
unit_price: unitPrice,
|
||||
amount: parseFloat(amount.toFixed(2)),
|
||||
});
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
return createInvoice(
|
||||
customerId,
|
||||
parseFloat(total.toFixed(2)),
|
||||
lineItems,
|
||||
start.toISOString().slice(0, 10),
|
||||
end.toISOString().slice(0, 10)
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { getPool } from '../db.js';
|
||||
import { getCredential, Platform, PlatformCredentials } from '../multitenancy/credential-store.js';
|
||||
import type { PlanKey } from './plans.js';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { verifyJWT } from '../auth.js';
|
||||
|
||||
const redis = createClient({ url: process.env.REDIS_URL });
|
||||
redis.connect().catch((err) => console.error('[billing] Redis connect error:', err));
|
||||
@@ -24,16 +25,22 @@ interface CustomerRow extends RowDataPacket {
|
||||
email: string;
|
||||
}
|
||||
|
||||
async function resolveCustomer(apiKey: string): Promise<Customer | null> {
|
||||
function buildCustomer(row: CustomerRow): Customer {
|
||||
return {
|
||||
id: row.id,
|
||||
plan: row.plan,
|
||||
active: Boolean(row.active),
|
||||
email: row.email,
|
||||
getCredential: <T extends PlatformCredentials>(platform: Platform) =>
|
||||
getCredential<T>(row.id, platform),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveCustomerByApiKey(apiKey: string): Promise<Customer | null> {
|
||||
const cached = await redis.get(`customer:apikey:${apiKey}`);
|
||||
if (cached) {
|
||||
const base = JSON.parse(cached) as Omit<Customer, 'getCredential'>;
|
||||
// Re-attach the credential loader (functions can't be cached)
|
||||
return {
|
||||
...base,
|
||||
getCredential: <T extends PlatformCredentials>(platform: Platform) =>
|
||||
getCredential<T>(base.id, platform),
|
||||
};
|
||||
const base = JSON.parse(cached) as Omit<CustomerRow, 'getCredential'>;
|
||||
return buildCustomer(base as CustomerRow);
|
||||
}
|
||||
|
||||
const [rows] = await getPool().query<CustomerRow[]>(
|
||||
@@ -42,50 +49,80 @@ async function resolveCustomer(apiKey: string): Promise<Customer | null> {
|
||||
);
|
||||
if (!rows.length) return null;
|
||||
|
||||
const { id, plan, active, email } = rows[0];
|
||||
const customer: Customer = {
|
||||
id,
|
||||
plan,
|
||||
active: Boolean(active),
|
||||
email,
|
||||
getCredential: <T extends PlatformCredentials>(platform: Platform) =>
|
||||
getCredential<T>(id, platform),
|
||||
};
|
||||
|
||||
// Cache only the serialisable fields (not the function)
|
||||
await redis.setEx(`customer:apikey:${apiKey}`, 60, JSON.stringify({ id, plan, active, email }));
|
||||
return customer;
|
||||
const row = rows[0];
|
||||
await redis.setEx(`customer:apikey:${apiKey}`, 60, JSON.stringify(row));
|
||||
return buildCustomer(row);
|
||||
}
|
||||
|
||||
// Express middleware: resolve API key → Customer and attach to req.customer
|
||||
export async function resolveCustomerById(id: string): Promise<Customer | null> {
|
||||
const cached = await redis.get(`customer:id:${id}`);
|
||||
if (cached) {
|
||||
const base = JSON.parse(cached) as CustomerRow;
|
||||
return buildCustomer(base);
|
||||
}
|
||||
|
||||
const [rows] = await getPool().query<CustomerRow[]>(
|
||||
'SELECT id, plan, active, email FROM customers WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
if (!rows.length) return null;
|
||||
|
||||
const row = rows[0];
|
||||
await redis.setEx(`customer:id:${id}`, 60, JSON.stringify(row));
|
||||
return buildCustomer(row);
|
||||
}
|
||||
|
||||
// Express middleware: resolve API key OR JWT cookie → Customer and attach to req.customer
|
||||
export async function meterMiddleware(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 1. Try API key
|
||||
const apiKey =
|
||||
(req.headers['x-api-key'] as string | undefined) ||
|
||||
(req.query.key as string | undefined);
|
||||
|
||||
if (!apiKey) {
|
||||
res.status(401).json({ error: 'Missing API key' });
|
||||
if (apiKey) {
|
||||
const customer = await resolveCustomerByApiKey(apiKey);
|
||||
if (!customer) {
|
||||
res.status(401).json({ error: 'Invalid API key' });
|
||||
return;
|
||||
}
|
||||
if (!customer.active) {
|
||||
res.status(403).json({ error: 'Account suspended' });
|
||||
return;
|
||||
}
|
||||
(req as Request & { customer: Customer }).customer = customer;
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const customer = await resolveCustomer(apiKey);
|
||||
if (!customer) {
|
||||
res.status(401).json({ error: 'Invalid API key' });
|
||||
return;
|
||||
// 2. Try JWT session cookie
|
||||
const jwtCookie = req.cookies?.session;
|
||||
if (jwtCookie) {
|
||||
try {
|
||||
const payload = verifyJWT(jwtCookie);
|
||||
const customer = await resolveCustomerById(payload.sub);
|
||||
if (!customer) {
|
||||
res.status(401).json({ error: 'Invalid session' });
|
||||
return;
|
||||
}
|
||||
if (!customer.active) {
|
||||
res.status(403).json({ error: 'Account suspended' });
|
||||
return;
|
||||
}
|
||||
(req as Request & { customer: Customer }).customer = customer;
|
||||
next();
|
||||
return;
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Invalid or expired session' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customer.active) {
|
||||
res.status(403).json({ error: 'Account suspended' });
|
||||
return;
|
||||
}
|
||||
|
||||
(req as Request & { customer: Customer }).customer = customer;
|
||||
next();
|
||||
res.status(401).json({ error: 'Missing API key or session' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
|
||||
48
src/billing/usage.ts
Normal file
48
src/billing/usage.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { getPool } from '../db.js';
|
||||
import { PLANS, type PlanKey } from './plans.js';
|
||||
|
||||
export async function recordUsage(
|
||||
customerId: string,
|
||||
platform: string,
|
||||
action: string
|
||||
): Promise<void> {
|
||||
await getPool().query(
|
||||
'INSERT INTO usage_logs (customer_id, platform, action) VALUES (?, ?, ?)',
|
||||
[customerId, platform, action]
|
||||
);
|
||||
}
|
||||
|
||||
export async function getMonthlyUsage(customerId: string): Promise<number> {
|
||||
const [rows] = await getPool().query<any[]>(
|
||||
`SELECT COUNT(*) as count FROM usage_logs
|
||||
WHERE customer_id = ?
|
||||
AND created_at >= DATE_FORMAT(NOW(), '%Y-%m-01')
|
||||
AND created_at < DATE_FORMAT(DATE_ADD(NOW(), INTERVAL 1 MONTH), '%Y-%m-01')`,
|
||||
[customerId]
|
||||
);
|
||||
return rows[0]?.count ?? 0;
|
||||
}
|
||||
|
||||
export async function getUsageBreakdown(customerId: string): Promise<Record<string, number>> {
|
||||
const [rows] = await getPool().query<any[]>(
|
||||
`SELECT platform, COUNT(*) as count FROM usage_logs
|
||||
WHERE customer_id = ?
|
||||
AND created_at >= DATE_FORMAT(NOW(), '%Y-%m-01')
|
||||
GROUP BY platform`,
|
||||
[customerId]
|
||||
);
|
||||
const breakdown: Record<string, number> = {};
|
||||
for (const row of rows) {
|
||||
breakdown[row.platform] = row.count;
|
||||
}
|
||||
return breakdown;
|
||||
}
|
||||
|
||||
export async function checkLimit(customerId: string, plan: PlanKey): Promise<{ allowed: boolean; limit: number; used: number }> {
|
||||
const planConfig = PLANS[plan];
|
||||
if (planConfig.monthlyCallLimit === -1) {
|
||||
return { allowed: true, limit: -1, used: 0 };
|
||||
}
|
||||
const used = await getMonthlyUsage(customerId);
|
||||
return { allowed: used < planConfig.monthlyCallLimit, limit: planConfig.monthlyCallLimit, used };
|
||||
}
|
||||
@@ -99,7 +99,7 @@ export async function getPosts(
|
||||
const { accessToken, pageId } = await resolveCreds(args, customer);
|
||||
const limit = args.limit ?? 10;
|
||||
const data = await fbRequest(
|
||||
`/${pageId}/feed?fields=id,message,story,created_time,permalink_url&limit=${limit}`,
|
||||
`/${pageId}/published_posts?fields=id,message,story,created_time,permalink_url&limit=${limit}`,
|
||||
accessToken
|
||||
);
|
||||
return (data.data ?? []).map((p: Record<string, unknown>) => ({
|
||||
|
||||
@@ -6,7 +6,7 @@ const TIKTOK_API_BASE = 'https://open.tiktokapis.com/v2';
|
||||
|
||||
function getEnvToken(account: string): string {
|
||||
const envKey = `TIKTOK_${account.toUpperCase()}_ACCESS_TOKEN`;
|
||||
return process.env[envKey] ?? '';
|
||||
return process.env[envKey] ?? process.env.TIKTOK_DEFAULT_ACCESS_TOKEN ?? '';
|
||||
}
|
||||
|
||||
async function resolveToken(args: { account?: string }, customer?: Customer): Promise<string> {
|
||||
@@ -122,9 +122,13 @@ export async function createVideo(
|
||||
|
||||
// Step 1: query creator info to get valid privacy levels (sandbox may not support PUBLIC_TO_EVERYONE)
|
||||
const creatorInfo = await getCreatorInfo(args, customer);
|
||||
const privacyLevel = creatorInfo.privacy_level_options.includes('PUBLIC_TO_EVERYONE')
|
||||
const options = creatorInfo.privacy_level_options;
|
||||
// Unaudited apps MUST post SELF_ONLY; prefer that when public isn't available
|
||||
const privacyLevel = options.includes('PUBLIC_TO_EVERYONE')
|
||||
? 'PUBLIC_TO_EVERYONE'
|
||||
: creatorInfo.privacy_level_options[0] ?? 'SELF_ONLY';
|
||||
: options.includes('SELF_ONLY')
|
||||
? 'SELF_ONLY'
|
||||
: options[0] ?? 'SELF_ONLY';
|
||||
|
||||
// Step 2: initialise upload
|
||||
const init = await tiktokRequest('/post/publish/video/init/', accessToken, 'POST', {
|
||||
|
||||
44
src/db.ts
44
src/db.ts
@@ -93,6 +93,7 @@ export async function initDatabase(): Promise<void> {
|
||||
await ensureColumn(db, 'oauth_auth_codes', 'scope', 'TEXT NULL');
|
||||
await ensureColumn(db, 'oauth_auth_codes', 'code_challenge', 'TEXT NULL');
|
||||
await ensureColumn(db, 'oauth_auth_codes', 'code_challenge_method', 'VARCHAR(20) NULL');
|
||||
await ensureColumn(db, 'customers', 'password_hash', 'VARCHAR(255) NULL');
|
||||
|
||||
await db.execute(`
|
||||
CREATE TABLE IF NOT EXISTS oauth_tokens (
|
||||
@@ -112,10 +113,51 @@ export async function initDatabase(): Promise<void> {
|
||||
plan ENUM('free', 'starter', 'growth', 'enterprise') DEFAULT 'free',
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
password_hash VARCHAR(255) NULL,
|
||||
role ENUM('user', 'admin') DEFAULT 'user',
|
||||
reset_token VARCHAR(255) NULL,
|
||||
reset_expires_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_api_key (api_key)
|
||||
INDEX idx_api_key (api_key),
|
||||
INDEX idx_email (email),
|
||||
INDEX idx_reset_token (reset_token)
|
||||
)
|
||||
`);
|
||||
|
||||
await db.execute(`
|
||||
CREATE TABLE IF NOT EXISTS usage_logs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
customer_id VARCHAR(255) NOT NULL,
|
||||
platform VARCHAR(32) NOT NULL,
|
||||
action VARCHAR(64) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_customer_time (customer_id, created_at)
|
||||
)
|
||||
`);
|
||||
|
||||
await db.execute(`
|
||||
CREATE TABLE IF NOT EXISTS invoices (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
customer_id VARCHAR(255) NOT NULL,
|
||||
invoice_number VARCHAR(64) NOT NULL UNIQUE,
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
currency VARCHAR(3) DEFAULT 'USD',
|
||||
status ENUM('draft', 'sent', 'paid', 'overdue') DEFAULT 'draft',
|
||||
period_start DATE NOT NULL,
|
||||
period_end DATE NOT NULL,
|
||||
line_items JSON,
|
||||
sent_at TIMESTAMP NULL,
|
||||
paid_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_customer (customer_id),
|
||||
INDEX idx_status (status)
|
||||
)
|
||||
`);
|
||||
|
||||
// Ensure new columns on existing customers table
|
||||
await ensureColumn(db, 'customers', 'role', "ENUM('user','admin') DEFAULT 'user'");
|
||||
await ensureColumn(db, 'customers', 'reset_token', 'VARCHAR(255) NULL');
|
||||
await ensureColumn(db, 'customers', 'reset_expires_at', 'TIMESTAMP NULL');
|
||||
} finally {
|
||||
db.release();
|
||||
}
|
||||
|
||||
386
src/index.ts
386
src/index.ts
@@ -1,6 +1,7 @@
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
@@ -14,7 +15,7 @@ import { tools, handleToolCall } from './tools.js';
|
||||
import { getManifest, getOpenApiSpec, getOpenApiSpecMail, getOpenApiSpecSocial } from './manifest.js';
|
||||
import { routeWhatsAppWebhook, registerWhatsAppNumber, type RoutedWebhookEvent } from './multitenancy/webhook-router.js';
|
||||
import { storeCredential, type Platform } from './multitenancy/credential-store.js';
|
||||
import { meterMiddleware, type Customer } from './billing/middleware.js';
|
||||
import { meterMiddleware, resolveCustomerByApiKey, resolveCustomerById, type Customer } from './billing/middleware.js';
|
||||
import {
|
||||
registerClient,
|
||||
getClient,
|
||||
@@ -23,9 +24,13 @@ import {
|
||||
validateAccessToken,
|
||||
getAuthorizeHtml,
|
||||
} from './oauth.js';
|
||||
import { initDatabase } from './db.js';
|
||||
import { initDatabase, getPool } from './db.js';
|
||||
import { hashPassword, verifyPassword, signJWT, verifyJWT, findCustomerByEmail, createCustomer, setResetToken, findCustomerByResetToken, clearResetToken, updatePassword } from './auth.js';
|
||||
import { recordUsage, getMonthlyUsage, getUsageBreakdown, checkLimit } from './billing/usage.js';
|
||||
import { getCustomerInvoices, getInvoiceByNumber, markInvoiceSent, markInvoicePaid, generateMonthlyInvoice } from './billing/invoices.js';
|
||||
|
||||
const app = express();
|
||||
app.use(cookieParser());
|
||||
app.use(cors({
|
||||
origin: '*',
|
||||
methods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
|
||||
@@ -152,14 +157,14 @@ async function appendPilotRequestToVault(requestId: string, body: PilotRequestBo
|
||||
const content = formatPilotRequestMarkdown(requestId, body, req);
|
||||
const dailyNotePath = `Daily Notes/${getEasternDateString()}.md`;
|
||||
|
||||
await handleToolCall('obsidian_append_to_note', {
|
||||
await callTool(req, 'obsidian_append_to_note', {
|
||||
path: 'SquareMCP/Pilot Requests.md',
|
||||
header: 'Pilot Requests',
|
||||
content,
|
||||
create_if_missing: true,
|
||||
});
|
||||
|
||||
await handleToolCall('obsidian_append_to_note', {
|
||||
await callTool(req, 'obsidian_append_to_note', {
|
||||
path: dailyNotePath,
|
||||
header: 'SquareMCP Pilot Requests',
|
||||
content,
|
||||
@@ -302,19 +307,48 @@ async function requireAuth(req: express.Request, res: express.Response, next: ex
|
||||
// No API key configured = open access
|
||||
if (!API_KEY) return next();
|
||||
|
||||
// 1. Check x-api-key header or query param (backward compatibility)
|
||||
// 1. Check x-api-key header or query param (backward compatibility — global key)
|
||||
const apiKeyProvided = (req.headers['x-api-key'] as string | undefined) || (req.query.key as string | undefined);
|
||||
if (apiKeyProvided === API_KEY) return next();
|
||||
|
||||
// 2. Check OAuth Bearer token
|
||||
// 2. Check customer API key (per-user SaaS auth)
|
||||
if (apiKeyProvided) {
|
||||
const customer = await resolveCustomerByApiKey(apiKeyProvided);
|
||||
if (customer && customer.active) {
|
||||
(req as express.Request & { customer?: Customer }).customer = customer;
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check OAuth Bearer token
|
||||
const bearerToken = extractBearerToken(req);
|
||||
if (bearerToken && await validateAccessToken(bearerToken)) return next();
|
||||
|
||||
// 4. Check JWT session cookie (web app auth)
|
||||
const jwtCookie = req.cookies?.session;
|
||||
if (jwtCookie) {
|
||||
try {
|
||||
const payload = verifyJWT(jwtCookie);
|
||||
const customer = await resolveCustomerById(payload.sub);
|
||||
if (customer && customer.active) {
|
||||
(req as express.Request & { customer?: Customer; jwtUser?: { id: string; email: string; plan: string } }).customer = customer;
|
||||
(req as express.Request & { jwtUser?: { id: string; email: string; plan: string } }).jwtUser = {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
plan: payload.plan,
|
||||
};
|
||||
return next();
|
||||
}
|
||||
} catch {
|
||||
// invalid JWT, fall through to 401
|
||||
}
|
||||
}
|
||||
|
||||
res.setHeader(
|
||||
'WWW-Authenticate',
|
||||
`Bearer realm="hermes", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`
|
||||
);
|
||||
res.status(401).json({ error: 'Unauthorized — provide x-api-key header, ?key= query param, or Bearer token' });
|
||||
res.status(401).json({ error: 'Unauthorized — provide x-api-key header, ?key= query param, Bearer token, or session cookie' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
@@ -717,7 +751,7 @@ app.post('/hermes-mcp/:linkSegment/:toolName', requireAuth, async (req, res) =>
|
||||
const args = (req.body ?? {}) as Record<string, unknown>;
|
||||
console.log(`[chatgpt-mcp] ${toolName}`, JSON.stringify(args).substring(0, 200));
|
||||
try {
|
||||
const result = await handleToolCall(toolName, args);
|
||||
const result = await callTool(req, toolName, args);
|
||||
const text = result.content[0].text;
|
||||
if (text.startsWith('Error:')) {
|
||||
res.status(400).json({ error: text.slice(7).trim() });
|
||||
@@ -734,7 +768,7 @@ app.get('/hermes-mcp/:linkSegment/:toolName', requireAuth, async (req, res) => {
|
||||
const args = req.query as Record<string, unknown>;
|
||||
console.log(`[chatgpt-mcp] GET ${toolName}`, JSON.stringify(args).substring(0, 200));
|
||||
try {
|
||||
const result = await handleToolCall(toolName, args);
|
||||
const result = await callTool(req, toolName, args);
|
||||
const text = result.content[0].text;
|
||||
if (text.startsWith('Error:')) {
|
||||
res.status(400).json({ error: text.slice(7).trim() });
|
||||
@@ -799,7 +833,7 @@ app.get('/api/obsidian/search', requireAuth, async (req, res) => {
|
||||
const tagsRaw = req.query.tags as string | undefined;
|
||||
const tags = tagsRaw ? tagsRaw.split(',').map((t) => t.trim()).filter(Boolean) : undefined;
|
||||
try {
|
||||
const result = await handleToolCall('obsidian_search_notes', { query, limit, path_filter, tags });
|
||||
const result = await callTool(req, 'obsidian_search_notes', { query, limit, path_filter, tags });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -810,7 +844,7 @@ app.get('/api/obsidian/note', requireAuth, async (req, res) => {
|
||||
const path = req.query.path as string | undefined;
|
||||
if (!path) { res.status(400).json({ error: 'path is required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('obsidian_read_note', { path });
|
||||
const result = await callTool(req, 'obsidian_read_note', { path });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
@@ -822,7 +856,7 @@ app.post('/api/obsidian/note/append', requireAuth, async (req, res) => {
|
||||
const { path, content, header, create_if_missing } = req.body as Record<string, unknown>;
|
||||
if (!path || !content) { res.status(400).json({ error: 'path and content are required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('obsidian_append_to_note', { path, content, header, create_if_missing });
|
||||
const result = await callTool(req, 'obsidian_append_to_note', { path, content, header, create_if_missing });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -833,16 +867,16 @@ app.put('/api/obsidian/note', requireAuth, async (req, res) => {
|
||||
const { path, content } = req.body as Record<string, unknown>;
|
||||
if (!path || !content) { res.status(400).json({ error: 'path and content are required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('obsidian_update_note', { path, content });
|
||||
const result = await callTool(req, 'obsidian_update_note', { path, content });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/obsidian/sync', requireAuth, async (_req, res) => {
|
||||
app.get('/api/obsidian/sync', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const result = await handleToolCall('obsidian_sync_status', {});
|
||||
const result = await callTool(req, 'obsidian_sync_status', {});
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -854,7 +888,7 @@ app.post('/api/whatsapp/send', requireAuth, async (req, res) => {
|
||||
const { to, message, account } = req.body as Record<string, unknown>;
|
||||
if (!to || !message) { res.status(400).json({ error: 'to and message are required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('whatsapp_send_message', { to, message, account });
|
||||
const result = await callTool(req, 'whatsapp_send_message', { to, message, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -865,7 +899,7 @@ app.post('/api/whatsapp/template', requireAuth, async (req, res) => {
|
||||
const { to, template_name, language, components, account } = req.body as Record<string, unknown>;
|
||||
if (!to || !template_name) { res.status(400).json({ error: 'to and template_name are required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('whatsapp_send_template', { to, template_name, language, components, account });
|
||||
const result = await callTool(req, 'whatsapp_send_template', { to, template_name, language, components, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -875,7 +909,7 @@ app.post('/api/whatsapp/template', requireAuth, async (req, res) => {
|
||||
app.get('/api/whatsapp/templates', requireAuth, async (req, res) => {
|
||||
const account = req.query.account as string | undefined;
|
||||
try {
|
||||
const result = await handleToolCall('whatsapp_list_templates', { account });
|
||||
const result = await callTool(req, 'whatsapp_list_templates', { account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -916,6 +950,93 @@ app.post('/webhook/whatsapp', express.json(), async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Auth endpoints ──────────────────────────────────────────────
|
||||
|
||||
app.post('/api/auth/signup', express.json(), async (req, res) => {
|
||||
const { email, password } = req.body as Record<string, string>;
|
||||
if (!email || !password) {
|
||||
res.status(400).json({ error: 'Email and password required' });
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
res.status(400).json({ error: 'Password must be at least 8 characters' });
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await findCustomerByEmail(email);
|
||||
if (existing) {
|
||||
res.status(409).json({ error: 'Email already registered' });
|
||||
return;
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const apiKey = crypto.randomUUID().replace(/-/g, '');
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
try {
|
||||
// Check if this is the first user — make them admin
|
||||
const [countRows] = await getPool().query<any[]>('SELECT COUNT(*) as c FROM customers');
|
||||
const isFirstUser = countRows[0]?.c === 0;
|
||||
await createCustomer(id, email, passwordHash, apiKey);
|
||||
if (isFirstUser) {
|
||||
await getPool().query("UPDATE customers SET role = 'admin', plan = 'enterprise' WHERE id = ?", [id]);
|
||||
}
|
||||
const token = signJWT({ sub: id, email, plan: isFirstUser ? 'enterprise' : 'free' });
|
||||
res.cookie('session', token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
});
|
||||
res.status(201).json({ id, email, plan: 'free', api_key: apiKey });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to create account' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/login', express.json(), async (req, res) => {
|
||||
const { email, password } = req.body as Record<string, string>;
|
||||
if (!email || !password) {
|
||||
res.status(400).json({ error: 'Email and password required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const customer = await findCustomerByEmail(email);
|
||||
if (!customer || !customer.password_hash) {
|
||||
res.status(401).json({ error: 'Invalid credentials' });
|
||||
return;
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(password, customer.password_hash);
|
||||
if (!valid) {
|
||||
res.status(401).json({ error: 'Invalid credentials' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = signJWT({ sub: customer.id, email: customer.email, plan: customer.plan });
|
||||
res.cookie('session', token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
res.json({ id: customer.id, email: customer.email, plan: customer.plan, api_key: customer.api_key });
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', (_req, res) => {
|
||||
res.clearCookie('session');
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.get('/api/auth/me', requireAuth, async (req, res) => {
|
||||
const jwtUser = (req as express.Request & { jwtUser?: { id: string; email: string; plan: string } }).jwtUser;
|
||||
if (jwtUser) {
|
||||
res.json(jwtUser);
|
||||
return;
|
||||
}
|
||||
res.status(401).json({ error: 'Not authenticated' });
|
||||
});
|
||||
|
||||
// ── Customer onboarding endpoints ───────────────────────────────
|
||||
|
||||
// Connect WhatsApp — called after customer enters their Meta credentials
|
||||
@@ -1013,11 +1134,174 @@ app.get('/api/connections', meterMiddleware, async (req, res) => {
|
||||
res.json({ customerId: customer.id, connections: status });
|
||||
});
|
||||
|
||||
// ── Usage & Limits ──────────────────────────────────────────────
|
||||
|
||||
app.get('/api/usage', meterMiddleware, async (req, res) => {
|
||||
const customer = (req as unknown as { customer: Customer }).customer;
|
||||
const used = await getMonthlyUsage(customer.id);
|
||||
const breakdown = await getUsageBreakdown(customer.id);
|
||||
const limitCheck = await checkLimit(customer.id, customer.plan);
|
||||
res.json({
|
||||
plan: customer.plan,
|
||||
monthlyLimit: limitCheck.limit,
|
||||
used,
|
||||
remaining: limitCheck.limit === -1 ? -1 : Math.max(0, limitCheck.limit - used),
|
||||
breakdown,
|
||||
});
|
||||
});
|
||||
|
||||
// ── Invoices ────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/invoices', meterMiddleware, async (req, res) => {
|
||||
const customer = (req as unknown as { customer: Customer }).customer;
|
||||
const invoices = await getCustomerInvoices(customer.id);
|
||||
res.json({ invoices });
|
||||
});
|
||||
|
||||
app.get('/api/invoices/:number', meterMiddleware, async (req, res) => {
|
||||
const invoice = await getInvoiceByNumber(req.params.number);
|
||||
if (!invoice) {
|
||||
res.status(404).json({ error: 'Invoice not found' });
|
||||
return;
|
||||
}
|
||||
res.json(invoice);
|
||||
});
|
||||
|
||||
// ── Password Reset ──────────────────────────────────────────────
|
||||
|
||||
app.post('/api/auth/forgot-password', express.json(), async (req, res) => {
|
||||
const { email } = req.body as Record<string, string>;
|
||||
if (!email) {
|
||||
res.status(400).json({ error: 'Email required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = crypto.randomUUID().replace(/-/g, '');
|
||||
const success = await setResetToken(email, token);
|
||||
|
||||
if (!success) {
|
||||
// Don't reveal if email exists
|
||||
res.json({ message: 'If an account exists, a reset link has been sent.' });
|
||||
return;
|
||||
}
|
||||
|
||||
// In production, send email here. For now, return the token in dev mode.
|
||||
const resetUrl = `https://app.squaremcp.com/reset-password?token=${token}`;
|
||||
res.json({
|
||||
message: 'Password reset link generated.',
|
||||
resetUrl,
|
||||
token,
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/auth/reset-password', express.json(), async (req, res) => {
|
||||
const { token, password } = req.body as Record<string, string>;
|
||||
if (!token || !password) {
|
||||
res.status(400).json({ error: 'Token and password required' });
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
res.status(400).json({ error: 'Password must be at least 8 characters' });
|
||||
return;
|
||||
}
|
||||
|
||||
const customer = await findCustomerByResetToken(token);
|
||||
if (!customer) {
|
||||
res.status(400).json({ error: 'Invalid or expired reset token' });
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
await updatePassword(customer.id, passwordHash);
|
||||
await clearResetToken(customer.id);
|
||||
|
||||
res.json({ message: 'Password updated successfully.' });
|
||||
});
|
||||
|
||||
// ── Admin Endpoints ─────────────────────────────────────────────
|
||||
|
||||
function callTool(req: express.Request, name: string, args: Record<string, unknown>) {
|
||||
const customer = (req as express.Request & { customer?: Customer }).customer;
|
||||
return handleToolCall(name, args, customer);
|
||||
}
|
||||
|
||||
async function requireAdmin(req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||
// Global API key = superadmin access
|
||||
const apiKeyProvided = (req.headers['x-api-key'] as string | undefined) || (req.query.key as string | undefined);
|
||||
if (apiKeyProvided === API_KEY) return next();
|
||||
|
||||
// Check JWT first
|
||||
const jwtCookie = req.cookies?.session;
|
||||
let customerId: string | null = null;
|
||||
|
||||
if (jwtCookie) {
|
||||
try {
|
||||
const payload = verifyJWT(jwtCookie);
|
||||
customerId = payload.sub;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
// Check customer API key
|
||||
if (!customerId && apiKeyProvided) {
|
||||
const customer = await resolveCustomerByApiKey(apiKeyProvided);
|
||||
if (customer) customerId = customer.id;
|
||||
}
|
||||
|
||||
if (!customerId) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const [rows] = await getPool().query<any[]>('SELECT role FROM customers WHERE id = ?', [customerId]);
|
||||
if (!rows.length || rows[0].role !== 'admin') {
|
||||
res.status(403).json({ error: 'Admin access required' });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
app.get('/api/admin/customers', requireAdmin, async (req, res) => {
|
||||
const [rows] = await getPool().query<any[]>(
|
||||
'SELECT id, email, plan, active, role, created_at FROM customers ORDER BY created_at DESC'
|
||||
);
|
||||
res.json({ customers: rows });
|
||||
});
|
||||
|
||||
app.get('/api/admin/customers/:id/usage', requireAdmin, async (req, res) => {
|
||||
const customerId = req.params.id;
|
||||
const used = await getMonthlyUsage(customerId);
|
||||
const breakdown = await getUsageBreakdown(customerId);
|
||||
res.json({ customerId, used, breakdown });
|
||||
});
|
||||
|
||||
app.post('/api/admin/customers/:id/invoice', requireAdmin, async (req, res) => {
|
||||
const customerId = req.params.id;
|
||||
const invoice = await generateMonthlyInvoice(customerId);
|
||||
if (!invoice) {
|
||||
res.status(400).json({ error: 'No usage to invoice' });
|
||||
return;
|
||||
}
|
||||
res.json(invoice);
|
||||
});
|
||||
|
||||
app.post('/api/admin/invoices/:number/send', requireAdmin, async (req, res) => {
|
||||
await markInvoiceSent(req.params.number);
|
||||
res.json({ sent: true });
|
||||
});
|
||||
|
||||
app.post('/api/admin/invoices/:number/pay', requireAdmin, async (req, res) => {
|
||||
await markInvoicePaid(req.params.number);
|
||||
res.json({ paid: true });
|
||||
});
|
||||
|
||||
// ── LinkedIn REST endpoints ─────────────────────────────────────
|
||||
app.get('/api/linkedin/profile', requireAuth, async (req, res) => {
|
||||
const account = req.query.account as string | undefined;
|
||||
try {
|
||||
const result = await handleToolCall('linkedin_get_profile', { account });
|
||||
const result = await callTool(req, 'linkedin_get_profile', { account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1028,7 +1312,7 @@ app.post('/api/linkedin/post', requireAuth, async (req, res) => {
|
||||
const { text, visibility, account } = req.body as Record<string, unknown>;
|
||||
if (!text) { res.status(400).json({ error: 'text is required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('linkedin_create_post', { text, visibility, account });
|
||||
const result = await callTool(req, 'linkedin_create_post', { text, visibility, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1038,7 +1322,7 @@ app.post('/api/linkedin/post', requireAuth, async (req, res) => {
|
||||
app.post('/api/linkedin/video', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { video_url, text, visibility, account } = req.body as Record<string, unknown>;
|
||||
const result = await handleToolCall('linkedin_upload_video', { video_url, text, visibility, account });
|
||||
const result = await callTool(req, 'linkedin_upload_video', { video_url, text, visibility, account });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: String(err) });
|
||||
@@ -1048,7 +1332,7 @@ app.post('/api/linkedin/video', requireAuth, async (req, res) => {
|
||||
app.post('/api/linkedin/search-connections', requireAuth, async (req, res) => {
|
||||
const { keywords, account } = req.body as Record<string, unknown>;
|
||||
try {
|
||||
const result = await handleToolCall('linkedin_search_connections', { keywords, account });
|
||||
const result = await callTool(req, 'linkedin_search_connections', { keywords, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1059,7 +1343,7 @@ app.post('/api/linkedin/message', requireAuth, async (req, res) => {
|
||||
const { recipient_id, message, account } = req.body as Record<string, unknown>;
|
||||
if (!recipient_id || !message) { res.status(400).json({ error: 'recipient_id and message are required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('linkedin_send_message', { recipient_id, message, account });
|
||||
const result = await callTool(req, 'linkedin_send_message', { recipient_id, message, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1070,7 +1354,7 @@ app.post('/api/linkedin/message', requireAuth, async (req, res) => {
|
||||
app.get('/api/telegram/me', requireAuth, async (req, res) => {
|
||||
const account = req.query.account as string | undefined;
|
||||
try {
|
||||
const result = await handleToolCall('telegram_get_me', { account });
|
||||
const result = await callTool(req, 'telegram_get_me', { account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1081,7 +1365,7 @@ app.post('/api/telegram/message', requireAuth, async (req, res) => {
|
||||
const { chat_id, text, parse_mode, account } = req.body as Record<string, unknown>;
|
||||
if (!chat_id || !text) { res.status(400).json({ error: 'chat_id and text are required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('telegram_send_message', { chat_id, text, parse_mode, account });
|
||||
const result = await callTool(req, 'telegram_send_message', { chat_id, text, parse_mode, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1092,7 +1376,7 @@ app.post('/api/telegram/photo', requireAuth, async (req, res) => {
|
||||
const { chat_id, photo, caption, account } = req.body as Record<string, unknown>;
|
||||
if (!chat_id || !photo) { res.status(400).json({ error: 'chat_id and photo are required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('telegram_send_photo', { chat_id, photo, caption, account });
|
||||
const result = await callTool(req, 'telegram_send_photo', { chat_id, photo, caption, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1103,7 +1387,7 @@ app.get('/api/telegram/updates', requireAuth, async (req, res) => {
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : undefined;
|
||||
const account = req.query.account as string | undefined;
|
||||
try {
|
||||
const result = await handleToolCall('telegram_get_updates', { limit, account });
|
||||
const result = await callTool(req, 'telegram_get_updates', { limit, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1115,7 +1399,7 @@ app.get('/api/telegram/chat', requireAuth, async (req, res) => {
|
||||
const account = req.query.account as string | undefined;
|
||||
if (!chat_id) { res.status(400).json({ error: 'chat_id is required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('telegram_get_chat', { chat_id, account });
|
||||
const result = await callTool(req, 'telegram_get_chat', { chat_id, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1126,7 +1410,7 @@ app.get('/api/telegram/chat', requireAuth, async (req, res) => {
|
||||
app.get('/api/discord/me', requireAuth, async (req, res) => {
|
||||
const account = req.query.account as string | undefined;
|
||||
try {
|
||||
const result = await handleToolCall('discord_get_me', { account });
|
||||
const result = await callTool(req, 'discord_get_me', { account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1136,7 +1420,7 @@ app.get('/api/discord/me', requireAuth, async (req, res) => {
|
||||
app.get('/api/discord/guilds', requireAuth, async (req, res) => {
|
||||
const account = req.query.account as string | undefined;
|
||||
try {
|
||||
const result = await handleToolCall('discord_get_guilds', { account });
|
||||
const result = await callTool(req, 'discord_get_guilds', { account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1148,7 +1432,7 @@ app.get('/api/discord/channels', requireAuth, async (req, res) => {
|
||||
const account = req.query.account as string | undefined;
|
||||
if (!guild_id) { res.status(400).json({ error: 'guild_id is required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('discord_get_channels', { guild_id, account });
|
||||
const result = await callTool(req, 'discord_get_channels', { guild_id, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1159,7 +1443,7 @@ app.post('/api/discord/message', requireAuth, async (req, res) => {
|
||||
const { channel_id, content, account } = req.body as Record<string, unknown>;
|
||||
if (!channel_id || !content) { res.status(400).json({ error: 'channel_id and content are required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('discord_send_message', { channel_id, content, account });
|
||||
const result = await callTool(req, 'discord_send_message', { channel_id, content, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1172,7 +1456,7 @@ app.get('/api/discord/messages', requireAuth, async (req, res) => {
|
||||
const account = req.query.account as string | undefined;
|
||||
if (!channel_id) { res.status(400).json({ error: 'channel_id is required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('discord_get_messages', { channel_id, limit, account });
|
||||
const result = await callTool(req, 'discord_get_messages', { channel_id, limit, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1183,7 +1467,7 @@ app.get('/api/discord/messages', requireAuth, async (req, res) => {
|
||||
app.get('/api/instagram/profile', requireAuth, async (req, res) => {
|
||||
const account = req.query.account as string | undefined;
|
||||
try {
|
||||
const result = await handleToolCall('instagram_get_profile', { account });
|
||||
const result = await callTool(req, 'instagram_get_profile', { account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1194,7 +1478,7 @@ app.get('/api/instagram/media', requireAuth, async (req, res) => {
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : undefined;
|
||||
const account = req.query.account as string | undefined;
|
||||
try {
|
||||
const result = await handleToolCall('instagram_get_media', { limit, account });
|
||||
const result = await callTool(req, 'instagram_get_media', { limit, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1205,7 +1489,7 @@ app.post('/api/instagram/post', requireAuth, async (req, res) => {
|
||||
const { image_url, caption, account } = req.body as Record<string, unknown>;
|
||||
if (!image_url) { res.status(400).json({ error: 'image_url is required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('instagram_create_post', { image_url, caption, account });
|
||||
const result = await callTool(req, 'instagram_create_post', { image_url, caption, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1216,7 +1500,7 @@ app.post('/api/instagram/reel', requireAuth, async (req, res) => {
|
||||
const { video_url, caption, account } = req.body as Record<string, unknown>;
|
||||
if (!video_url) { res.status(400).json({ error: 'video_url is required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('instagram_create_reel', { video_url, caption, account });
|
||||
const result = await callTool(req, 'instagram_create_reel', { video_url, caption, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1230,7 +1514,7 @@ app.get('/api/twitter/search', requireAuth, async (req, res) => {
|
||||
const account = req.query.account as string | undefined;
|
||||
if (!query) { res.status(400).json({ error: 'query is required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('twitter_search_tweets', { query, max_results, account });
|
||||
const result = await callTool(req, 'twitter_search_tweets', { query, max_results, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1242,7 +1526,7 @@ app.get('/api/twitter/user', requireAuth, async (req, res) => {
|
||||
const account = req.query.account as string | undefined;
|
||||
if (!username) { res.status(400).json({ error: 'username is required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('twitter_get_user_profile', { username, account });
|
||||
const result = await callTool(req, 'twitter_get_user_profile', { username, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1255,7 +1539,7 @@ app.get('/api/twitter/tweets', requireAuth, async (req, res) => {
|
||||
const account = req.query.account as string | undefined;
|
||||
if (!username) { res.status(400).json({ error: 'username is required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('twitter_get_user_tweets', { username, max_results, account });
|
||||
const result = await callTool(req, 'twitter_get_user_tweets', { username, max_results, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1266,7 +1550,7 @@ app.post('/api/twitter/tweet', requireAuth, async (req, res) => {
|
||||
const { text, account } = req.body as Record<string, unknown>;
|
||||
if (!text) { res.status(400).json({ error: 'text is required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('twitter_create_tweet', { text, account });
|
||||
const result = await callTool(req, 'twitter_create_tweet', { text, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1277,7 +1561,7 @@ app.post('/api/twitter/video', requireAuth, async (req, res) => {
|
||||
const { video_url, text, account } = req.body as Record<string, unknown>;
|
||||
if (!video_url || !text) { res.status(400).json({ error: 'video_url and text are required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('twitter_upload_video', { video_url, text, account });
|
||||
const result = await callTool(req, 'twitter_upload_video', { video_url, text, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1288,7 +1572,7 @@ app.post('/api/twitter/video', requireAuth, async (req, res) => {
|
||||
app.get('/api/facebook/page', requireAuth, async (req, res) => {
|
||||
const account = req.query.account as string | undefined;
|
||||
try {
|
||||
const result = await handleToolCall('facebook_get_page', { account });
|
||||
const result = await callTool(req, 'facebook_get_page', { account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1299,7 +1583,7 @@ app.get('/api/facebook/posts', requireAuth, async (req, res) => {
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : undefined;
|
||||
const account = req.query.account as string | undefined;
|
||||
try {
|
||||
const result = await handleToolCall('facebook_get_posts', { limit, account });
|
||||
const result = await callTool(req, 'facebook_get_posts', { limit, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1310,7 +1594,7 @@ app.post('/api/facebook/post', requireAuth, async (req, res) => {
|
||||
const { message, link, account } = req.body as Record<string, unknown>;
|
||||
if (!message) { res.status(400).json({ error: 'message is required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('facebook_create_post', { message, link, account });
|
||||
const result = await callTool(req, 'facebook_create_post', { message, link, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1321,7 +1605,7 @@ app.post('/api/facebook/photo', requireAuth, async (req, res) => {
|
||||
const { image_url, caption, account } = req.body as Record<string, unknown>;
|
||||
if (!image_url) { res.status(400).json({ error: 'image_url is required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('facebook_create_photo_post', { image_url, caption, account });
|
||||
const result = await callTool(req, 'facebook_create_photo_post', { image_url, caption, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1332,7 +1616,7 @@ app.post('/api/facebook/video', requireAuth, async (req, res) => {
|
||||
const { video_url, description, account } = req.body as Record<string, unknown>;
|
||||
if (!video_url) { res.status(400).json({ error: 'video_url is required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('facebook_create_video_post', { video_url, description, account });
|
||||
const result = await callTool(req, 'facebook_create_video_post', { video_url, description, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1343,7 +1627,7 @@ app.post('/api/facebook/video', requireAuth, async (req, res) => {
|
||||
app.get('/api/tiktok/profile', requireAuth, async (req, res) => {
|
||||
const account = req.query.account as string | undefined;
|
||||
try {
|
||||
const result = await handleToolCall('tiktok_get_profile', { account });
|
||||
const result = await callTool(req, 'tiktok_get_profile', { account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1353,7 +1637,7 @@ app.get('/api/tiktok/profile', requireAuth, async (req, res) => {
|
||||
app.get('/api/tiktok/creator-info', requireAuth, async (req, res) => {
|
||||
const account = req.query.account as string | undefined;
|
||||
try {
|
||||
const result = await handleToolCall('tiktok_get_creator_info', { account });
|
||||
const result = await callTool(req, 'tiktok_get_creator_info', { account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1364,7 +1648,7 @@ app.post('/api/tiktok/video', requireAuth, async (req, res) => {
|
||||
const { video_url, title, description, account } = req.body as Record<string, unknown>;
|
||||
if (!video_url) { res.status(400).json({ error: 'video_url is required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('tiktok_create_video', { video_url, title, description, account });
|
||||
const result = await callTool(req, 'tiktok_create_video', { video_url, title, description, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1375,7 +1659,7 @@ app.post('/api/tiktok/video/status', requireAuth, async (req, res) => {
|
||||
const { publish_id, account } = req.body as Record<string, unknown>;
|
||||
if (!publish_id) { res.status(400).json({ error: 'publish_id is required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('tiktok_get_video_status', { publish_id, account });
|
||||
const result = await callTool(req, 'tiktok_get_video_status', { publish_id, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { Customer } from './billing/middleware.js';
|
||||
import { recordUsage } from './billing/usage.js';
|
||||
import { searchMessages, readMessage, getProfile, listFolders, type Account } from './imap.js';
|
||||
import { sendEmail, createDraft } from './smtp.js';
|
||||
import { searchNotes, getNote, appendToNote, updateNote, getSyncStatus } from './clients/obsidian.js';
|
||||
@@ -1124,6 +1125,10 @@ export async function handleToolCall(
|
||||
}
|
||||
|
||||
console.log(`[tool] ${name} OK (${Date.now() - t0}ms)`);
|
||||
if (customer) {
|
||||
const platform = name.split('_')[0];
|
||||
recordUsage(customer.id, platform, name).catch(() => {});
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
||||
};
|
||||
|
||||
@@ -1,54 +1,72 @@
|
||||
# Remotion video
|
||||
# SquareMCP Remotion Workflow
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/remotion-dev/logo">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/remotion-dev/logo/raw/main/animated-logo-banner-dark.apng">
|
||||
<img alt="Animated Remotion Logo" src="https://github.com/remotion-dev/logo/raw/main/animated-logo-banner-light.gif">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
This directory is the canonical video-production workspace for SquareMCP marketing assets inside the `hermes-mcp` repo.
|
||||
|
||||
Welcome to your Remotion project!
|
||||
## What lives here
|
||||
|
||||
## Commands
|
||||
- `SquareMCPLinkedIn`: 90-second 1080x1080 explainer
|
||||
- `SquareMCPHeroLoop`: 30-second 1920x1080 seamless site hero loop
|
||||
- `SquareMCPTikTokFull`: 30-second 1080x1920 short-form vertical launch video
|
||||
- `SquareMCPTikTokHook`, `SquareMCPTikTokProblem`, `SquareMCPTikTokDemo`, `SquareMCPTikTokProof`, `SquareMCPTikTokCTA`: standalone cutdowns for platform testing
|
||||
|
||||
**Install Dependencies**
|
||||
## Install
|
||||
|
||||
```console
|
||||
npm i
|
||||
```bash
|
||||
cd videos/remotion-demo
|
||||
npm install
|
||||
```
|
||||
|
||||
**Start Preview**
|
||||
## Preview in Studio
|
||||
|
||||
```console
|
||||
```bash
|
||||
cd videos/remotion-demo
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**Render video**
|
||||
Remotion Studio will open on `http://localhost:3000`.
|
||||
|
||||
```console
|
||||
npx remotion render
|
||||
## Render Commands
|
||||
|
||||
Full LinkedIn video:
|
||||
|
||||
```bash
|
||||
cd videos/remotion-demo
|
||||
npx remotion render src/index.ts SquareMCPLinkedIn out/squaremcp-linkedin.mp4
|
||||
```
|
||||
|
||||
**Upgrade Remotion**
|
||||
Full site hero loop:
|
||||
|
||||
```console
|
||||
npx remotion upgrade
|
||||
```bash
|
||||
cd videos/remotion-demo
|
||||
npx remotion render src/index.ts SquareMCPHeroLoop out/squaremcp-hero-loop.mp4
|
||||
```
|
||||
|
||||
## Docs
|
||||
Full TikTok video:
|
||||
|
||||
Get started with Remotion by reading the [fundamentals page](https://www.remotion.dev/docs/the-fundamentals).
|
||||
```bash
|
||||
cd videos/remotion-demo
|
||||
npx remotion render src/index.ts SquareMCPTikTokFull out/squaremcp-tiktok-full.mp4
|
||||
```
|
||||
|
||||
## Help
|
||||
TikTok cutdowns:
|
||||
|
||||
We provide help on our [Discord server](https://discord.gg/6VzzNDwUwV).
|
||||
```bash
|
||||
cd videos/remotion-demo
|
||||
npx remotion render src/index.ts SquareMCPTikTokHook out/squaremcp-tiktok-hook.mp4
|
||||
npx remotion render src/index.ts SquareMCPTikTokProblem out/squaremcp-tiktok-problem.mp4
|
||||
npx remotion render src/index.ts SquareMCPTikTokDemo out/squaremcp-tiktok-demo.mp4
|
||||
npx remotion render src/index.ts SquareMCPTikTokProof out/squaremcp-tiktok-proof.mp4
|
||||
npx remotion render src/index.ts SquareMCPTikTokCTA out/squaremcp-tiktok-cta.mp4
|
||||
```
|
||||
|
||||
## Issues
|
||||
## Project conventions
|
||||
|
||||
Found an issue with Remotion? [File an issue here](https://github.com/remotion-dev/remotion/issues/new).
|
||||
- Keep assets brand-aligned with `src/styles.ts`
|
||||
- Put reusable animations in dedicated scene files under `src/scenes/`
|
||||
- Render outputs to `out/`; the repo already ignores that directory
|
||||
- Do not mention Remotion in public-facing social copy
|
||||
|
||||
## License
|
||||
## Notes
|
||||
|
||||
Note that for some entities a company license is needed. [Read the terms here](https://github.com/remotion-dev/remotion/blob/main/LICENSE.md).
|
||||
- Logo source for video work lives in `public/squaremcp-logo.svg`
|
||||
- The May 11 notes in the synced vault describe the original LinkedIn and hero-loop workflow
|
||||
|
||||
48
videos/remotion-demo/package-lock.json
generated
48
videos/remotion-demo/package-lock.json
generated
@@ -1002,9 +1002,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -1017,9 +1014,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -1032,9 +1026,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -1047,9 +1038,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -1537,9 +1525,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1553,9 +1538,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1569,9 +1551,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1585,9 +1564,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1824,9 +1800,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1843,9 +1816,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1862,9 +1832,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1881,9 +1848,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3631,9 +3595,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3654,9 +3615,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3677,9 +3635,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3700,9 +3655,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
@@ -2,6 +2,14 @@ import "./index.css";
|
||||
import { Composition } from "remotion";
|
||||
import { SquareMCPLinkedIn } from "./SquareMCPLinkedIn";
|
||||
import { SquareMCPHeroLoop } from "./SquareMCPHeroLoop";
|
||||
import {
|
||||
SquareMCPTikTokCTA,
|
||||
SquareMCPTikTokDemo,
|
||||
SquareMCPTikTokFull,
|
||||
SquareMCPTikTokHook,
|
||||
SquareMCPTikTokProblem,
|
||||
SquareMCPTikTokProof,
|
||||
} from "./SquareMCPTikTok";
|
||||
|
||||
export const RemotionRoot = () => {
|
||||
return (
|
||||
@@ -22,6 +30,54 @@ export const RemotionRoot = () => {
|
||||
width={1920}
|
||||
height={1080}
|
||||
/>
|
||||
<Composition
|
||||
id="SquareMCPTikTokFull"
|
||||
component={SquareMCPTikTokFull}
|
||||
durationInFrames={30 * 30}
|
||||
fps={30}
|
||||
width={1080}
|
||||
height={1920}
|
||||
/>
|
||||
<Composition
|
||||
id="SquareMCPTikTokHook"
|
||||
component={SquareMCPTikTokHook}
|
||||
durationInFrames={3 * 30}
|
||||
fps={30}
|
||||
width={1080}
|
||||
height={1920}
|
||||
/>
|
||||
<Composition
|
||||
id="SquareMCPTikTokProblem"
|
||||
component={SquareMCPTikTokProblem}
|
||||
durationInFrames={5 * 30}
|
||||
fps={30}
|
||||
width={1080}
|
||||
height={1920}
|
||||
/>
|
||||
<Composition
|
||||
id="SquareMCPTikTokDemo"
|
||||
component={SquareMCPTikTokDemo}
|
||||
durationInFrames={12 * 30}
|
||||
fps={30}
|
||||
width={1080}
|
||||
height={1920}
|
||||
/>
|
||||
<Composition
|
||||
id="SquareMCPTikTokProof"
|
||||
component={SquareMCPTikTokProof}
|
||||
durationInFrames={5 * 30}
|
||||
fps={30}
|
||||
width={1080}
|
||||
height={1920}
|
||||
/>
|
||||
<Composition
|
||||
id="SquareMCPTikTokCTA"
|
||||
component={SquareMCPTikTokCTA}
|
||||
durationInFrames={5 * 30}
|
||||
fps={30}
|
||||
width={1080}
|
||||
height={1920}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
81
videos/remotion-demo/src/SquareMCPTikTok.tsx
Normal file
81
videos/remotion-demo/src/SquareMCPTikTok.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { AbsoluteFill, Sequence, useVideoConfig } from "remotion";
|
||||
import { TikTokBackground } from "./scenes/tiktok/TikTokBackground";
|
||||
import { TikTokCTA } from "./scenes/tiktok/TikTokCTA";
|
||||
import { TikTokDemo } from "./scenes/tiktok/TikTokDemo";
|
||||
import { TikTokHook } from "./scenes/tiktok/TikTokHook";
|
||||
import { TikTokProblem } from "./scenes/tiktok/TikTokProblem";
|
||||
import { TikTokProof } from "./scenes/tiktok/TikTokProof";
|
||||
|
||||
const TikTokShell = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<TikTokBackground />
|
||||
{children}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
export const SquareMCPTikTokFull = () => {
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<TikTokShell>
|
||||
<Sequence durationInFrames={3 * fps}>
|
||||
<TikTokHook />
|
||||
</Sequence>
|
||||
<Sequence from={3 * fps} durationInFrames={5 * fps}>
|
||||
<TikTokProblem />
|
||||
</Sequence>
|
||||
<Sequence from={8 * fps} durationInFrames={12 * fps}>
|
||||
<TikTokDemo />
|
||||
</Sequence>
|
||||
<Sequence from={20 * fps} durationInFrames={5 * fps}>
|
||||
<TikTokProof />
|
||||
</Sequence>
|
||||
<Sequence from={25 * fps} durationInFrames={5 * fps}>
|
||||
<TikTokCTA />
|
||||
</Sequence>
|
||||
</TikTokShell>
|
||||
);
|
||||
};
|
||||
|
||||
export const SquareMCPTikTokHook = () => {
|
||||
return (
|
||||
<TikTokShell>
|
||||
<TikTokHook />
|
||||
</TikTokShell>
|
||||
);
|
||||
};
|
||||
|
||||
export const SquareMCPTikTokProblem = () => {
|
||||
return (
|
||||
<TikTokShell>
|
||||
<TikTokProblem />
|
||||
</TikTokShell>
|
||||
);
|
||||
};
|
||||
|
||||
export const SquareMCPTikTokDemo = () => {
|
||||
return (
|
||||
<TikTokShell>
|
||||
<TikTokDemo />
|
||||
</TikTokShell>
|
||||
);
|
||||
};
|
||||
|
||||
export const SquareMCPTikTokProof = () => {
|
||||
return (
|
||||
<TikTokShell>
|
||||
<TikTokProof />
|
||||
</TikTokShell>
|
||||
);
|
||||
};
|
||||
|
||||
export const SquareMCPTikTokCTA = () => {
|
||||
return (
|
||||
<TikTokShell>
|
||||
<TikTokCTA />
|
||||
</TikTokShell>
|
||||
);
|
||||
};
|
||||
26
videos/remotion-demo/src/scenes/tiktok/TikTokBackground.tsx
Normal file
26
videos/remotion-demo/src/scenes/tiktok/TikTokBackground.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion";
|
||||
import { COLORS } from "../../styles";
|
||||
|
||||
export const TikTokBackground = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const orbit = interpolate(frame % 180, [0, 90, 180], [0, 1, 0]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `radial-gradient(circle at ${20 + orbit * 45}% ${18 + orbit * 30}%, rgba(125, 182, 255, 0.18), transparent 28%), radial-gradient(circle at 80% 78%, rgba(14, 99, 246, 0.22), transparent 30%), linear-gradient(180deg, #070812 0%, ${COLORS.bg} 100%)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
backgroundImage:
|
||||
"linear-gradient(rgba(255,255,255,0.04) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.04) 1px, transparent 1px)",
|
||||
backgroundSize: "60px 60px",
|
||||
maskImage: "linear-gradient(180deg, rgba(0,0,0,0.65), rgba(0,0,0,0.95))",
|
||||
}}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
69
videos/remotion-demo/src/scenes/tiktok/TikTokCTA.tsx
Normal file
69
videos/remotion-demo/src/scenes/tiktok/TikTokCTA.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { AbsoluteFill, Img, interpolate, spring, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { COLORS, FONT, SPRING_CFG } from "../../styles";
|
||||
|
||||
export const TikTokCTA = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const logoIn = spring({ fps, frame, config: SPRING_CFG });
|
||||
const glow = interpolate(frame % 60, [0, 30, 60], [0.32, 0.7, 0.32]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ alignItems: "center", justifyContent: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: 520,
|
||||
height: 520,
|
||||
borderRadius: 999,
|
||||
background: `radial-gradient(circle, rgba(14,99,246,${glow}) 0%, rgba(14,99,246,0) 68%)`,
|
||||
filter: "blur(12px)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
opacity: logoIn,
|
||||
transform: `scale(${0.9 + logoIn * 0.1}) translateY(${(1 - logoIn) * 50}px)`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Img src={staticFile("squaremcp-logo.svg")} style={{ width: 240, height: 240 }} />
|
||||
<div
|
||||
style={{
|
||||
marginTop: 26,
|
||||
fontFamily: FONT,
|
||||
fontSize: 82,
|
||||
fontWeight: 800,
|
||||
color: COLORS.text,
|
||||
letterSpacing: -2.2,
|
||||
}}
|
||||
>
|
||||
SquareMCP
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
fontFamily: FONT,
|
||||
fontSize: 34,
|
||||
fontWeight: 600,
|
||||
color: COLORS.accentLight,
|
||||
letterSpacing: -0.6,
|
||||
}}
|
||||
>
|
||||
squaremcp.com
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 22,
|
||||
fontFamily: FONT,
|
||||
fontSize: 24,
|
||||
color: COLORS.textSecondary,
|
||||
}}
|
||||
>
|
||||
Early access now open
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
124
videos/remotion-demo/src/scenes/tiktok/TikTokDemo.tsx
Normal file
124
videos/remotion-demo/src/scenes/tiktok/TikTokDemo.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { COLORS, FONT, SPRING_CFG } from "../../styles";
|
||||
|
||||
export const TikTokDemo = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const chatIn = spring({ fps, frame, config: SPRING_CFG });
|
||||
const jsonIn = spring({ fps, frame: Math.max(0, frame - 30), config: SPRING_CFG });
|
||||
const chipsIn = spring({ fps, frame: Math.max(0, frame - 70), config: SPRING_CFG });
|
||||
const pulse = interpolate(frame % 45, [0, 22, 45], [0.75, 1, 0.75]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ padding: 58 }}>
|
||||
<div style={{ fontFamily: FONT, color: COLORS.text, fontSize: 68, fontWeight: 800, lineHeight: 1, marginTop: 48 }}>
|
||||
One prompt.
|
||||
<br />
|
||||
Real execution.
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 42,
|
||||
borderRadius: 36,
|
||||
border: `1px solid ${COLORS.borderBlue}`,
|
||||
background: "rgba(9, 12, 22, 0.94)",
|
||||
padding: 30,
|
||||
opacity: chatIn,
|
||||
transform: `translateY(${(1 - chatIn) * 60}px)`,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontFamily: FONT, color: COLORS.textSecondary, fontSize: 22, marginBottom: 18 }}>Chat</div>
|
||||
<div
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
maxWidth: 760,
|
||||
borderRadius: 28,
|
||||
background: "linear-gradient(135deg, rgba(14,99,246,0.95), rgba(125,182,255,0.9))",
|
||||
padding: "24px 26px",
|
||||
color: "#08111f",
|
||||
fontFamily: FONT,
|
||||
fontSize: 34,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.25,
|
||||
}}
|
||||
>
|
||||
Post my launch video to LinkedIn, X, and Instagram.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 28,
|
||||
borderRadius: 36,
|
||||
border: "1px solid rgba(34, 197, 94, 0.24)",
|
||||
background: "rgba(5, 14, 10, 0.9)",
|
||||
padding: 30,
|
||||
opacity: jsonIn,
|
||||
transform: `translateY(${(1 - jsonIn) * 60}px)`,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", color: "#86efac", fontSize: 22, marginBottom: 18 }}>
|
||||
response.json
|
||||
</div>
|
||||
{[
|
||||
'{',
|
||||
' "status": "success",',
|
||||
' "published": ["linkedin", "x", "instagram"],',
|
||||
' "duration_seconds": 12,',
|
||||
' "urls": 3',
|
||||
'}',
|
||||
].map((line) => (
|
||||
<div
|
||||
key={line}
|
||||
style={{
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
||||
color: line.includes("success") ? "#86efac" : "#dbeafe",
|
||||
fontSize: 24,
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 28,
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr",
|
||||
gap: 16,
|
||||
opacity: chipsIn,
|
||||
transform: `scale(${0.96 + chipsIn * 0.04})`,
|
||||
}}
|
||||
>
|
||||
{["linkedin.com/post/7459...", "x.com/squaremcp/status/...", "instagram.com/reel/C..."].map((url) => (
|
||||
<div
|
||||
key={url}
|
||||
style={{
|
||||
borderRadius: 22,
|
||||
padding: "18px 22px",
|
||||
background: "rgba(255,255,255,0.06)",
|
||||
border: `1px solid ${COLORS.borderBlue}`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
boxShadow: `0 0 24px rgba(34, 197, 94, ${0.12 * pulse})`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 999,
|
||||
background: "#22c55e",
|
||||
}}
|
||||
/>
|
||||
<div style={{ fontFamily: FONT, color: COLORS.text, fontSize: 24, fontWeight: 600 }}>{url}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
113
videos/remotion-demo/src/scenes/tiktok/TikTokHook.tsx
Normal file
113
videos/remotion-demo/src/scenes/tiktok/TikTokHook.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { COLORS, FONT, SPRING_CFG } from "../../styles";
|
||||
|
||||
const TOOL_LABELS = [
|
||||
"Email",
|
||||
"Notes",
|
||||
"CRM",
|
||||
"LinkedIn",
|
||||
"X",
|
||||
"WhatsApp",
|
||||
"Docs",
|
||||
"Sheets",
|
||||
"Tickets",
|
||||
"Calendar",
|
||||
"DB",
|
||||
"API",
|
||||
];
|
||||
|
||||
export const TikTokHook = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const terminalIn = spring({ fps, frame, config: SPRING_CFG });
|
||||
const gridIn = spring({ fps, frame: Math.max(0, frame - 18), config: SPRING_CFG });
|
||||
const captionIn = interpolate(frame, [20, 34], [0, 1], { extrapolateRight: "clamp" });
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ padding: 72 }}>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 120,
|
||||
borderRadius: 32,
|
||||
border: `1px solid ${COLORS.borderBlue}`,
|
||||
background: "rgba(7, 8, 18, 0.92)",
|
||||
boxShadow: `0 20px 60px ${COLORS.accentGlow}`,
|
||||
padding: "28px 32px",
|
||||
opacity: terminalIn,
|
||||
transform: `translateY(${(1 - terminalIn) * 80}px)`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", gap: 12, marginBottom: 26 }}>
|
||||
{["#ff5f57", "#febc2e", "#28c840"].map((dot) => (
|
||||
<div key={dot} style={{ width: 14, height: 14, borderRadius: 999, background: dot }} />
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
||||
fontWeight: 700,
|
||||
fontSize: 44,
|
||||
color: COLORS.accentLight,
|
||||
letterSpacing: -1.2,
|
||||
}}
|
||||
>
|
||||
{"> connect squaremcp"}
|
||||
<span style={{ color: COLORS.text, opacity: (frame % 20) < 10 ? 1 : 0 }}>|</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 48,
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, 1fr)",
|
||||
gap: 18,
|
||||
opacity: gridIn,
|
||||
transform: `scale(${0.9 + gridIn * 0.1})`,
|
||||
}}
|
||||
>
|
||||
{TOOL_LABELS.map((label, index) => (
|
||||
<div
|
||||
key={label}
|
||||
style={{
|
||||
borderRadius: 24,
|
||||
border: `1px solid ${COLORS.borderBlue}`,
|
||||
background: index % 2 === 0 ? "rgba(18, 18, 31, 0.95)" : "rgba(10, 17, 38, 0.92)",
|
||||
padding: "26px 16px",
|
||||
minHeight: 112,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: COLORS.text,
|
||||
fontFamily: FONT,
|
||||
fontWeight: 700,
|
||||
fontSize: 28,
|
||||
boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.03)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: "auto",
|
||||
marginBottom: 68,
|
||||
fontFamily: FONT,
|
||||
fontSize: 54,
|
||||
fontWeight: 800,
|
||||
color: COLORS.text,
|
||||
lineHeight: 1.05,
|
||||
letterSpacing: -1.8,
|
||||
opacity: captionIn,
|
||||
transform: `translateY(${(1 - captionIn) * 40}px)`,
|
||||
}}
|
||||
>
|
||||
One API key.
|
||||
<br />
|
||||
One workflow layer.
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
131
videos/remotion-demo/src/scenes/tiktok/TikTokProblem.tsx
Normal file
131
videos/remotion-demo/src/scenes/tiktok/TikTokProblem.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { COLORS, FONT, SPRING_CFG } from "../../styles";
|
||||
|
||||
const LEFT_APPS = ["Email", "LinkedIn", "Slack", "Notes", "Sheets", "Docs"];
|
||||
|
||||
export const TikTokProblem = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const leftIn = spring({ fps, frame, config: SPRING_CFG });
|
||||
const rightIn = spring({ fps, frame: Math.max(0, frame - 12), config: SPRING_CFG });
|
||||
const divider = interpolate(frame, [4, 24], [0.25, 1], { extrapolateRight: "clamp" });
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ padding: 60, flexDirection: "column" }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: FONT,
|
||||
fontSize: 70,
|
||||
fontWeight: 800,
|
||||
color: COLORS.text,
|
||||
letterSpacing: -2,
|
||||
lineHeight: 1,
|
||||
marginTop: 44,
|
||||
marginBottom: 42,
|
||||
}}
|
||||
>
|
||||
8 tabs vs
|
||||
<br />
|
||||
1 gateway
|
||||
</div>
|
||||
<div style={{ display: "flex", flex: 1, gap: 28 }}>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
opacity: leftIn,
|
||||
transform: `translateX(${(1 - leftIn) * -80}px) rotate(${-4 + (1 - leftIn) * -8}deg)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 32,
|
||||
border: `1px solid rgba(239, 68, 68, 0.4)`,
|
||||
background: "rgba(35, 10, 16, 0.88)",
|
||||
padding: 28,
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontFamily: FONT, color: "#fca5a5", fontSize: 30, fontWeight: 700, marginBottom: 20 }}>
|
||||
Before
|
||||
</div>
|
||||
<div style={{ display: "grid", gap: 16 }}>
|
||||
{LEFT_APPS.map((app, index) => (
|
||||
<div
|
||||
key={app}
|
||||
style={{
|
||||
borderRadius: 20,
|
||||
padding: "18px 20px",
|
||||
background: `rgba(255,255,255,${0.06 + index * 0.01})`,
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
fontFamily: FONT,
|
||||
color: COLORS.text,
|
||||
fontSize: 28,
|
||||
fontWeight: 600,
|
||||
transform: `translateX(${index % 2 === 0 ? -12 : 18}px)`,
|
||||
}}
|
||||
>
|
||||
{app}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: 6,
|
||||
borderRadius: 999,
|
||||
background: `linear-gradient(180deg, rgba(255,255,255,0.05), rgba(14, 99, 246, ${divider}), rgba(255,255,255,0.05))`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
opacity: rightIn,
|
||||
transform: `translateX(${(1 - rightIn) * 80}px)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 32,
|
||||
border: `1px solid ${COLORS.borderBlue}`,
|
||||
background: "rgba(9, 16, 36, 0.92)",
|
||||
padding: 28,
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
gap: 20,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontFamily: FONT, color: COLORS.accentLight, fontSize: 30, fontWeight: 700 }}>
|
||||
After
|
||||
</div>
|
||||
{["AI agent", "SquareMCP", "Connected tools"].map((item, index) => (
|
||||
<div
|
||||
key={item}
|
||||
style={{
|
||||
borderRadius: 22,
|
||||
padding: "24px 22px",
|
||||
background: index === 1 ? "rgba(14, 99, 246, 0.22)" : "rgba(255,255,255,0.05)",
|
||||
border: `1px solid ${index === 1 ? COLORS.borderBlue : "rgba(255,255,255,0.08)"}`,
|
||||
fontFamily: FONT,
|
||||
color: COLORS.text,
|
||||
fontSize: 30,
|
||||
fontWeight: index === 1 ? 800 : 600,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
<div style={{ fontFamily: FONT, color: COLORS.textSecondary, fontSize: 24, lineHeight: 1.35 }}>
|
||||
One entry point for auth, permissions, posting, notes, and internal systems.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
58
videos/remotion-demo/src/scenes/tiktok/TikTokProof.tsx
Normal file
58
videos/remotion-demo/src/scenes/tiktok/TikTokProof.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { AbsoluteFill, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { COLORS, FONT, SPRING_CFG } from "../../styles";
|
||||
|
||||
const CHANNELS = [
|
||||
{ name: "LinkedIn", time: "12:04:03", tint: "rgba(10, 102, 194, 0.28)" },
|
||||
{ name: "X", time: "12:04:05", tint: "rgba(255, 255, 255, 0.08)" },
|
||||
{ name: "Instagram", time: "12:04:07", tint: "rgba(225, 48, 108, 0.24)" },
|
||||
{ name: "TikTok", time: "12:04:09", tint: "rgba(37, 244, 238, 0.18)" },
|
||||
];
|
||||
|
||||
export const TikTokProof = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const inView = spring({ fps, frame, config: SPRING_CFG });
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ padding: 60 }}>
|
||||
<div style={{ fontFamily: FONT, fontSize: 62, fontWeight: 800, color: COLORS.text, letterSpacing: -1.8, marginTop: 50 }}>
|
||||
Publish once.
|
||||
<br />
|
||||
Watch it land.
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 52,
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: 22,
|
||||
opacity: inView,
|
||||
transform: `scale(${0.92 + inView * 0.08})`,
|
||||
}}
|
||||
>
|
||||
{CHANNELS.map((channel, index) => (
|
||||
<div
|
||||
key={channel.name}
|
||||
style={{
|
||||
borderRadius: 30,
|
||||
border: `1px solid ${COLORS.borderBlue}`,
|
||||
background: channel.tint,
|
||||
minHeight: 260,
|
||||
padding: 24,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
transform: `translateY(${index % 2 === 0 ? -8 : 12}px)`,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontFamily: FONT, color: COLORS.text, fontSize: 30, fontWeight: 700 }}>{channel.name}</div>
|
||||
<div style={{ fontFamily: FONT, color: "#86efac", fontSize: 26, fontWeight: 800 }}>LIVE</div>
|
||||
<div style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", color: COLORS.textSecondary, fontSize: 22 }}>
|
||||
{channel.time}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user