From 5f1154d363e56169d39e175004b9b9007aa9df44 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Tue, 10 Mar 2026 11:24:38 -0400 Subject: [PATCH] Add admin panel for merchant management - POST/GET/DELETE /api/admin/users endpoints - Admin UI at /admin with login, user list, create/delete - Auth via ADMIN_API_KEY env var (separate from tenant auth) - Tenants remain scoped to their own store only --- .env.example | 3 + api/app/admin_routes.py | 216 ++++++++++++++++++++++++ api/app/config.py | 1 + api/app/main.py | 2 + docker-compose.yml | 1 + ui/app/(app)/admin/actions.ts | 171 +++++++++++++++++++ ui/app/(app)/admin/admin-login-form.tsx | 38 +++++ ui/app/(app)/admin/admin-panel.tsx | 188 +++++++++++++++++++++ ui/app/(app)/admin/page.tsx | 38 +++++ 9 files changed, 658 insertions(+) create mode 100644 api/app/admin_routes.py create mode 100644 ui/app/(app)/admin/actions.ts create mode 100644 ui/app/(app)/admin/admin-login-form.tsx create mode 100644 ui/app/(app)/admin/admin-panel.tsx create mode 100644 ui/app/(app)/admin/page.tsx diff --git a/.env.example b/.env.example index 5226feb..baaa456 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,9 @@ MONERO_WALLET_RPC_WALLET_DIR=/wallets # - Local: run the bundled `monerod` service and set MONERO_DAEMON_URL=http://monerod:18081 MONERO_DAEMON_URL=http://xmr-node.cakewallet.com:18081 +# Admin panel (generate with: openssl rand -base64 32 | tr -d '=' | head -c 32) +ADMIN_API_KEY= + # Registration (set to false to require manual merchant onboarding) OPEN_REGISTRATION=true diff --git a/api/app/admin_routes.py b/api/app/admin_routes.py new file mode 100644 index 0000000..3488e2d --- /dev/null +++ b/api/app/admin_routes.py @@ -0,0 +1,216 @@ +"""Admin API endpoints for managing merchants. + +Protected by ADMIN_API_KEY — separate from per-user API keys. +""" + +from fastapi import APIRouter, Depends, Header, HTTPException, status +from pydantic import BaseModel +from sqlalchemy import func +from sqlalchemy.orm import Session + +from .config import ADMIN_API_KEY +from .db import get_db +from .models import Invoice, User +from .security import ( + encrypt_api_key, + encrypt_secret, + generate_api_key, + generate_webhook_secret, + hash_api_key, + decrypt_api_key, +) + +router = APIRouter(prefix="/api/admin", tags=["admin"]) + + +# --------------------------------------------------------------------------- +# Auth dependency +# --------------------------------------------------------------------------- + +def require_admin( + authorization: str | None = Header(default=None), + x_admin_key: str | None = Header(default=None, alias="X-Admin-Key"), +) -> str: + """Validate admin API key from Authorization header or X-Admin-Key.""" + if not ADMIN_API_KEY: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Admin API not configured", + ) + + key: str | None = None + if authorization: + scheme, _, token = authorization.partition(" ") + if scheme.lower() in ("adminkey", "bearer"): + key = token.strip() or None + if key is None: + key = x_admin_key + + if key != ADMIN_API_KEY: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid admin key", + ) + return key + + +# --------------------------------------------------------------------------- +# Schemas +# --------------------------------------------------------------------------- + +class AdminUserResponse(BaseModel): + id: str + payment_address: str + invoice_count: int + created_at: str | None + + class Config: + from_attributes = True + + +class AdminUserDetail(AdminUserResponse): + api_key: str + webhook_secret: str | None + next_subaddress_index: int + + +class AdminCreateUser(BaseModel): + payment_address: str + view_key: str + + +class AdminCreateUserResponse(BaseModel): + id: str + payment_address: str + api_key: str + webhook_secret: str + store_id: str + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + +@router.get("/users", response_model=list[AdminUserResponse]) +def list_users( + _admin: str = Depends(require_admin), + db: Session = Depends(get_db), +): + """List all registered merchants with invoice counts.""" + rows = ( + db.query( + User.id, + User.payment_address, + User.created_at, + func.count(Invoice.id).label("invoice_count"), + ) + .outerjoin(Invoice, Invoice.user_id == User.id) + .group_by(User.id) + .order_by(User.created_at.desc()) + .all() + ) + return [ + AdminUserResponse( + id=str(r.id), + payment_address=r.payment_address, + invoice_count=r.invoice_count, + created_at=r.created_at.isoformat() if r.created_at else None, + ) + for r in rows + ] + + +@router.get("/users/{user_id}", response_model=AdminUserDetail) +def get_user( + user_id: str, + _admin: str = Depends(require_admin), + db: Session = Depends(get_db), +): + """Get detailed info for a single merchant.""" + user = db.query(User).filter(User.id == user_id).first() + if user is None: + raise HTTPException(status_code=404, detail="User not found") + invoice_count = db.query(func.count(Invoice.id)).filter(Invoice.user_id == user.id).scalar() + api_key = decrypt_api_key(user.api_key_encrypted) + webhook_secret = None + if user.webhook_secret_encrypted: + from .security import decrypt_secret + webhook_secret = decrypt_secret(user.webhook_secret_encrypted) + return AdminUserDetail( + id=str(user.id), + payment_address=user.payment_address, + invoice_count=invoice_count or 0, + created_at=user.created_at.isoformat() if user.created_at else None, + api_key=api_key, + webhook_secret=webhook_secret, + next_subaddress_index=user.next_subaddress_index, + ) + + +@router.post("/users", response_model=AdminCreateUserResponse, status_code=201) +def create_user( + payload: AdminCreateUser, + _admin: str = Depends(require_admin), + db: Session = Depends(get_db), +): + """Create a new merchant. Same logic as login but bypasses OPEN_REGISTRATION.""" + from .routes import _validate_payment_address_and_view_key + + payment_address = payload.payment_address.strip() + view_key = payload.view_key.strip() + _validate_payment_address_and_view_key(payment_address, view_key) + + existing = db.query(User).filter(User.payment_address == payment_address).first() + if existing is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="User with this address already exists", + ) + + api_key = generate_api_key() + webhook_secret = generate_webhook_secret() + user = User( + payment_address=payment_address, + view_key_encrypted=encrypt_secret(view_key), + api_key_hash=hash_api_key(api_key), + api_key_encrypted=encrypt_api_key(api_key), + webhook_secret_encrypted=encrypt_secret(webhook_secret), + ) + db.add(user) + db.commit() + db.refresh(user) + return AdminCreateUserResponse( + id=str(user.id), + payment_address=payment_address, + api_key=api_key, + webhook_secret=webhook_secret, + store_id=str(user.id), + ) + + +@router.delete("/users/{user_id}", status_code=204) +def delete_user( + user_id: str, + _admin: str = Depends(require_admin), + db: Session = Depends(get_db), +): + """Remove a merchant and all their data.""" + user = db.query(User).filter(User.id == user_id).first() + if user is None: + raise HTTPException(status_code=404, detail="User not found") + # Delete related data + from .models import Webhook, WebhookDelivery, ProfileHistory, BtcpayWebhook + db.query(WebhookDelivery).filter(WebhookDelivery.user_id == user.id).delete() + db.query(Webhook).filter(Webhook.user_id == user.id).delete() + db.query(BtcpayWebhook).filter(BtcpayWebhook.user_id == user.id).delete() + db.query(ProfileHistory).filter(ProfileHistory.user_id == user.id).delete() + db.query(Invoice).filter(Invoice.user_id == user.id).delete() + db.delete(user) + db.commit() + return None + + +@router.post("/auth/verify", status_code=200) +def verify_admin_key(_admin: str = Depends(require_admin)): + """Verify that the provided admin key is valid.""" + return {"ok": True} diff --git a/api/app/config.py b/api/app/config.py index 4930cc7..a29e983 100644 --- a/api/app/config.py +++ b/api/app/config.py @@ -46,3 +46,4 @@ MONERO_WALLET_RPC_WALLET_DIR = os.getenv("MONERO_WALLET_RPC_WALLET_DIR", "") FOUNDER_PAYMENT_ADDRESS = os.getenv("FOUNDER_PAYMENT_ADDRESS", "") FOUNDER_VIEW_KEY = os.getenv("FOUNDER_VIEW_KEY", "") QR_STORAGE_DIR = os.getenv("QR_STORAGE_DIR", "/qr") +ADMIN_API_KEY = os.getenv("ADMIN_API_KEY", "") diff --git a/api/app/main.py b/api/app/main.py index 9405309..486a903 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -7,6 +7,7 @@ from sqlalchemy import text from .db import Base, engine from .config import DONATIONS_ENABLED +from .admin_routes import router as admin_router from .btcpay_routes import router as btcpay_router from .routes import router @@ -263,3 +264,4 @@ def startup(): app.include_router(router) app.include_router(btcpay_router) +app.include_router(admin_router) diff --git a/docker-compose.yml b/docker-compose.yml index d8bbe46..0d53f63 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,7 @@ services: FOUNDER_VIEW_KEY: ${FOUNDER_VIEW_KEY} DONATIONS_ENABLED: ${DONATIONS_ENABLED:-false} OPEN_REGISTRATION: ${OPEN_REGISTRATION:-true} + ADMIN_API_KEY: ${ADMIN_API_KEY:-} QR_STORAGE_DIR: ${QR_STORAGE_DIR:-/qr} MONERO_WALLET_RPC_URLS: ${MONERO_WALLET_RPC_URLS:-http://wallet-rpc-reconciler-1:18083,http://wallet-rpc-reconciler-2:18083,http://wallet-rpc-reconciler-3:18083} MONERO_WALLET_RPC_USER: ${MONERO_WALLET_RPC_USER} diff --git a/ui/app/(app)/admin/actions.ts b/ui/app/(app)/admin/actions.ts new file mode 100644 index 0000000..0fe7c5a --- /dev/null +++ b/ui/app/(app)/admin/actions.ts @@ -0,0 +1,171 @@ +"use server"; + +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { revalidatePath } from "next/cache"; + +const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:8000"; + +const ADMIN_COOKIE = "xmrcheckout_admin_key"; + +export type AdminLoginState = { + error: string | null; +}; + +export type AdminCreateUserState = { + error: string | null; + success: string | null; + apiKey: string | null; + webhookSecret: string | null; + storeId: string | null; +}; + +export type AdminDeleteUserState = { + error: string | null; + success: string | null; + deletedId: string | null; +}; + +async function getAdminKey(): Promise { + return (await cookies()).get(ADMIN_COOKIE)?.value ?? null; +} + +export async function adminLoginAction( + _prevState: AdminLoginState, + formData: FormData +): Promise { + const key = String(formData.get("admin_key") ?? "").trim(); + if (!key) { + return { error: "Admin key is required." }; + } + + const response = await fetch(`${apiBaseUrl}/api/admin/auth/verify`, { + method: "POST", + headers: { "X-Admin-Key": key }, + cache: "no-store", + }); + + if (!response.ok) { + return { error: "Invalid admin key." }; + } + + const cookieStore = await cookies(); + cookieStore.set(ADMIN_COOKIE, key, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + }); + + redirect("/admin"); +} + +export async function adminLogoutAction() { + const cookieStore = await cookies(); + cookieStore.set(ADMIN_COOKIE, "", { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: 0, + }); + redirect("/admin"); +} + +export async function adminCreateUserAction( + _prevState: AdminCreateUserState, + formData: FormData +): Promise { + const adminKey = await getAdminKey(); + if (!adminKey) { + return { error: "Not authenticated.", success: null, apiKey: null, webhookSecret: null, storeId: null }; + } + + const paymentAddress = String(formData.get("payment_address") ?? "").trim(); + const viewKey = String(formData.get("view_key") ?? "").trim(); + + if (!paymentAddress || !viewKey) { + return { error: "Address and view key are required.", success: null, apiKey: null, webhookSecret: null, storeId: null }; + } + + const response = await fetch(`${apiBaseUrl}/api/admin/users`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Admin-Key": adminKey, + }, + body: JSON.stringify({ payment_address: paymentAddress, view_key: viewKey }), + cache: "no-store", + }); + + if (!response.ok) { + const detail = await response.json().catch(() => null); + const message = detail?.detail ?? "Failed to create user."; + return { error: message, success: null, apiKey: null, webhookSecret: null, storeId: null }; + } + + const data = (await response.json()) as { + api_key: string; + webhook_secret: string; + store_id: string; + }; + + revalidatePath("/admin"); + return { + error: null, + success: `Merchant created.`, + apiKey: data.api_key, + webhookSecret: data.webhook_secret, + storeId: data.store_id, + }; +} + +export async function adminDeleteUserAction( + _prevState: AdminDeleteUserState, + formData: FormData +): Promise { + const adminKey = await getAdminKey(); + if (!adminKey) { + return { error: "Not authenticated.", success: null, deletedId: null }; + } + + const userId = String(formData.get("user_id") ?? "").trim(); + if (!userId) { + return { error: "User ID missing.", success: null, deletedId: null }; + } + + const response = await fetch(`${apiBaseUrl}/api/admin/users/${encodeURIComponent(userId)}`, { + method: "DELETE", + headers: { "X-Admin-Key": adminKey }, + cache: "no-store", + }); + + if (!response.ok) { + const detail = await response.json().catch(() => null); + const message = detail?.detail ?? "Failed to delete user."; + return { error: message, success: null, deletedId: null }; + } + + revalidatePath("/admin"); + return { error: null, success: "Merchant deleted.", deletedId: userId }; +} + +export type AdminUser = { + id: string; + payment_address: string; + invoice_count: number; + created_at: string | null; +}; + +export async function fetchAdminUsers(): Promise { + const adminKey = await getAdminKey(); + if (!adminKey) return null; + + const response = await fetch(`${apiBaseUrl}/api/admin/users`, { + headers: { "X-Admin-Key": adminKey }, + cache: "no-store", + }); + + if (!response.ok) return null; + return response.json(); +} diff --git a/ui/app/(app)/admin/admin-login-form.tsx b/ui/app/(app)/admin/admin-login-form.tsx new file mode 100644 index 0000000..6846e34 --- /dev/null +++ b/ui/app/(app)/admin/admin-login-form.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useActionState } from "react"; +import { adminLoginAction, type AdminLoginState } from "./actions"; + +const initialState: AdminLoginState = { error: null }; + +export function AdminLoginForm() { + const [state, formAction, pending] = useActionState(adminLoginAction, initialState); + + return ( +
+
+ + +
+ {state.error && ( +

{state.error}

+ )} + +
+ ); +} diff --git a/ui/app/(app)/admin/admin-panel.tsx b/ui/app/(app)/admin/admin-panel.tsx new file mode 100644 index 0000000..038fb3a --- /dev/null +++ b/ui/app/(app)/admin/admin-panel.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { useActionState, useState } from "react"; +import { + adminCreateUserAction, + adminDeleteUserAction, + adminLogoutAction, + type AdminCreateUserState, + type AdminDeleteUserState, + type AdminUser, +} from "./actions"; + +const createInitial: AdminCreateUserState = { + error: null, + success: null, + apiKey: null, + webhookSecret: null, + storeId: null, +}; + +const deleteInitial: AdminDeleteUserState = { + error: null, + success: null, + deletedId: null, +}; + +export function AdminPanel({ users }: { users: AdminUser[] }) { + const [createState, createAction, createPending] = useActionState( + adminCreateUserAction, + createInitial + ); + const [deleteState, deleteAction, deletePending] = useActionState( + adminDeleteUserAction, + deleteInitial + ); + const [confirmDelete, setConfirmDelete] = useState(null); + + const visibleUsers = users.filter( + (u) => u.id !== deleteState.deletedId + ); + + return ( +
+
+

Admin — Merchants

+
+ +
+
+ + {/* Add merchant form */} +
+

Add Merchant

+
+
+ +