feat: shop activation + DB commission wallets + onion display (deploy pipeline)
All checks were successful
Release: multi-arch Docker images / build-push (push) Successful in 11m28s
All checks were successful
Release: multi-arch Docker images / build-push (push) Successful in 11m28s
- src/services/commissionService.js: read commission wallets and shop_activated from site_settings (DB) with env fallback, 30s cache - src/handlers/adminHandlers/adminWalletsHandler.js: commission wallets from CommissionService instead of static config - src/handlers/userHandlers/userHandler.js: canUseBot blocks all users when shop not activated - src/migrations/015_shop_activation.js: seed shop_activated + commission_wallet_* into site_settings; register in runner - admin-next: /api/system/info (onion hosts), /api/commission-wallets GET/PUT (super admin), /api/activation GET/PUT (super admin); lib/commission-wallets.ts shared helper; onion badge in header; shop activation card in settings; wallet editor in wallets page - test: commissionService.test.js (10 cases: DB/env fallback, caching)
This commit is contained in:
@@ -22,8 +22,9 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Moon, Sun, LogOut, User, Search } from "lucide-react";
|
||||
import { Moon, Sun, LogOut, User, Search, Globe, Copy } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { toast } from "sonner";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { CommandPalette, openCommandPalette } from "@/components/layout/command-palette";
|
||||
import { AppBreadcrumbs } from "@/components/layout/breadcrumbs";
|
||||
@@ -79,6 +80,7 @@ export function AdminHeader() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { role, logout } = useAuthStore();
|
||||
const [logoutOpen, setLogoutOpen] = useState(false);
|
||||
const [onion, setOnion] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => setHash(window.location.hash.slice(1) || "/");
|
||||
@@ -87,6 +89,35 @@ export function AdminHeader() {
|
||||
return () => window.removeEventListener("hashchange", update);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/system/info");
|
||||
if (!res.ok) throw new Error("Failed to fetch system info");
|
||||
const data = await res.json();
|
||||
if (cancelled) return;
|
||||
setOnion(data.adminOnion || "");
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
setOnion("");
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const copyOnion = async () => {
|
||||
if (!onion) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(onion);
|
||||
toast.success("Onion address copied");
|
||||
} catch {
|
||||
toast.error("Failed to copy onion address");
|
||||
}
|
||||
};
|
||||
|
||||
const title =
|
||||
pageTitles[hash] ||
|
||||
(hash.startsWith("/users/")
|
||||
@@ -105,6 +136,18 @@ export function AdminHeader() {
|
||||
</h1>
|
||||
{/* 1. Clock (hidden on mobile) */}
|
||||
<RealtimeClock />
|
||||
{/* 1b. Onion host badge (hidden on mobile) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyOnion}
|
||||
disabled={!onion}
|
||||
title={onion ? "Copy onion address" : "Onion address not ready yet"}
|
||||
className="hidden md:flex items-center gap-1.5 rounded-md border border-border bg-muted/50 px-2 py-1 text-xs font-mono text-muted-foreground hover:bg-muted transition-colors shrink-0 max-w-[220px]"
|
||||
>
|
||||
<Globe className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{onion || "onion: —"}</span>
|
||||
{onion && <Copy className="size-3 shrink-0 opacity-60" />}
|
||||
</button>
|
||||
{/* 2. Command palette search button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { toast } from "sonner";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { Bot, Shield, Wrench, Save, AlertTriangle, Download, Upload, Database, Info, Eye, EyeOff } from "lucide-react";
|
||||
import { Bot, Shield, Wrench, Save, AlertTriangle, Download, Upload, Database, Info, Eye, EyeOff, ShieldCheck, Power, Copy, Globe, Link2 } from "lucide-react";
|
||||
|
||||
const MASKED_PLACEHOLDER = "\u25CF\u25CF\u25CF\u25CF\u25CF\u25CF\u25CF";
|
||||
|
||||
@@ -88,6 +89,83 @@ export function SettingsPage() {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [revealed, setRevealed] = useState<Record<string, string>>({});
|
||||
const [revealing, setRevealing] = useState<string | null>(null);
|
||||
const [activated, setActivated] = useState<boolean | null>(null);
|
||||
const [activationLoading, setActivationLoading] = useState(false);
|
||||
const [activationDialogOpen, setActivationDialogOpen] = useState(false);
|
||||
const [onion, setOnion] = useState("");
|
||||
const [lanUrl, setLanUrl] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSuperAdmin) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/activation");
|
||||
if (!res.ok) throw new Error("Failed to fetch activation");
|
||||
const data = await res.json();
|
||||
if (!cancelled) setActivated(!!data.activated);
|
||||
} catch {
|
||||
if (!cancelled) setActivated(null);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isSuperAdmin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSuperAdmin) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/system/info");
|
||||
if (!res.ok) throw new Error("Failed to fetch system info");
|
||||
const data = await res.json();
|
||||
if (cancelled) return;
|
||||
setOnion(data.adminOnion || "");
|
||||
setLanUrl(data.lanUrl || "");
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
setOnion("");
|
||||
setLanUrl("");
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isSuperAdmin]);
|
||||
|
||||
const copyText = async (text: string, label: string) => {
|
||||
if (!text) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(`${label} copied`);
|
||||
} catch {
|
||||
toast.error(`Failed to copy ${label}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleActivation = async () => {
|
||||
if (activated === null) return;
|
||||
const next = !activated;
|
||||
setActivationLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/activation", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ activated: next }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to update activation");
|
||||
const data = await res.json();
|
||||
setActivated(!!data.activated);
|
||||
toast.success(next ? "Shop activated" : "Shop blocked");
|
||||
} catch {
|
||||
toast.error("Failed to update shop activation");
|
||||
} finally {
|
||||
setActivationLoading(false);
|
||||
setActivationDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/settings")
|
||||
@@ -431,6 +509,110 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Shop Activation (super admin only) */}
|
||||
{isSuperAdmin && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ShieldCheck className="h-4 w-4 text-emerald-500" />
|
||||
Shop Activation
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Control whether the shop is active and accepting orders. Blocking the shop
|
||||
immediately disables purchasing for all users.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{activated === null ? (
|
||||
<Badge variant="secondary">Checking...</Badge>
|
||||
) : activated ? (
|
||||
<Badge className="bg-emerald-500/15 text-emerald-400">Activated</Badge>
|
||||
) : (
|
||||
<Badge className="bg-red-500/15 text-red-400">Blocked</Badge>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{activated === null
|
||||
? "Loading activation status..."
|
||||
: activated
|
||||
? "The shop is currently active and accepting orders."
|
||||
: "The shop is currently blocked. Users cannot place orders."}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Onion:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText(onion, "Onion address")}
|
||||
disabled={!onion}
|
||||
title={onion ? "Copy onion address" : "Onion address not ready yet"}
|
||||
className="inline-flex items-center gap-1.5 rounded border border-border bg-muted/50 px-2 py-0.5 font-mono text-xs text-muted-foreground hover:bg-muted transition-colors disabled:opacity-50"
|
||||
>
|
||||
{onion || "—"}
|
||||
{onion && <Copy className="h-3 w-3 opacity-60" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link2 className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">LAN:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText(lanUrl, "LAN URL")}
|
||||
disabled={!lanUrl}
|
||||
title={lanUrl ? "Copy LAN URL" : "LAN URL not ready yet"}
|
||||
className="inline-flex items-center gap-1.5 rounded border border-border bg-muted/50 px-2 py-0.5 font-mono text-xs text-muted-foreground hover:bg-muted transition-colors disabled:opacity-50"
|
||||
>
|
||||
{lanUrl || "—"}
|
||||
{lanUrl && <Copy className="h-3 w-3 opacity-60" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={activationDialogOpen} onOpenChange={setActivationDialogOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant={activated ? "destructive" : "default"}
|
||||
className="gap-2"
|
||||
disabled={activated === null || activationLoading}
|
||||
>
|
||||
<Power className="h-4 w-4" />
|
||||
{activated ? "Block Shop" : "Activate Shop"}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{activated ? "Block Shop" : "Activate Shop"}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{activated
|
||||
? "Blocking the shop will immediately prevent all users from placing orders. This action can be reversed at any time."
|
||||
: "Activating the shop will allow users to place orders again. This action can be reversed at any time."}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleToggleActivation}
|
||||
disabled={activationLoading}
|
||||
className={activated ? "bg-destructive text-white hover:bg-destructive/90" : ""}
|
||||
>
|
||||
{activationLoading
|
||||
? "Updating..."
|
||||
: activated
|
||||
? "Block Shop"
|
||||
: "Activate Shop"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
27
admin-next/src/lib/commission-wallets.ts
Normal file
27
admin-next/src/lib/commission-wallets.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
const WALLET_TYPES = ['BTC', 'LTC', 'USDT', 'USDC', 'ETH'] as const;
|
||||
|
||||
const ENV_KEYS: Record<string, string> = {
|
||||
BTC: 'COMMISSION_WALLET_BTC',
|
||||
LTC: 'COMMISSION_WALLET_LTC',
|
||||
USDT: 'COMMISSION_WALLET_USDT',
|
||||
USDC: 'COMMISSION_WALLET_USDC',
|
||||
ETH: 'COMMISSION_WALLET_ETH',
|
||||
};
|
||||
|
||||
export async function getCommissionWallets(): Promise<Record<string, string>> {
|
||||
const keys = WALLET_TYPES.map((t) => `commission_wallet_${t}`);
|
||||
const rows = await db.siteSetting.findMany({
|
||||
where: { key: { in: keys } },
|
||||
});
|
||||
|
||||
const wallets: Record<string, string> = {};
|
||||
for (const type of WALLET_TYPES) {
|
||||
const dbKey = `commission_wallet_${type}`;
|
||||
const row = rows.find((r) => r.key === dbKey);
|
||||
wallets[type] = row?.value || process.env[ENV_KEYS[type]] || '';
|
||||
}
|
||||
|
||||
return wallets;
|
||||
}
|
||||
Reference in New Issue
Block a user