Spaces:
Paused
Paused
Deploy emergent2api
Browse files- README.md +0 -4
- emergent2api/app.py +32 -114
- emergent2api/config.py +1 -0
- emergent2api/database.py +56 -0
- emergent2api/routes/admin.py +346 -0
- emergent2api/static/admin/config.html +112 -0
- emergent2api/static/admin/docs.html +145 -0
- emergent2api/static/admin/login.html +40 -0
- emergent2api/static/admin/token.html +204 -0
README.md
CHANGED
|
@@ -7,7 +7,3 @@ sdk: docker
|
|
| 7 |
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
-
|
| 11 |
-
# Emergent2API v0.2.0
|
| 12 |
-
|
| 13 |
-
OpenAI/Anthropic-compatible API gateway backed by Emergent.sh account pool.
|
|
|
|
| 7 |
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
---
|
|
|
|
|
|
|
|
|
|
|
|
emergent2api/app.py
CHANGED
|
@@ -4,15 +4,17 @@ from __future__ import annotations
|
|
| 4 |
import json
|
| 5 |
import logging
|
| 6 |
import os
|
|
|
|
| 7 |
|
| 8 |
from fastapi import FastAPI, Request
|
| 9 |
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
-
from fastapi.responses import JSONResponse
|
| 11 |
|
| 12 |
from .config import settings
|
| 13 |
from . import database as db
|
| 14 |
from .pool import pool
|
| 15 |
from .routes import openai, anthropic, responses
|
|
|
|
| 16 |
|
| 17 |
logging.basicConfig(
|
| 18 |
level=logging.INFO,
|
|
@@ -43,11 +45,16 @@ app.add_middleware(
|
|
| 43 |
async def auth_middleware(request: Request, call_next):
|
| 44 |
path = request.url.path
|
| 45 |
|
| 46 |
-
# Skip auth for health
|
| 47 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
return await call_next(request)
|
| 49 |
|
| 50 |
-
# Check API key
|
| 51 |
auth_header = request.headers.get("authorization", "")
|
| 52 |
api_key = request.headers.get("x-api-key", "")
|
| 53 |
|
|
@@ -94,6 +101,9 @@ async def shutdown():
|
|
| 94 |
app.include_router(openai.router)
|
| 95 |
app.include_router(anthropic.router)
|
| 96 |
app.include_router(responses.router)
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
|
| 99 |
@app.get("/")
|
|
@@ -101,7 +111,7 @@ async def root():
|
|
| 101 |
count = await pool.count()
|
| 102 |
return {
|
| 103 |
"service": "Emergent2API",
|
| 104 |
-
"version": "0.
|
| 105 |
"backend": settings.backend,
|
| 106 |
"accounts": count,
|
| 107 |
"endpoints": {
|
|
@@ -111,87 +121,35 @@ async def root():
|
|
| 111 |
"POST /v1/responses (OpenAI Response API)",
|
| 112 |
"GET /v1/models",
|
| 113 |
],
|
| 114 |
-
"admin":
|
| 115 |
-
"GET /admin/accounts",
|
| 116 |
-
"POST /admin/accounts",
|
| 117 |
-
"POST /admin/accounts/import",
|
| 118 |
-
"POST /admin/accounts/import-jsonl",
|
| 119 |
-
"DELETE /admin/accounts/{id}",
|
| 120 |
-
"POST /admin/accounts/{id}/toggle",
|
| 121 |
-
"POST /admin/accounts/{id}/refresh",
|
| 122 |
-
"POST /admin/accounts/refresh-all",
|
| 123 |
-
],
|
| 124 |
},
|
| 125 |
}
|
| 126 |
|
| 127 |
|
| 128 |
-
@app.get("/
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
return
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
# ---------------------------------------------------------------------------
|
| 135 |
-
# Admin endpoints
|
| 136 |
-
# ---------------------------------------------------------------------------
|
| 137 |
-
|
| 138 |
-
@app.get("/admin/accounts")
|
| 139 |
-
async def admin_list_accounts(active_only: bool = False):
|
| 140 |
-
"""List all accounts or only active ones."""
|
| 141 |
-
if active_only:
|
| 142 |
-
accounts = await db.get_active_accounts()
|
| 143 |
-
else:
|
| 144 |
-
accounts = await db.get_all_accounts()
|
| 145 |
-
return {
|
| 146 |
-
"total": len(accounts),
|
| 147 |
-
"accounts": [
|
| 148 |
-
{
|
| 149 |
-
"id": a["id"],
|
| 150 |
-
"email": a["email"],
|
| 151 |
-
"balance": a["balance"],
|
| 152 |
-
"is_active": a["is_active"],
|
| 153 |
-
"last_used": a["last_used"].isoformat() if a["last_used"] else None,
|
| 154 |
-
"created_at": a["created_at"].isoformat() if a["created_at"] else None,
|
| 155 |
-
}
|
| 156 |
-
for a in accounts
|
| 157 |
-
],
|
| 158 |
-
}
|
| 159 |
|
| 160 |
|
| 161 |
-
@app.
|
| 162 |
-
async def
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
if f not in body:
|
| 168 |
-
return JSONResponse(status_code=400, content={"error": f"Missing required field: {f}"})
|
| 169 |
-
account_id = await db.upsert_account(body)
|
| 170 |
-
return {"id": account_id, "email": body["email"], "status": "added"}
|
| 171 |
|
| 172 |
|
| 173 |
-
@app.
|
| 174 |
-
async def
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
accounts = body.get("accounts", [])
|
| 178 |
-
imported = 0
|
| 179 |
-
errors = []
|
| 180 |
-
for acct in accounts:
|
| 181 |
-
if "email" in acct and "password" in acct and "jwt" in acct:
|
| 182 |
-
try:
|
| 183 |
-
await db.upsert_account(acct)
|
| 184 |
-
imported += 1
|
| 185 |
-
except Exception as e:
|
| 186 |
-
errors.append({"email": acct.get("email"), "error": str(e)})
|
| 187 |
-
else:
|
| 188 |
-
errors.append({"email": acct.get("email", "?"), "error": "missing email/password/jwt"})
|
| 189 |
-
return {"imported": imported, "total_submitted": len(accounts), "errors": errors}
|
| 190 |
|
| 191 |
|
|
|
|
| 192 |
@app.post("/admin/accounts/import-jsonl")
|
| 193 |
async def admin_import_jsonl(request: Request):
|
| 194 |
-
"""Import accounts from JSONL text. Body: {jsonl: "
|
| 195 |
content_type = request.headers.get("content-type", "")
|
| 196 |
if "application/json" in content_type:
|
| 197 |
body = await request.json()
|
|
@@ -220,46 +178,6 @@ async def admin_import_jsonl(request: Request):
|
|
| 220 |
return {"imported": imported, "total_lines": len(lines), "errors": errors}
|
| 221 |
|
| 222 |
|
| 223 |
-
@app.delete("/admin/accounts/{account_id}")
|
| 224 |
-
async def admin_delete_account(account_id: int):
|
| 225 |
-
"""Permanently delete an account."""
|
| 226 |
-
await db.delete_account(account_id)
|
| 227 |
-
return {"id": account_id, "status": "deleted"}
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
@app.post("/admin/accounts/{account_id}/toggle")
|
| 231 |
-
async def admin_toggle_account(account_id: int):
|
| 232 |
-
"""Toggle active/inactive status."""
|
| 233 |
-
new_state = await db.toggle_account(account_id)
|
| 234 |
-
return {"id": account_id, "is_active": new_state}
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
@app.post("/admin/accounts/{account_id}/refresh")
|
| 238 |
-
async def admin_refresh_jwt(account_id: int):
|
| 239 |
-
"""Refresh JWT for a specific account."""
|
| 240 |
-
account = await db.get_account_by_id(account_id)
|
| 241 |
-
if not account:
|
| 242 |
-
return JSONResponse(status_code=404, content={"error": "Account not found"})
|
| 243 |
-
new_jwt = await pool.refresh_jwt(account)
|
| 244 |
-
if new_jwt:
|
| 245 |
-
return {"id": account_id, "email": account["email"], "status": "refreshed"}
|
| 246 |
-
return JSONResponse(status_code=500, content={"error": "JWT refresh failed"})
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
@app.post("/admin/accounts/refresh-all")
|
| 250 |
-
async def admin_refresh_all():
|
| 251 |
-
"""Refresh JWTs for all active accounts."""
|
| 252 |
-
accounts = await db.get_active_accounts()
|
| 253 |
-
results = {"refreshed": 0, "failed": 0, "total": len(accounts)}
|
| 254 |
-
for acct in accounts:
|
| 255 |
-
new_jwt = await pool.refresh_jwt(acct)
|
| 256 |
-
if new_jwt:
|
| 257 |
-
results["refreshed"] += 1
|
| 258 |
-
else:
|
| 259 |
-
results["failed"] += 1
|
| 260 |
-
return results
|
| 261 |
-
|
| 262 |
-
|
| 263 |
# ---------------------------------------------------------------------------
|
| 264 |
# Entrypoint
|
| 265 |
# ---------------------------------------------------------------------------
|
|
|
|
| 4 |
import json
|
| 5 |
import logging
|
| 6 |
import os
|
| 7 |
+
import pathlib
|
| 8 |
|
| 9 |
from fastapi import FastAPI, Request
|
| 10 |
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
+
from fastapi.responses import FileResponse, JSONResponse
|
| 12 |
|
| 13 |
from .config import settings
|
| 14 |
from . import database as db
|
| 15 |
from .pool import pool
|
| 16 |
from .routes import openai, anthropic, responses
|
| 17 |
+
from .routes.admin import router as admin_router
|
| 18 |
|
| 19 |
logging.basicConfig(
|
| 20 |
level=logging.INFO,
|
|
|
|
| 45 |
async def auth_middleware(request: Request, call_next):
|
| 46 |
path = request.url.path
|
| 47 |
|
| 48 |
+
# Skip auth for health, docs, admin panel pages, and admin API (has its own cookie auth)
|
| 49 |
+
if (
|
| 50 |
+
path in ("/", "/health", "/docs", "/openapi.json", "/redoc")
|
| 51 |
+
or path.startswith("/admin")
|
| 52 |
+
or path.startswith("/v1/admin")
|
| 53 |
+
or path.startswith("/static")
|
| 54 |
+
):
|
| 55 |
return await call_next(request)
|
| 56 |
|
| 57 |
+
# Check API key for /v1/* API routes
|
| 58 |
auth_header = request.headers.get("authorization", "")
|
| 59 |
api_key = request.headers.get("x-api-key", "")
|
| 60 |
|
|
|
|
| 101 |
app.include_router(openai.router)
|
| 102 |
app.include_router(anthropic.router)
|
| 103 |
app.include_router(responses.router)
|
| 104 |
+
app.include_router(admin_router)
|
| 105 |
+
|
| 106 |
+
_STATIC_DIR = pathlib.Path(__file__).parent / "static" / "admin"
|
| 107 |
|
| 108 |
|
| 109 |
@app.get("/")
|
|
|
|
| 111 |
count = await pool.count()
|
| 112 |
return {
|
| 113 |
"service": "Emergent2API",
|
| 114 |
+
"version": "0.3.0",
|
| 115 |
"backend": settings.backend,
|
| 116 |
"accounts": count,
|
| 117 |
"endpoints": {
|
|
|
|
| 121 |
"POST /v1/responses (OpenAI Response API)",
|
| 122 |
"GET /v1/models",
|
| 123 |
],
|
| 124 |
+
"admin": "/admin/",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
},
|
| 126 |
}
|
| 127 |
|
| 128 |
|
| 129 |
+
@app.get("/admin")
|
| 130 |
+
@app.get("/admin/")
|
| 131 |
+
async def admin_index():
|
| 132 |
+
return FileResponse(_STATIC_DIR / "login.html")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
|
| 134 |
|
| 135 |
+
@app.get("/admin/{page}")
|
| 136 |
+
async def admin_page(page: str):
|
| 137 |
+
html = _STATIC_DIR / f"{page}.html"
|
| 138 |
+
if html.is_file():
|
| 139 |
+
return FileResponse(html)
|
| 140 |
+
return FileResponse(_STATIC_DIR / "login.html")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
|
| 142 |
|
| 143 |
+
@app.get("/health")
|
| 144 |
+
async def health():
|
| 145 |
+
count = await pool.count()
|
| 146 |
+
return {"status": "ok", "accounts": count}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
|
| 148 |
|
| 149 |
+
# Legacy import endpoint (backward compat for CI push)
|
| 150 |
@app.post("/admin/accounts/import-jsonl")
|
| 151 |
async def admin_import_jsonl(request: Request):
|
| 152 |
+
"""Import accounts from JSONL text. Body: {jsonl: "..."} or raw JSONL body."""
|
| 153 |
content_type = request.headers.get("content-type", "")
|
| 154 |
if "application/json" in content_type:
|
| 155 |
body = await request.json()
|
|
|
|
| 178 |
return {"imported": imported, "total_lines": len(lines), "errors": errors}
|
| 179 |
|
| 180 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
# ---------------------------------------------------------------------------
|
| 182 |
# Entrypoint
|
| 183 |
# ---------------------------------------------------------------------------
|
emergent2api/config.py
CHANGED
|
@@ -17,6 +17,7 @@ class Settings:
|
|
| 17 |
"postgresql://neondb_owner:npg_cyGIuK58kePT@ep-muddy-sky-adych24h-pooler.c-2.us-east-1.aws.neon.tech/neondb?sslmode=require",
|
| 18 |
))
|
| 19 |
port: int = field(default_factory=lambda: int(os.environ.get("PORT", "8000")))
|
|
|
|
| 20 |
|
| 21 |
# Supabase constants
|
| 22 |
supabase_anon_key: str = (
|
|
|
|
| 17 |
"postgresql://neondb_owner:npg_cyGIuK58kePT@ep-muddy-sky-adych24h-pooler.c-2.us-east-1.aws.neon.tech/neondb?sslmode=require",
|
| 18 |
))
|
| 19 |
port: int = field(default_factory=lambda: int(os.environ.get("PORT", "8000")))
|
| 20 |
+
admin_password: str = field(default_factory=lambda: os.environ.get("ADMIN_PASSWORD", "bk@3fd3E"))
|
| 21 |
|
| 22 |
# Supabase constants
|
| 23 |
supabase_anon_key: str = (
|
emergent2api/database.py
CHANGED
|
@@ -149,6 +149,62 @@ async def toggle_account(account_id: int) -> bool:
|
|
| 149 |
return row["is_active"] if row else False
|
| 150 |
|
| 151 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
async def get_account_count() -> dict[str, int]:
|
| 153 |
pool = await get_pool()
|
| 154 |
async with pool.acquire() as conn:
|
|
|
|
| 149 |
return row["is_active"] if row else False
|
| 150 |
|
| 151 |
|
| 152 |
+
async def batch_delete(account_ids: list[int]) -> int:
|
| 153 |
+
pool = await get_pool()
|
| 154 |
+
async with pool.acquire() as conn:
|
| 155 |
+
result = await conn.execute(
|
| 156 |
+
"DELETE FROM emergent_accounts WHERE id = ANY($1::int[])", account_ids
|
| 157 |
+
)
|
| 158 |
+
return int(result.split()[-1])
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
async def batch_toggle(account_ids: list[int], active: bool) -> int:
|
| 162 |
+
pool = await get_pool()
|
| 163 |
+
async with pool.acquire() as conn:
|
| 164 |
+
result = await conn.execute(
|
| 165 |
+
"UPDATE emergent_accounts SET is_active = $2, updated_at = NOW() WHERE id = ANY($1::int[])",
|
| 166 |
+
account_ids, active,
|
| 167 |
+
)
|
| 168 |
+
return int(result.split()[-1])
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
async def delete_inactive() -> int:
|
| 172 |
+
pool = await get_pool()
|
| 173 |
+
async with pool.acquire() as conn:
|
| 174 |
+
result = await conn.execute("DELETE FROM emergent_accounts WHERE is_active = FALSE")
|
| 175 |
+
return int(result.split()[-1])
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
# ---------------------------------------------------------------------------
|
| 179 |
+
# Config KV store
|
| 180 |
+
# ---------------------------------------------------------------------------
|
| 181 |
+
|
| 182 |
+
async def get_config(key: str) -> Optional[str]:
|
| 183 |
+
pool = await get_pool()
|
| 184 |
+
async with pool.acquire() as conn:
|
| 185 |
+
return await conn.fetchval(
|
| 186 |
+
"SELECT value FROM emergent_config WHERE key = $1", key
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
async def set_config(key: str, value: str) -> None:
|
| 191 |
+
pool = await get_pool()
|
| 192 |
+
async with pool.acquire() as conn:
|
| 193 |
+
await conn.execute(
|
| 194 |
+
"""INSERT INTO emergent_config (key, value, updated_at)
|
| 195 |
+
VALUES ($1, $2, NOW())
|
| 196 |
+
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()""",
|
| 197 |
+
key, value,
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
async def get_all_config() -> dict[str, str]:
|
| 202 |
+
pool = await get_pool()
|
| 203 |
+
async with pool.acquire() as conn:
|
| 204 |
+
rows = await conn.fetch("SELECT key, value FROM emergent_config ORDER BY key")
|
| 205 |
+
return {r["key"]: r["value"] for r in rows}
|
| 206 |
+
|
| 207 |
+
|
| 208 |
async def get_account_count() -> dict[str, int]:
|
| 209 |
pool = await get_pool()
|
| 210 |
async with pool.acquire() as conn:
|
emergent2api/routes/admin.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Admin API routes: login, token CRUD, import/export, connectivity test, config."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import io
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
import secrets
|
| 8 |
+
import zipfile
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
from curl_cffi import requests as cffi_requests
|
| 12 |
+
from fastapi import APIRouter, Request, Response
|
| 13 |
+
from fastapi.responses import JSONResponse, StreamingResponse
|
| 14 |
+
|
| 15 |
+
from ..config import settings
|
| 16 |
+
from .. import database as db
|
| 17 |
+
from ..pool import pool
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger("emergent2api.admin")
|
| 20 |
+
|
| 21 |
+
router = APIRouter(prefix="/v1/admin", tags=["admin"])
|
| 22 |
+
|
| 23 |
+
COOKIE_NAME = "emergent_admin_session"
|
| 24 |
+
_sessions: set[str] = set()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _check_session(request: Request) -> bool:
|
| 28 |
+
token = request.cookies.get(COOKIE_NAME, "")
|
| 29 |
+
return token in _sessions and bool(token)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _require_auth(request: Request) -> JSONResponse | None:
|
| 33 |
+
if not _check_session(request):
|
| 34 |
+
return JSONResponse(status_code=401, content={"error": "Not authenticated"})
|
| 35 |
+
return None
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ---------------------------------------------------------------------------
|
| 39 |
+
# Auth
|
| 40 |
+
# ---------------------------------------------------------------------------
|
| 41 |
+
|
| 42 |
+
@router.post("/login")
|
| 43 |
+
async def login(request: Request):
|
| 44 |
+
body = await request.json()
|
| 45 |
+
pwd = body.get("password", "")
|
| 46 |
+
db_pwd = await db.get_config("admin_password")
|
| 47 |
+
expected = db_pwd or settings.admin_password
|
| 48 |
+
if pwd != expected:
|
| 49 |
+
return JSONResponse(status_code=403, content={"error": "Invalid password"})
|
| 50 |
+
token = secrets.token_urlsafe(32)
|
| 51 |
+
_sessions.add(token)
|
| 52 |
+
resp = JSONResponse(content={"ok": True})
|
| 53 |
+
resp.set_cookie(COOKIE_NAME, token, httponly=True, samesite="lax", max_age=86400 * 7)
|
| 54 |
+
return resp
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@router.post("/logout")
|
| 58 |
+
async def logout(request: Request):
|
| 59 |
+
token = request.cookies.get(COOKIE_NAME, "")
|
| 60 |
+
_sessions.discard(token)
|
| 61 |
+
resp = JSONResponse(content={"ok": True})
|
| 62 |
+
resp.delete_cookie(COOKIE_NAME)
|
| 63 |
+
return resp
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@router.get("/verify")
|
| 67 |
+
async def verify(request: Request):
|
| 68 |
+
if _check_session(request):
|
| 69 |
+
return {"authenticated": True}
|
| 70 |
+
return JSONResponse(status_code=401, content={"authenticated": False})
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# ---------------------------------------------------------------------------
|
| 74 |
+
# Token (account) management
|
| 75 |
+
# ---------------------------------------------------------------------------
|
| 76 |
+
|
| 77 |
+
@router.get("/tokens")
|
| 78 |
+
async def list_tokens(request: Request):
|
| 79 |
+
err = _require_auth(request)
|
| 80 |
+
if err:
|
| 81 |
+
return err
|
| 82 |
+
accounts = await db.get_all_accounts()
|
| 83 |
+
return {
|
| 84 |
+
"total": len(accounts),
|
| 85 |
+
"active": sum(1 for a in accounts if a["is_active"]),
|
| 86 |
+
"tokens": [
|
| 87 |
+
{
|
| 88 |
+
"id": a["id"],
|
| 89 |
+
"email": a["email"],
|
| 90 |
+
"balance": a["balance"],
|
| 91 |
+
"is_active": a["is_active"],
|
| 92 |
+
"last_used": a["last_used"].isoformat() if a["last_used"] else None,
|
| 93 |
+
"created_at": a["created_at"].isoformat() if a["created_at"] else None,
|
| 94 |
+
}
|
| 95 |
+
for a in accounts
|
| 96 |
+
],
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@router.post("/tokens/import")
|
| 101 |
+
async def import_tokens(request: Request):
|
| 102 |
+
"""Import accounts from JSONL text: {jsonl: "..."}"""
|
| 103 |
+
err = _require_auth(request)
|
| 104 |
+
if err:
|
| 105 |
+
return err
|
| 106 |
+
body = await request.json()
|
| 107 |
+
raw = body.get("jsonl", "")
|
| 108 |
+
lines = raw.strip().splitlines()
|
| 109 |
+
imported, errors = 0, []
|
| 110 |
+
for i, line in enumerate(lines):
|
| 111 |
+
line = line.strip()
|
| 112 |
+
if not line:
|
| 113 |
+
continue
|
| 114 |
+
try:
|
| 115 |
+
acct = json.loads(line)
|
| 116 |
+
if "email" in acct and "password" in acct and "jwt" in acct:
|
| 117 |
+
await db.upsert_account(acct)
|
| 118 |
+
imported += 1
|
| 119 |
+
else:
|
| 120 |
+
errors.append({"line": i + 1, "error": "missing required fields"})
|
| 121 |
+
except Exception as e:
|
| 122 |
+
errors.append({"line": i + 1, "error": str(e)})
|
| 123 |
+
return {"imported": imported, "errors": errors}
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
@router.post("/tokens/import-zip")
|
| 127 |
+
async def import_zip(request: Request):
|
| 128 |
+
"""Import from zip containing accounts.jsonl or individual .json files."""
|
| 129 |
+
err = _require_auth(request)
|
| 130 |
+
if err:
|
| 131 |
+
return err
|
| 132 |
+
body = await request.body()
|
| 133 |
+
imported, errors = 0, []
|
| 134 |
+
try:
|
| 135 |
+
with zipfile.ZipFile(io.BytesIO(body)) as zf:
|
| 136 |
+
for name in zf.namelist():
|
| 137 |
+
raw = zf.read(name).decode("utf-8", errors="replace")
|
| 138 |
+
if name.endswith(".jsonl") or name == "accounts.jsonl":
|
| 139 |
+
for line in raw.strip().splitlines():
|
| 140 |
+
line = line.strip()
|
| 141 |
+
if not line:
|
| 142 |
+
continue
|
| 143 |
+
try:
|
| 144 |
+
acct = json.loads(line)
|
| 145 |
+
if "email" in acct and "jwt" in acct:
|
| 146 |
+
await db.upsert_account(acct)
|
| 147 |
+
imported += 1
|
| 148 |
+
except Exception as e:
|
| 149 |
+
errors.append({"file": name, "error": str(e)})
|
| 150 |
+
elif name.endswith(".json"):
|
| 151 |
+
try:
|
| 152 |
+
acct = json.loads(raw)
|
| 153 |
+
if "email" in acct and "jwt" in acct:
|
| 154 |
+
await db.upsert_account(acct)
|
| 155 |
+
imported += 1
|
| 156 |
+
except Exception as e:
|
| 157 |
+
errors.append({"file": name, "error": str(e)})
|
| 158 |
+
except Exception as e:
|
| 159 |
+
return JSONResponse(status_code=400, content={"error": f"Invalid zip: {e}"})
|
| 160 |
+
return {"imported": imported, "errors": errors}
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@router.get("/tokens/export")
|
| 164 |
+
async def export_tokens(request: Request):
|
| 165 |
+
"""Download all accounts as a zip (accounts.jsonl + tokens.txt)."""
|
| 166 |
+
err = _require_auth(request)
|
| 167 |
+
if err:
|
| 168 |
+
return err
|
| 169 |
+
accounts = await db.get_all_accounts()
|
| 170 |
+
buf = io.BytesIO()
|
| 171 |
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
| 172 |
+
jsonl_lines = []
|
| 173 |
+
jwt_lines = []
|
| 174 |
+
for a in accounts:
|
| 175 |
+
row = {
|
| 176 |
+
"email": a["email"], "password": a["password"],
|
| 177 |
+
"jwt": a["jwt"], "refresh_token": a.get("refresh_token", ""),
|
| 178 |
+
"user_id": a.get("user_id", ""), "balance": a["balance"],
|
| 179 |
+
"is_active": a["is_active"],
|
| 180 |
+
}
|
| 181 |
+
jsonl_lines.append(json.dumps(row, ensure_ascii=False))
|
| 182 |
+
if a["jwt"]:
|
| 183 |
+
jwt_lines.append(a["jwt"])
|
| 184 |
+
zf.writestr("accounts.jsonl", "\n".join(jsonl_lines) + "\n")
|
| 185 |
+
zf.writestr("tokens.txt", "\n".join(jwt_lines) + "\n")
|
| 186 |
+
buf.seek(0)
|
| 187 |
+
return StreamingResponse(
|
| 188 |
+
buf,
|
| 189 |
+
media_type="application/zip",
|
| 190 |
+
headers={"Content-Disposition": "attachment; filename=emergent_accounts.zip"},
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
@router.post("/tokens/test")
|
| 195 |
+
async def test_tokens(request: Request):
|
| 196 |
+
"""Test connectivity for selected account IDs."""
|
| 197 |
+
err = _require_auth(request)
|
| 198 |
+
if err:
|
| 199 |
+
return err
|
| 200 |
+
body = await request.json()
|
| 201 |
+
ids = body.get("ids", [])
|
| 202 |
+
results = []
|
| 203 |
+
for aid in ids:
|
| 204 |
+
acct = await db.get_account_by_id(aid)
|
| 205 |
+
if not acct:
|
| 206 |
+
results.append({"id": aid, "status": "not_found"})
|
| 207 |
+
continue
|
| 208 |
+
try:
|
| 209 |
+
s = cffi_requests.Session(impersonate="chrome")
|
| 210 |
+
if settings.proxy:
|
| 211 |
+
s.proxies = {"http": settings.proxy, "https": settings.proxy}
|
| 212 |
+
resp = s.post(
|
| 213 |
+
f"{settings.auth_base}/token?grant_type=password",
|
| 214 |
+
json={"email": acct["email"], "password": acct["password"], "gotrue_meta_security": {}},
|
| 215 |
+
headers={
|
| 216 |
+
"Apikey": settings.supabase_anon_key,
|
| 217 |
+
"Authorization": f"Bearer {settings.supabase_anon_key}",
|
| 218 |
+
"X-Client-Info": "supabase-js-web/2.45.4",
|
| 219 |
+
},
|
| 220 |
+
timeout=15,
|
| 221 |
+
)
|
| 222 |
+
if resp.status_code == 200:
|
| 223 |
+
data = resp.json()
|
| 224 |
+
new_jwt = data.get("access_token", "")
|
| 225 |
+
if new_jwt:
|
| 226 |
+
await db.update_jwt(aid, new_jwt, data.get("refresh_token", ""))
|
| 227 |
+
results.append({"id": aid, "email": acct["email"], "status": "ok"})
|
| 228 |
+
else:
|
| 229 |
+
results.append({"id": aid, "email": acct["email"], "status": "failed", "code": resp.status_code})
|
| 230 |
+
except Exception as e:
|
| 231 |
+
results.append({"id": aid, "email": acct["email"], "status": "error", "detail": str(e)})
|
| 232 |
+
return {"results": results}
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
@router.post("/tokens/refresh")
|
| 236 |
+
async def refresh_tokens(request: Request):
|
| 237 |
+
"""Refresh JWT for selected account IDs."""
|
| 238 |
+
err = _require_auth(request)
|
| 239 |
+
if err:
|
| 240 |
+
return err
|
| 241 |
+
body = await request.json()
|
| 242 |
+
ids = body.get("ids", [])
|
| 243 |
+
ok, fail = 0, 0
|
| 244 |
+
for aid in ids:
|
| 245 |
+
acct = await db.get_account_by_id(aid)
|
| 246 |
+
if not acct:
|
| 247 |
+
fail += 1
|
| 248 |
+
continue
|
| 249 |
+
new_jwt = await pool.refresh_jwt(acct)
|
| 250 |
+
if new_jwt:
|
| 251 |
+
ok += 1
|
| 252 |
+
else:
|
| 253 |
+
fail += 1
|
| 254 |
+
return {"refreshed": ok, "failed": fail}
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
@router.post("/tokens/delete")
|
| 258 |
+
async def delete_tokens(request: Request):
|
| 259 |
+
"""Batch delete accounts by IDs."""
|
| 260 |
+
err = _require_auth(request)
|
| 261 |
+
if err:
|
| 262 |
+
return err
|
| 263 |
+
body = await request.json()
|
| 264 |
+
ids = body.get("ids", [])
|
| 265 |
+
if not ids:
|
| 266 |
+
return {"deleted": 0}
|
| 267 |
+
count = await db.batch_delete(ids)
|
| 268 |
+
return {"deleted": count}
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
@router.post("/tokens/toggle")
|
| 272 |
+
async def toggle_tokens(request: Request):
|
| 273 |
+
"""Batch set active status. Body: {ids: [...], active: bool}"""
|
| 274 |
+
err = _require_auth(request)
|
| 275 |
+
if err:
|
| 276 |
+
return err
|
| 277 |
+
body = await request.json()
|
| 278 |
+
ids = body.get("ids", [])
|
| 279 |
+
active = body.get("active", True)
|
| 280 |
+
if not ids:
|
| 281 |
+
return {"updated": 0}
|
| 282 |
+
count = await db.batch_toggle(ids, active)
|
| 283 |
+
return {"updated": count}
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
@router.post("/tokens/delete-inactive")
|
| 287 |
+
async def delete_inactive(request: Request):
|
| 288 |
+
err = _require_auth(request)
|
| 289 |
+
if err:
|
| 290 |
+
return err
|
| 291 |
+
count = await db.delete_inactive()
|
| 292 |
+
return {"deleted": count}
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
# ---------------------------------------------------------------------------
|
| 296 |
+
# Config
|
| 297 |
+
# ---------------------------------------------------------------------------
|
| 298 |
+
|
| 299 |
+
@router.get("/config")
|
| 300 |
+
async def get_config(request: Request):
|
| 301 |
+
err = _require_auth(request)
|
| 302 |
+
if err:
|
| 303 |
+
return err
|
| 304 |
+
db_config = await db.get_all_config()
|
| 305 |
+
return {
|
| 306 |
+
"api_key": db_config.get("api_key", settings.api_key),
|
| 307 |
+
"backend": db_config.get("backend", settings.backend),
|
| 308 |
+
"proxy": db_config.get("proxy", settings.proxy),
|
| 309 |
+
"poll_interval": db_config.get("poll_interval", str(settings.poll_interval)),
|
| 310 |
+
"poll_timeout": db_config.get("poll_timeout", str(settings.poll_timeout)),
|
| 311 |
+
"admin_password": db_config.get("admin_password", settings.admin_password),
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
@router.post("/config")
|
| 316 |
+
async def update_config(request: Request):
|
| 317 |
+
err = _require_auth(request)
|
| 318 |
+
if err:
|
| 319 |
+
return err
|
| 320 |
+
body = await request.json()
|
| 321 |
+
allowed = {"api_key", "backend", "proxy", "poll_interval", "poll_timeout", "admin_password"}
|
| 322 |
+
saved = []
|
| 323 |
+
for key, value in body.items():
|
| 324 |
+
if key in allowed:
|
| 325 |
+
await db.set_config(key, str(value))
|
| 326 |
+
if key == "api_key":
|
| 327 |
+
settings.api_key = str(value)
|
| 328 |
+
elif key == "backend":
|
| 329 |
+
settings.backend = str(value)
|
| 330 |
+
elif key == "proxy":
|
| 331 |
+
settings.proxy = str(value)
|
| 332 |
+
elif key == "admin_password":
|
| 333 |
+
settings.admin_password = str(value)
|
| 334 |
+
saved.append(key)
|
| 335 |
+
return {"saved": saved}
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
@router.post("/config/generate-key")
|
| 339 |
+
async def generate_api_key(request: Request):
|
| 340 |
+
err = _require_auth(request)
|
| 341 |
+
if err:
|
| 342 |
+
return err
|
| 343 |
+
new_key = f"sk-{secrets.token_urlsafe(24)}"
|
| 344 |
+
await db.set_config("api_key", new_key)
|
| 345 |
+
settings.api_key = new_key
|
| 346 |
+
return {"api_key": new_key}
|
emergent2api/static/admin/config.html
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
| 5 |
+
<title>Emergent2API - Config</title>
|
| 6 |
+
<style>
|
| 7 |
+
*{margin:0;padding:0;box-sizing:border-box}
|
| 8 |
+
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f5f5f5;color:#1a1a1a}
|
| 9 |
+
nav{background:#fff;border-bottom:1px solid #e5e5e5;padding:0 24px;display:flex;align-items:center;height:56px;position:sticky;top:0;z-index:10}
|
| 10 |
+
nav .brand{font-weight:700;font-size:18px;margin-right:32px;color:#4f46e5}
|
| 11 |
+
nav a{text-decoration:none;color:#666;padding:16px 12px;font-size:14px;border-bottom:2px solid transparent;transition:all .2s}
|
| 12 |
+
nav a:hover{color:#1a1a1a}nav a.active{color:#4f46e5;border-bottom-color:#4f46e5;font-weight:600}
|
| 13 |
+
nav .spacer{flex:1}
|
| 14 |
+
nav .logout{color:#ef4444;cursor:pointer;font-size:13px}
|
| 15 |
+
.container{max-width:700px;margin:0 auto;padding:28px 24px}
|
| 16 |
+
.card{background:#fff;border-radius:10px;box-shadow:0 1px 4px rgba(0,0,0,.06);padding:28px;margin-bottom:20px}
|
| 17 |
+
.card h2{font-size:17px;margin-bottom:18px;padding-bottom:10px;border-bottom:1px solid #f0f0f0}
|
| 18 |
+
.field{margin-bottom:18px}
|
| 19 |
+
.field label{display:block;font-size:13px;font-weight:600;color:#555;margin-bottom:6px}
|
| 20 |
+
.field input,.field select{width:100%;padding:10px 14px;border:1px solid #ddd;border-radius:6px;font-size:14px;outline:none;transition:border .2s}
|
| 21 |
+
.field input:focus,.field select:focus{border-color:#4f46e5}
|
| 22 |
+
.field .hint{font-size:11px;color:#aaa;margin-top:4px}
|
| 23 |
+
.row{display:flex;gap:10px;align-items:end}
|
| 24 |
+
.row .field{flex:1}
|
| 25 |
+
.btn{padding:10px 20px;border:none;border-radius:6px;font-size:14px;cursor:pointer;font-weight:500;transition:all .2s}
|
| 26 |
+
.btn-primary{background:#4f46e5;color:#fff}.btn-primary:hover{background:#4338ca}
|
| 27 |
+
.btn-outline{background:#fff;color:#333;border:1px solid #ddd}.btn-outline:hover{background:#f3f4f6}
|
| 28 |
+
.actions{display:flex;gap:10px;justify-content:flex-end;margin-top:6px}
|
| 29 |
+
.toast{position:fixed;bottom:24px;right:24px;background:#1a1a1a;color:#fff;padding:12px 20px;border-radius:8px;font-size:13px;z-index:30;opacity:0;transition:opacity .3s}
|
| 30 |
+
.toast.show{opacity:1}
|
| 31 |
+
</style>
|
| 32 |
+
</head>
|
| 33 |
+
<body>
|
| 34 |
+
<nav>
|
| 35 |
+
<span class="brand">Emergent2API</span>
|
| 36 |
+
<a href="/admin/token">Token Management</a>
|
| 37 |
+
<a href="/admin/config" class="active">Config</a>
|
| 38 |
+
<a href="/admin/docs">Usage Docs</a>
|
| 39 |
+
<span class="spacer"></span>
|
| 40 |
+
<span class="logout" onclick="logout()">Logout</span>
|
| 41 |
+
</nav>
|
| 42 |
+
<div class="container">
|
| 43 |
+
<div class="card">
|
| 44 |
+
<h2>API Settings</h2>
|
| 45 |
+
<div class="row">
|
| 46 |
+
<div class="field"><label>API Key</label><input id="api_key" placeholder="sk-..."></div>
|
| 47 |
+
<div style="padding-bottom:18px"><span class="btn btn-outline" onclick="genKey()">Generate</span></div>
|
| 48 |
+
</div>
|
| 49 |
+
<div class="field">
|
| 50 |
+
<label>Backend</label>
|
| 51 |
+
<select id="backend"><option value="jobs">Jobs API (no IP restriction)</option><option value="integrations">Integrations API (faster, needs US proxy)</option></select>
|
| 52 |
+
</div>
|
| 53 |
+
<div class="field"><label>Proxy</label><input id="proxy" placeholder="http://user:pass@host:port"><div class="hint">Required for Integrations backend; optional for Jobs</div></div>
|
| 54 |
+
<div class="row">
|
| 55 |
+
<div class="field"><label>Poll Interval (s)</label><input id="poll_interval" type="number" min="1"></div>
|
| 56 |
+
<div class="field"><label>Poll Timeout (s)</label><input id="poll_timeout" type="number" min="10"></div>
|
| 57 |
+
</div>
|
| 58 |
+
</div>
|
| 59 |
+
<div class="card">
|
| 60 |
+
<h2>Admin Settings</h2>
|
| 61 |
+
<div class="field"><label>Admin Password</label><input id="admin_password" type="password"></div>
|
| 62 |
+
</div>
|
| 63 |
+
<div class="actions">
|
| 64 |
+
<span class="btn btn-outline" onclick="load()">Reset</span>
|
| 65 |
+
<span class="btn btn-primary" onclick="save()">Save Changes</span>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
<div class="toast" id="toast"></div>
|
| 69 |
+
<script>
|
| 70 |
+
const API='/v1/admin';
|
| 71 |
+
function toast(msg,dur=3000){const t=document.getElementById('toast');t.textContent=msg;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),dur)}
|
| 72 |
+
|
| 73 |
+
async function api(path,opts={}){
|
| 74 |
+
const r=await fetch(API+path,opts);
|
| 75 |
+
if(r.status===401){window.location.href='/admin/login';return null}
|
| 76 |
+
return r;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
async function load(){
|
| 80 |
+
const r=await api('/config');if(!r)return;
|
| 81 |
+
const d=await r.json();
|
| 82 |
+
document.getElementById('api_key').value=d.api_key||'';
|
| 83 |
+
document.getElementById('backend').value=d.backend||'jobs';
|
| 84 |
+
document.getElementById('proxy').value=d.proxy||'';
|
| 85 |
+
document.getElementById('poll_interval').value=d.poll_interval||'5';
|
| 86 |
+
document.getElementById('poll_timeout').value=d.poll_timeout||'120';
|
| 87 |
+
document.getElementById('admin_password').value=d.admin_password||'';
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
async function save(){
|
| 91 |
+
const body={
|
| 92 |
+
api_key:document.getElementById('api_key').value,
|
| 93 |
+
backend:document.getElementById('backend').value,
|
| 94 |
+
proxy:document.getElementById('proxy').value,
|
| 95 |
+
poll_interval:document.getElementById('poll_interval').value,
|
| 96 |
+
poll_timeout:document.getElementById('poll_timeout').value,
|
| 97 |
+
admin_password:document.getElementById('admin_password').value,
|
| 98 |
+
};
|
| 99 |
+
const r=await api('/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
| 100 |
+
if(r){const d=await r.json();toast(`Saved: ${d.saved.join(', ')}`)}
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
async function genKey(){
|
| 104 |
+
const r=await api('/config/generate-key',{method:'POST'});
|
| 105 |
+
if(r){const d=await r.json();document.getElementById('api_key').value=d.api_key;toast('New key generated')}
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
async function logout(){await api('/logout',{method:'POST'});window.location.href='/admin/login'}
|
| 109 |
+
load();
|
| 110 |
+
</script>
|
| 111 |
+
</body>
|
| 112 |
+
</html>
|
emergent2api/static/admin/docs.html
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
| 5 |
+
<title>Emergent2API - Usage Docs</title>
|
| 6 |
+
<style>
|
| 7 |
+
*{margin:0;padding:0;box-sizing:border-box}
|
| 8 |
+
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f5f5f5;color:#1a1a1a}
|
| 9 |
+
nav{background:#fff;border-bottom:1px solid #e5e5e5;padding:0 24px;display:flex;align-items:center;height:56px;position:sticky;top:0;z-index:10}
|
| 10 |
+
nav .brand{font-weight:700;font-size:18px;margin-right:32px;color:#4f46e5}
|
| 11 |
+
nav a{text-decoration:none;color:#666;padding:16px 12px;font-size:14px;border-bottom:2px solid transparent;transition:all .2s}
|
| 12 |
+
nav a:hover{color:#1a1a1a}nav a.active{color:#4f46e5;border-bottom-color:#4f46e5;font-weight:600}
|
| 13 |
+
nav .spacer{flex:1}
|
| 14 |
+
nav .logout{color:#ef4444;cursor:pointer;font-size:13px}
|
| 15 |
+
.container{max-width:800px;margin:0 auto;padding:28px 24px}
|
| 16 |
+
.card{background:#fff;border-radius:10px;box-shadow:0 1px 4px rgba(0,0,0,.06);padding:28px;margin-bottom:20px}
|
| 17 |
+
.card h2{font-size:17px;margin-bottom:14px;color:#4f46e5}
|
| 18 |
+
.card h3{font-size:14px;margin:16px 0 8px;color:#333}
|
| 19 |
+
p{font-size:14px;line-height:1.6;color:#555;margin-bottom:8px}
|
| 20 |
+
pre{background:#1a1a2e;color:#e2e8f0;padding:16px;border-radius:8px;overflow-x:auto;font-size:12px;line-height:1.5;margin:8px 0 14px}
|
| 21 |
+
code{font-family:'SFMono-Regular',Consolas,monospace;font-size:12px}
|
| 22 |
+
.inline-code{background:#f1f5f9;padding:2px 6px;border-radius:4px;color:#4f46e5;font-size:13px}
|
| 23 |
+
table{width:100%;border-collapse:collapse;margin:8px 0 14px;font-size:13px}
|
| 24 |
+
th{text-align:left;padding:8px 12px;background:#f9fafb;color:#888;font-weight:600;border-bottom:1px solid #eee}
|
| 25 |
+
td{padding:8px 12px;border-bottom:1px solid #f3f3f3}
|
| 26 |
+
td code{background:#f1f5f9;padding:1px 4px;border-radius:3px}
|
| 27 |
+
</style>
|
| 28 |
+
</head>
|
| 29 |
+
<body>
|
| 30 |
+
<nav>
|
| 31 |
+
<span class="brand">Emergent2API</span>
|
| 32 |
+
<a href="/admin/token">Token Management</a>
|
| 33 |
+
<a href="/admin/config">Config</a>
|
| 34 |
+
<a href="/admin/docs" class="active">Usage Docs</a>
|
| 35 |
+
<span class="spacer"></span>
|
| 36 |
+
<span class="logout" onclick="logout()">Logout</span>
|
| 37 |
+
</nav>
|
| 38 |
+
<div class="container">
|
| 39 |
+
|
| 40 |
+
<div class="card">
|
| 41 |
+
<h2>Quick Start</h2>
|
| 42 |
+
<p>Emergent2API exposes Emergent.sh accounts as standard OpenAI and Anthropic-compatible API endpoints. Use your configured API key in the <span class="inline-code">Authorization</span> header.</p>
|
| 43 |
+
<pre>Base URL: https://<your-host>/v1
|
| 44 |
+
API Key: sk-6loA0HwMQP1mdhPvI (default, changeable in Config)</pre>
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
<div class="card">
|
| 48 |
+
<h2>Available Models</h2>
|
| 49 |
+
<table>
|
| 50 |
+
<tr><th>Model ID</th><th>Description</th></tr>
|
| 51 |
+
<tr><td><code>claude-opus-4-6</code></td><td>Claude Opus 4.6 (full, uncapped)</td></tr>
|
| 52 |
+
<tr><td><code>claude-sonnet-4-5</code></td><td>Claude Sonnet 4.5</td></tr>
|
| 53 |
+
<tr><td><code>claude-sonnet-4-5-thinking</code></td><td>Claude Sonnet 4.5 with extended thinking</td></tr>
|
| 54 |
+
</table>
|
| 55 |
+
</div>
|
| 56 |
+
|
| 57 |
+
<div class="card">
|
| 58 |
+
<h2>API Endpoints</h2>
|
| 59 |
+
<table>
|
| 60 |
+
<tr><th>Method</th><th>Endpoint</th><th>Format</th></tr>
|
| 61 |
+
<tr><td>POST</td><td><code>/v1/chat/completions</code></td><td>OpenAI Chat Completions</td></tr>
|
| 62 |
+
<tr><td>POST</td><td><code>/v1/messages</code></td><td>Anthropic Messages</td></tr>
|
| 63 |
+
<tr><td>POST</td><td><code>/v1/responses</code></td><td>OpenAI Response API</td></tr>
|
| 64 |
+
<tr><td>GET</td><td><code>/v1/models</code></td><td>Model list</td></tr>
|
| 65 |
+
</table>
|
| 66 |
+
</div>
|
| 67 |
+
|
| 68 |
+
<div class="card">
|
| 69 |
+
<h2>Examples</h2>
|
| 70 |
+
|
| 71 |
+
<h3>OpenAI Chat (curl)</h3>
|
| 72 |
+
<pre>curl -X POST https://your-host/v1/chat/completions \
|
| 73 |
+
-H "Authorization: Bearer sk-6loA0HwMQP1mdhPvI" \
|
| 74 |
+
-H "Content-Type: application/json" \
|
| 75 |
+
-d '{
|
| 76 |
+
"model": "claude-opus-4-6",
|
| 77 |
+
"messages": [
|
| 78 |
+
{"role": "user", "content": "Hello!"}
|
| 79 |
+
],
|
| 80 |
+
"stream": true
|
| 81 |
+
}'</pre>
|
| 82 |
+
|
| 83 |
+
<h3>Anthropic Messages (curl)</h3>
|
| 84 |
+
<pre>curl -X POST https://your-host/v1/messages \
|
| 85 |
+
-H "x-api-key: sk-6loA0HwMQP1mdhPvI" \
|
| 86 |
+
-H "Content-Type: application/json" \
|
| 87 |
+
-H "anthropic-version: 2023-06-01" \
|
| 88 |
+
-d '{
|
| 89 |
+
"model": "claude-opus-4-6",
|
| 90 |
+
"max_tokens": 1024,
|
| 91 |
+
"messages": [
|
| 92 |
+
{"role": "user", "content": "Hello!"}
|
| 93 |
+
]
|
| 94 |
+
}'</pre>
|
| 95 |
+
|
| 96 |
+
<h3>Python (OpenAI SDK)</h3>
|
| 97 |
+
<pre>from openai import OpenAI
|
| 98 |
+
|
| 99 |
+
client = OpenAI(
|
| 100 |
+
api_key="sk-6loA0HwMQP1mdhPvI",
|
| 101 |
+
base_url="https://your-host/v1"
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
response = client.chat.completions.create(
|
| 105 |
+
model="claude-opus-4-6",
|
| 106 |
+
messages=[{"role": "user", "content": "Hello!"}],
|
| 107 |
+
stream=True
|
| 108 |
+
)
|
| 109 |
+
for chunk in response:
|
| 110 |
+
print(chunk.choices[0].delta.content or "", end="")</pre>
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
<div class="card">
|
| 114 |
+
<h2>CherryStudio Configuration</h2>
|
| 115 |
+
<p>1. Open CherryStudio → Settings → Model Provider → Add Custom Provider</p>
|
| 116 |
+
<p>2. Set the following:</p>
|
| 117 |
+
<table>
|
| 118 |
+
<tr><th>Field</th><th>Value</th></tr>
|
| 119 |
+
<tr><td>Base URL</td><td><code>https://your-host/v1</code></td></tr>
|
| 120 |
+
<tr><td>API Key</td><td><code>sk-6loA0HwMQP1mdhPvI</code></td></tr>
|
| 121 |
+
<tr><td>Model</td><td><code>claude-opus-4-6</code></td></tr>
|
| 122 |
+
</table>
|
| 123 |
+
<p>3. Enable "Stream" mode for real-time responses.</p>
|
| 124 |
+
</div>
|
| 125 |
+
|
| 126 |
+
<div class="card">
|
| 127 |
+
<h2>Batch Import via CLI</h2>
|
| 128 |
+
<p>Import accounts from a zip artifact (produced by GitHub Actions):</p>
|
| 129 |
+
<pre>curl -X POST https://your-host/v1/admin/tokens/import-zip \
|
| 130 |
+
-H "Cookie: emergent_admin_session=YOUR_SESSION" \
|
| 131 |
+
-H "Content-Type: application/octet-stream" \
|
| 132 |
+
--data-binary @0316Emergent.zip</pre>
|
| 133 |
+
<p>Or import JSONL text directly:</p>
|
| 134 |
+
<pre>curl -X POST https://your-host/v1/admin/tokens/import \
|
| 135 |
+
-H "Cookie: emergent_admin_session=YOUR_SESSION" \
|
| 136 |
+
-H "Content-Type: application/json" \
|
| 137 |
+
-d '{"jsonl": "{\"email\":\"a@b.com\",\"password\":\"p\",\"jwt\":\"j\"}\n..."}'</pre>
|
| 138 |
+
</div>
|
| 139 |
+
|
| 140 |
+
</div>
|
| 141 |
+
<script>
|
| 142 |
+
async function logout(){await fetch('/v1/admin/logout',{method:'POST'});window.location.href='/admin/login'}
|
| 143 |
+
</script>
|
| 144 |
+
</body>
|
| 145 |
+
</html>
|
emergent2api/static/admin/login.html
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
| 5 |
+
<title>Emergent2API - Login</title>
|
| 6 |
+
<style>
|
| 7 |
+
*{margin:0;padding:0;box-sizing:border-box}
|
| 8 |
+
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f5f5f5;display:flex;align-items:center;justify-content:center;min-height:100vh}
|
| 9 |
+
.card{background:#fff;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.08);padding:40px;width:380px;text-align:center}
|
| 10 |
+
.card h1{font-size:22px;margin-bottom:6px;color:#1a1a1a}
|
| 11 |
+
.card p{color:#888;font-size:14px;margin-bottom:28px}
|
| 12 |
+
input{width:100%;padding:12px 16px;border:1px solid #ddd;border-radius:8px;font-size:15px;outline:none;transition:border .2s}
|
| 13 |
+
input:focus{border-color:#4f46e5}
|
| 14 |
+
button{width:100%;padding:12px;background:#4f46e5;color:#fff;border:none;border-radius:8px;font-size:15px;cursor:pointer;margin-top:16px;transition:background .2s}
|
| 15 |
+
button:hover{background:#4338ca}
|
| 16 |
+
.err{color:#ef4444;font-size:13px;margin-top:12px;display:none}
|
| 17 |
+
</style>
|
| 18 |
+
</head>
|
| 19 |
+
<body>
|
| 20 |
+
<div class="card">
|
| 21 |
+
<h1>Emergent2API</h1>
|
| 22 |
+
<p>Admin Panel Login</p>
|
| 23 |
+
<input type="password" id="pwd" placeholder="Admin Password" autofocus>
|
| 24 |
+
<button onclick="doLogin()">Login</button>
|
| 25 |
+
<div class="err" id="err">Invalid password</div>
|
| 26 |
+
</div>
|
| 27 |
+
<script>
|
| 28 |
+
const API='/v1/admin';
|
| 29 |
+
document.getElementById('pwd').addEventListener('keydown',e=>{if(e.key==='Enter')doLogin()});
|
| 30 |
+
async function doLogin(){
|
| 31 |
+
const pwd=document.getElementById('pwd').value;
|
| 32 |
+
try{
|
| 33 |
+
const r=await fetch(API+'/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:pwd})});
|
| 34 |
+
if(r.ok){window.location.href='/admin/token'}
|
| 35 |
+
else{document.getElementById('err').style.display='block'}
|
| 36 |
+
}catch(e){document.getElementById('err').style.display='block'}
|
| 37 |
+
}
|
| 38 |
+
</script>
|
| 39 |
+
</body>
|
| 40 |
+
</html>
|
emergent2api/static/admin/token.html
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
| 5 |
+
<title>Emergent2API - Token Management</title>
|
| 6 |
+
<style>
|
| 7 |
+
*{margin:0;padding:0;box-sizing:border-box}
|
| 8 |
+
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f5f5f5;color:#1a1a1a}
|
| 9 |
+
nav{background:#fff;border-bottom:1px solid #e5e5e5;padding:0 24px;display:flex;align-items:center;height:56px;position:sticky;top:0;z-index:10}
|
| 10 |
+
nav .brand{font-weight:700;font-size:18px;margin-right:32px;color:#4f46e5}
|
| 11 |
+
nav a{text-decoration:none;color:#666;padding:16px 12px;font-size:14px;border-bottom:2px solid transparent;transition:all .2s}
|
| 12 |
+
nav a:hover{color:#1a1a1a}nav a.active{color:#4f46e5;border-bottom-color:#4f46e5;font-weight:600}
|
| 13 |
+
nav .spacer{flex:1}
|
| 14 |
+
nav .logout{color:#ef4444;cursor:pointer;font-size:13px}
|
| 15 |
+
.container{max-width:1200px;margin:0 auto;padding:20px 24px}
|
| 16 |
+
.toolbar{display:flex;gap:10px;margin-bottom:16px;flex-wrap:wrap;align-items:center}
|
| 17 |
+
.toolbar select,.toolbar input{padding:8px 12px;border:1px solid #ddd;border-radius:6px;font-size:13px;background:#fff}
|
| 18 |
+
.btn{padding:8px 16px;border:none;border-radius:6px;font-size:13px;cursor:pointer;font-weight:500;transition:all .2s}
|
| 19 |
+
.btn-primary{background:#4f46e5;color:#fff}.btn-primary:hover{background:#4338ca}
|
| 20 |
+
.btn-danger{background:#ef4444;color:#fff}.btn-danger:hover{background:#dc2626}
|
| 21 |
+
.btn-warning{background:#f59e0b;color:#fff}.btn-warning:hover{background:#d97706}
|
| 22 |
+
.btn-success{background:#10b981;color:#fff}.btn-success:hover{background:#059669}
|
| 23 |
+
.btn-outline{background:#fff;color:#333;border:1px solid #ddd}.btn-outline:hover{background:#f3f4f6}
|
| 24 |
+
.stats{display:flex;gap:16px;margin-bottom:16px}
|
| 25 |
+
.stat{background:#fff;border-radius:8px;padding:16px 20px;box-shadow:0 1px 4px rgba(0,0,0,.06);flex:1}
|
| 26 |
+
.stat .num{font-size:28px;font-weight:700;color:#4f46e5}.stat .label{font-size:12px;color:#888;margin-top:4px}
|
| 27 |
+
table{width:100%;border-collapse:collapse;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.06)}
|
| 28 |
+
th{text-align:left;padding:10px 14px;font-size:12px;color:#888;background:#fafafa;font-weight:600;border-bottom:1px solid #eee}
|
| 29 |
+
td{padding:10px 14px;font-size:13px;border-bottom:1px solid #f3f3f3}
|
| 30 |
+
tr:hover td{background:#fafafe}
|
| 31 |
+
.badge{padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600}
|
| 32 |
+
.badge-ok{background:#d1fae5;color:#059669}.badge-off{background:#fee2e2;color:#ef4444}
|
| 33 |
+
.modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.4);z-index:20;align-items:center;justify-content:center}
|
| 34 |
+
.modal.open{display:flex}
|
| 35 |
+
.modal-box{background:#fff;border-radius:12px;padding:28px;width:520px;max-height:80vh;overflow-y:auto;box-shadow:0 8px 30px rgba(0,0,0,.15)}
|
| 36 |
+
.modal-box h3{margin-bottom:14px;font-size:17px}
|
| 37 |
+
textarea{width:100%;height:200px;padding:10px;border:1px solid #ddd;border-radius:6px;font-size:12px;font-family:monospace;resize:vertical}
|
| 38 |
+
.modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:14px}
|
| 39 |
+
input[type="checkbox"]{width:16px;height:16px;cursor:pointer}
|
| 40 |
+
.toast{position:fixed;bottom:24px;right:24px;background:#1a1a1a;color:#fff;padding:12px 20px;border-radius:8px;font-size:13px;z-index:30;opacity:0;transition:opacity .3s}
|
| 41 |
+
.toast.show{opacity:1}
|
| 42 |
+
</style>
|
| 43 |
+
</head>
|
| 44 |
+
<body>
|
| 45 |
+
<nav>
|
| 46 |
+
<span class="brand">Emergent2API</span>
|
| 47 |
+
<a href="/admin/token" class="active">Token Management</a>
|
| 48 |
+
<a href="/admin/config">Config</a>
|
| 49 |
+
<a href="/admin/docs">Usage Docs</a>
|
| 50 |
+
<span class="spacer"></span>
|
| 51 |
+
<span class="logout" onclick="logout()">Logout</span>
|
| 52 |
+
</nav>
|
| 53 |
+
<div class="container">
|
| 54 |
+
<div class="stats">
|
| 55 |
+
<div class="stat"><div class="num" id="s-total">-</div><div class="label">Total Tokens</div></div>
|
| 56 |
+
<div class="stat"><div class="num" id="s-active">-</div><div class="label">Active</div></div>
|
| 57 |
+
<div class="stat"><div class="num" id="s-inactive">-</div><div class="label">Inactive</div></div>
|
| 58 |
+
</div>
|
| 59 |
+
<div class="toolbar">
|
| 60 |
+
<select id="filter" onchange="render()">
|
| 61 |
+
<option value="all">All</option>
|
| 62 |
+
<option value="active">Active</option>
|
| 63 |
+
<option value="inactive">Inactive</option>
|
| 64 |
+
</select>
|
| 65 |
+
<span class="btn btn-outline" onclick="selectAll()">Select All</span>
|
| 66 |
+
<span class="btn btn-outline" onclick="selectNone()">Deselect All</span>
|
| 67 |
+
<span style="flex:1"></span>
|
| 68 |
+
<span class="btn btn-primary" onclick="openImport()">Import</span>
|
| 69 |
+
<span class="btn btn-outline" onclick="doExport()">Export</span>
|
| 70 |
+
<span class="btn btn-success" onclick="batchAction('test')">Test Selected</span>
|
| 71 |
+
<span class="btn btn-warning" onclick="batchAction('refresh')">Refresh JWT</span>
|
| 72 |
+
<span class="btn btn-danger" onclick="batchAction('delete')">Delete Selected</span>
|
| 73 |
+
<span class="btn btn-danger" onclick="deleteInactive()">Delete Inactive</span>
|
| 74 |
+
</div>
|
| 75 |
+
<table>
|
| 76 |
+
<thead><tr>
|
| 77 |
+
<th><input type="checkbox" id="chk-all" onchange="toggleAll(this.checked)"></th>
|
| 78 |
+
<th>ID</th><th>Email</th><th>Balance</th><th>Status</th><th>Last Used</th><th>Created</th><th>Actions</th>
|
| 79 |
+
</tr></thead>
|
| 80 |
+
<tbody id="tbody"></tbody>
|
| 81 |
+
</table>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
<div class="modal" id="modal-import">
|
| 85 |
+
<div class="modal-box">
|
| 86 |
+
<h3>Import Accounts</h3>
|
| 87 |
+
<p style="font-size:13px;color:#888;margin-bottom:10px">Paste JSONL data (one account per line: email, password, jwt required)</p>
|
| 88 |
+
<textarea id="import-text" placeholder='{"email":"...","password":"...","jwt":"..."}'></textarea>
|
| 89 |
+
<p style="font-size:12px;color:#888;margin-top:8px">Or upload a .zip file containing accounts.jsonl:</p>
|
| 90 |
+
<input type="file" id="import-file" accept=".zip" style="margin-top:6px">
|
| 91 |
+
<div class="modal-actions">
|
| 92 |
+
<span class="btn btn-outline" onclick="closeImport()">Cancel</span>
|
| 93 |
+
<span class="btn btn-primary" onclick="doImportText()">Import JSONL</span>
|
| 94 |
+
<span class="btn btn-primary" onclick="doImportZip()">Import Zip</span>
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
|
| 99 |
+
<div class="toast" id="toast"></div>
|
| 100 |
+
|
| 101 |
+
<script>
|
| 102 |
+
const API='/v1/admin';
|
| 103 |
+
let tokens=[];let selected=new Set();
|
| 104 |
+
|
| 105 |
+
async function api(path,opts={}){
|
| 106 |
+
const r=await fetch(API+path,opts);
|
| 107 |
+
if(r.status===401){window.location.href='/admin/login';return null}
|
| 108 |
+
return r;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
function toast(msg,dur=3000){const t=document.getElementById('toast');t.textContent=msg;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),dur)}
|
| 112 |
+
|
| 113 |
+
async function load(){
|
| 114 |
+
const r=await api('/tokens');if(!r)return;
|
| 115 |
+
const d=await r.json();
|
| 116 |
+
tokens=d.tokens||[];
|
| 117 |
+
document.getElementById('s-total').textContent=d.total;
|
| 118 |
+
document.getElementById('s-active').textContent=d.active;
|
| 119 |
+
document.getElementById('s-inactive').textContent=d.total-d.active;
|
| 120 |
+
render();
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
function render(){
|
| 124 |
+
const f=document.getElementById('filter').value;
|
| 125 |
+
const tbody=document.getElementById('tbody');
|
| 126 |
+
let rows=tokens;
|
| 127 |
+
if(f==='active')rows=rows.filter(t=>t.is_active);
|
| 128 |
+
else if(f==='inactive')rows=rows.filter(t=>!t.is_active);
|
| 129 |
+
tbody.innerHTML=rows.map(t=>`<tr>
|
| 130 |
+
<td><input type="checkbox" class="row-chk" data-id="${t.id}" ${selected.has(t.id)?'checked':''} onchange="toggleSel(${t.id},this.checked)"></td>
|
| 131 |
+
<td>${t.id}</td>
|
| 132 |
+
<td style="font-family:monospace;font-size:12px">${t.email}</td>
|
| 133 |
+
<td>${t.balance!=null?'$'+t.balance.toFixed(2):'-'}</td>
|
| 134 |
+
<td><span class="badge ${t.is_active?'badge-ok':'badge-off'}">${t.is_active?'Active':'Inactive'}</span></td>
|
| 135 |
+
<td style="font-size:12px;color:#888">${t.last_used?new Date(t.last_used).toLocaleString():'-'}</td>
|
| 136 |
+
<td style="font-size:12px;color:#888">${t.created_at?new Date(t.created_at).toLocaleDateString():'-'}</td>
|
| 137 |
+
<td>
|
| 138 |
+
<span class="btn btn-outline" style="padding:4px 8px;font-size:11px" onclick="toggleOne(${t.id})">${t.is_active?'Disable':'Enable'}</span>
|
| 139 |
+
</td>
|
| 140 |
+
</tr>`).join('');
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
function toggleSel(id,c){if(c)selected.add(id);else selected.delete(id)}
|
| 144 |
+
function selectAll(){tokens.forEach(t=>selected.add(t.id));render()}
|
| 145 |
+
function selectNone(){selected.clear();render()}
|
| 146 |
+
function toggleAll(c){if(c)selectAll();else selectNone()}
|
| 147 |
+
|
| 148 |
+
async function toggleOne(id){
|
| 149 |
+
await api('/tokens/toggle',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids:[id],active:!tokens.find(t=>t.id===id)?.is_active})});
|
| 150 |
+
load();
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
async function batchAction(action){
|
| 154 |
+
const ids=[...selected];
|
| 155 |
+
if(!ids.length){toast('Select accounts first');return}
|
| 156 |
+
if(action==='delete'&&!confirm(`Delete ${ids.length} accounts?`))return;
|
| 157 |
+
const endpoint=`/tokens/${action}`;
|
| 158 |
+
toast(`Processing ${ids.length} accounts...`);
|
| 159 |
+
const r=await api(endpoint,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids})});
|
| 160 |
+
if(r){const d=await r.json();toast(JSON.stringify(d))}
|
| 161 |
+
selected.clear();load();
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
async function deleteInactive(){
|
| 165 |
+
if(!confirm('Delete all inactive accounts?'))return;
|
| 166 |
+
const r=await api('/tokens/delete-inactive',{method:'POST'});
|
| 167 |
+
if(r){const d=await r.json();toast(`Deleted ${d.deleted} inactive accounts`)}
|
| 168 |
+
load();
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
function openImport(){document.getElementById('modal-import').classList.add('open')}
|
| 172 |
+
function closeImport(){document.getElementById('modal-import').classList.remove('open')}
|
| 173 |
+
|
| 174 |
+
async function doImportText(){
|
| 175 |
+
const text=document.getElementById('import-text').value.trim();
|
| 176 |
+
if(!text){toast('Paste JSONL data first');return}
|
| 177 |
+
const r=await api('/tokens/import',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({jsonl:text})});
|
| 178 |
+
if(r){const d=await r.json();toast(`Imported ${d.imported} accounts`+(d.errors.length?`, ${d.errors.length} errors`:''));closeImport();load()}
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
async function doImportZip(){
|
| 182 |
+
const f=document.getElementById('import-file').files[0];
|
| 183 |
+
if(!f){toast('Select a zip file');return}
|
| 184 |
+
const buf=await f.arrayBuffer();
|
| 185 |
+
const r=await api('/tokens/import-zip',{method:'POST',headers:{'Content-Type':'application/octet-stream'},body:buf});
|
| 186 |
+
if(r){const d=await r.json();toast(`Imported ${d.imported} accounts`);closeImport();load()}
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
async function doExport(){
|
| 190 |
+
const r=await api('/tokens/export');
|
| 191 |
+
if(!r)return;
|
| 192 |
+
const blob=await r.blob();
|
| 193 |
+
const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='emergent_accounts.zip';a.click();
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
async function logout(){
|
| 197 |
+
await api('/logout',{method:'POST'});
|
| 198 |
+
window.location.href='/admin/login';
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
load();
|
| 202 |
+
</script>
|
| 203 |
+
</body>
|
| 204 |
+
</html>
|