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

- 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:
NW
2026-08-11 18:59:18 +01:00
parent b3d3018057
commit e90dcb60c9
8 changed files with 610 additions and 15 deletions

View File

@@ -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>
);
}