mirror of
https://github.com/Such-Software/wowcheckout.git
synced 2026-10-17 05:51:45 +02:00
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
This commit is contained in:
@@ -30,6 +30,9 @@ MONERO_WALLET_RPC_WALLET_DIR=/wallets
|
|||||||
# - Local: run the bundled `wownerod` service and set MONERO_DAEMON_URL=http://wownerod:34568
|
# - Local: run the bundled `wownerod` service and set MONERO_DAEMON_URL=http://wownerod:34568
|
||||||
MONERO_DAEMON_URL=http://192.168.88.235:34568
|
MONERO_DAEMON_URL=http://192.168.88.235:34568
|
||||||
|
|
||||||
|
# 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)
|
# Registration (set to false to require manual merchant onboarding)
|
||||||
OPEN_REGISTRATION=true
|
OPEN_REGISTRATION=true
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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_PAYMENT_ADDRESS = os.getenv("FOUNDER_PAYMENT_ADDRESS", "")
|
||||||
FOUNDER_VIEW_KEY = os.getenv("FOUNDER_VIEW_KEY", "")
|
FOUNDER_VIEW_KEY = os.getenv("FOUNDER_VIEW_KEY", "")
|
||||||
QR_STORAGE_DIR = os.getenv("QR_STORAGE_DIR", "/qr")
|
QR_STORAGE_DIR = os.getenv("QR_STORAGE_DIR", "/qr")
|
||||||
|
ADMIN_API_KEY = os.getenv("ADMIN_API_KEY", "")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from sqlalchemy import text
|
|||||||
|
|
||||||
from .db import Base, engine
|
from .db import Base, engine
|
||||||
from .config import DONATIONS_ENABLED
|
from .config import DONATIONS_ENABLED
|
||||||
|
from .admin_routes import router as admin_router
|
||||||
from .btcpay_routes import router as btcpay_router
|
from .btcpay_routes import router as btcpay_router
|
||||||
from .routes import router
|
from .routes import router
|
||||||
|
|
||||||
@@ -263,3 +264,4 @@ def startup():
|
|||||||
|
|
||||||
app.include_router(router)
|
app.include_router(router)
|
||||||
app.include_router(btcpay_router)
|
app.include_router(btcpay_router)
|
||||||
|
app.include_router(admin_router)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ services:
|
|||||||
FOUNDER_VIEW_KEY: ${FOUNDER_VIEW_KEY}
|
FOUNDER_VIEW_KEY: ${FOUNDER_VIEW_KEY}
|
||||||
DONATIONS_ENABLED: ${DONATIONS_ENABLED:-false}
|
DONATIONS_ENABLED: ${DONATIONS_ENABLED:-false}
|
||||||
OPEN_REGISTRATION: ${OPEN_REGISTRATION:-true}
|
OPEN_REGISTRATION: ${OPEN_REGISTRATION:-true}
|
||||||
|
ADMIN_API_KEY: ${ADMIN_API_KEY:-}
|
||||||
QR_STORAGE_DIR: ${QR_STORAGE_DIR:-/qr}
|
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_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}
|
MONERO_WALLET_RPC_USER: ${MONERO_WALLET_RPC_USER}
|
||||||
|
|||||||
@@ -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 = "wowcheckout_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<string | null> {
|
||||||
|
return (await cookies()).get(ADMIN_COOKIE)?.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminLoginAction(
|
||||||
|
_prevState: AdminLoginState,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<AdminLoginState> {
|
||||||
|
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<AdminCreateUserState> {
|
||||||
|
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<AdminDeleteUserState> {
|
||||||
|
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<AdminUser[] | null> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<form action={formAction} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="admin_key" className="block text-sm font-medium mb-1">
|
||||||
|
Admin API Key
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="admin_key"
|
||||||
|
name="admin_key"
|
||||||
|
className="w-full rounded border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{state.error && (
|
||||||
|
<p className="text-sm text-red-500">{state.error}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={pending}
|
||||||
|
className="w-full rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{pending ? "Verifying…" : "Sign in"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | null>(null);
|
||||||
|
|
||||||
|
const visibleUsers = users.filter(
|
||||||
|
(u) => u.id !== deleteState.deletedId
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen px-4 py-12 max-w-5xl mx-auto">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<h1 className="text-2xl font-bold">Admin — Merchants</h1>
|
||||||
|
<form action={adminLogoutAction}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add merchant form */}
|
||||||
|
<div className="mb-10 p-6 rounded-lg border border-border bg-card">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Add Merchant</h2>
|
||||||
|
<form action={createAction} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="payment_address" className="block text-sm font-medium mb-1">
|
||||||
|
Primary Address
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="payment_address"
|
||||||
|
name="payment_address"
|
||||||
|
rows={2}
|
||||||
|
className="w-full rounded border border-border bg-background px-3 py-2 text-xs font-mono focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="view_key" className="block text-sm font-medium mb-1">
|
||||||
|
Secret View Key
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="view_key"
|
||||||
|
name="view_key"
|
||||||
|
className="w-full rounded border border-border bg-background px-3 py-2 text-xs font-mono focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={createPending}
|
||||||
|
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{createPending ? "Creating…" : "Create Merchant"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{createState.error && (
|
||||||
|
<p className="mt-3 text-sm text-red-500">{createState.error}</p>
|
||||||
|
)}
|
||||||
|
{createState.success && (
|
||||||
|
<div className="mt-3 p-4 rounded border border-green-500/30 bg-green-500/10 text-sm space-y-2">
|
||||||
|
<p className="font-medium text-green-600">{createState.success}</p>
|
||||||
|
<div className="space-y-1 font-mono text-xs">
|
||||||
|
<p><span className="font-semibold">Store ID:</span> {createState.storeId}</p>
|
||||||
|
<p><span className="font-semibold">API Key:</span> {createState.apiKey}</p>
|
||||||
|
<p><span className="font-semibold">Webhook Secret:</span> {createState.webhookSecret}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Save these credentials — the API key is shown only once.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Merchant list */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold mb-4">
|
||||||
|
Registered Merchants ({visibleUsers.length})
|
||||||
|
</h2>
|
||||||
|
{visibleUsers.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm">No merchants registered yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-left">
|
||||||
|
<th className="py-2 pr-4 font-medium">Address</th>
|
||||||
|
<th className="py-2 pr-4 font-medium">Invoices</th>
|
||||||
|
<th className="py-2 pr-4 font-medium">Created</th>
|
||||||
|
<th className="py-2 font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{visibleUsers.map((user) => (
|
||||||
|
<tr key={user.id} className="border-b border-border/50">
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
<span className="font-mono text-xs break-all">
|
||||||
|
{user.payment_address.slice(0, 8)}…{user.payment_address.slice(-8)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4">{user.invoice_count}</td>
|
||||||
|
<td className="py-3 pr-4 text-muted-foreground">
|
||||||
|
{user.created_at
|
||||||
|
? new Date(user.created_at).toLocaleDateString()
|
||||||
|
: "—"}
|
||||||
|
</td>
|
||||||
|
<td className="py-3">
|
||||||
|
{confirmDelete === user.id ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<form action={deleteAction}>
|
||||||
|
<input type="hidden" name="user_id" value={user.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={deletePending}
|
||||||
|
className="text-xs text-red-500 hover:text-red-700 font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmDelete(null)}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmDelete(user.id)}
|
||||||
|
className="text-xs text-red-500 hover:text-red-700"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deleteState.error && (
|
||||||
|
<p className="mt-3 text-sm text-red-500">{deleteState.error}</p>
|
||||||
|
)}
|
||||||
|
{deleteState.success && (
|
||||||
|
<p className="mt-3 text-sm text-green-600">{deleteState.success}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { fetchAdminUsers } from "./actions";
|
||||||
|
import { AdminLoginForm } from "./admin-login-form";
|
||||||
|
import { AdminPanel } from "./admin-panel";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function AdminPage() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const adminKey = cookieStore.get("wowcheckout_admin_key")?.value;
|
||||||
|
|
||||||
|
if (!adminKey) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center px-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<h1 className="text-2xl font-bold mb-6 text-center">Admin</h1>
|
||||||
|
<AdminLoginForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await fetchAdminUsers();
|
||||||
|
|
||||||
|
if (users === null) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center px-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<h1 className="text-2xl font-bold mb-6 text-center">Admin</h1>
|
||||||
|
<p className="text-center text-red-500 mb-4">Invalid or expired admin key.</p>
|
||||||
|
<AdminLoginForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AdminPanel users={users} />;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user