Files
telegram-shop/admin-next/src/components/settings/settings-page.tsx
NW e90dcb60c9
All checks were successful
Release: multi-arch Docker images / build-push (push) Successful in 11m28s
feat: shop activation + DB commission wallets + onion display (deploy pipeline)
- 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)
2026-08-11 18:59:18 +01:00

619 lines
25 KiB
TypeScript
Executable File

"use client";
import { useEffect, useState, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
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 {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} 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, ShieldCheck, Power, Copy, Globe, Link2 } from "lucide-react";
const MASKED_PLACEHOLDER = "\u25CF\u25CF\u25CF\u25CF\u25CF\u25CF\u25CF";
const KEY_META: Record<string, { label: string; description: string }> = {
BOT_TOKEN: { label: "Bot Token", description: "Telegram Bot API token from @BotFather" },
SUPPORT_LINK: { label: "Support Link", description: "URL shown to users for support" },
ADMIN_IDS: { label: "Admin Telegram IDs", description: "Comma-separated list of admin user IDs" },
SUPER_ADMIN_IDS: { label: "Super Admin IDs", description: "Comma-separated list of super admin IDs" },
WG_ENABLED: { label: "WireGuard VPN", description: "Enable WireGuard VPN for product delivery" },
WG_ENDPOINT: { label: "VPN Endpoint", description: "WireGuard server endpoint address" },
WG_ADDRESS: { label: "VPN Address", description: "WireGuard client address" },
WG_PUBLIC_KEY: { label: "VPN Public Key", description: "WireGuard server public key" },
WG_DNS: { label: "VPN DNS", description: "DNS server for VPN connection" },
ADMIN_PORT: { label: "Admin Port", description: "Port for the admin panel HTTP server" },
ADMIN_URL: { label: "Admin URL", description: "Public URL for the admin panel" },
CATALOG_PATH: { label: "Catalog Path", description: "File system path to the product catalog" },
GITEA_API_URL: { label: "Gitea API URL", description: "Gitea instance API endpoint URL" },
};
const SECTIONS = [
{
title: "Bot Configuration",
keys: ["BOT_TOKEN", "SUPPORT_LINK", "ADMIN_IDS", "SUPER_ADMIN_IDS"],
icon: Bot,
description: "Telegram bot and user management settings",
},
{
title: "WireGuard VPN",
keys: ["WG_ENABLED", "WG_ENDPOINT", "WG_ADDRESS", "WG_PUBLIC_KEY", "WG_DNS"],
icon: Shield,
description: "VPN configuration for secure product delivery",
},
{
title: "Admin Panel",
keys: ["ADMIN_PORT", "ADMIN_URL", "CATALOG_PATH", "GITEA_API_URL"],
icon: Wrench,
description: "Admin panel server and integration settings",
},
];
function SkeletonForm() {
return (
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-9 w-full" />
</div>
))}
</div>
);
}
export function SettingsPage() {
const { role } = useAuthStore();
const isSuperAdmin = role === "super_admin";
const [settings, setSettings] = useState<Record<string, string | boolean> | null>(null);
const [masked, setMasked] = useState<string[]>([]);
const [saving, setSaving] = useState<string | null>(null);
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const [importDialogOpen, setImportDialogOpen] = useState(false);
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")
.then((res) => res.json())
.then((data) => {
const m: string[] = data._masked || [];
setMasked(m);
delete data._masked;
setSettings(data);
})
.catch(() => toast.error("Failed to load settings"));
}, []);
const handleSave = async (key: string) => {
if (!settings) return;
setSaving(key);
try {
const res = await fetch("/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, value: settings[key] }),
});
if (!res.ok) throw new Error();
toast.success(`${KEY_META[key]?.label || key} saved. Restart required.`);
} catch {
toast.error(`Failed to save ${KEY_META[key]?.label || key}`);
} finally {
setSaving(null);
}
};
const handleChange = (key: string, value: string) => {
setSettings((prev) => (prev ? { ...prev, [key]: value } : prev));
};
// Показать/скрыть реальное значение секрета (глазик)
const handleReveal = async (key: string) => {
if (revealed[key] !== undefined) {
// Повторный клик — скрыть
setRevealed((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
return;
}
setRevealing(key);
try {
const res = await fetch("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key }),
});
if (!res.ok) throw new Error();
const data = await res.json();
setRevealed((prev) => ({ ...prev, [key]: data.value ?? "" }));
} catch {
toast.error(`Failed to reveal ${KEY_META[key]?.label || key}`);
} finally {
setRevealing(null);
}
};
const handleSwitchChange = (key: string, checked: boolean) => {
setSettings((prev) => (prev ? { ...prev, [key]: checked } : prev));
};
const handleExport = async () => {
setExporting(true);
try {
const res = await fetch("/api/settings/export");
if (!res.ok) throw new Error();
const data = await res.json();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `telegram-shop-backup-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("Data exported successfully");
} catch {
toast.error("Failed to export data");
} finally {
setExporting(false);
}
};
const handleImport = async () => {
const file = fileInputRef.current?.files?.[0];
if (!file) return;
setImporting(true);
try {
const text = await file.text();
const data = JSON.parse(text);
const res = await fetch("/api/settings/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error();
const result = await res.json();
if (result.ok) {
toast.info(result.message || "Import not yet implemented");
} else {
toast.error(result.error || "Import failed");
}
} catch {
toast.error("Failed to import data. Ensure the file is valid JSON.");
} finally {
setImporting(false);
setImportDialogOpen(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
return (
<div className="page-enter space-y-6">
<Alert className="border-yellow-500 bg-yellow-50 dark:bg-yellow-950/20">
<AlertTriangle className="h-4 w-4 text-yellow-600" />
<AlertDescription className="text-yellow-700 dark:text-yellow-400">
Restart the application to apply changes.
</AlertDescription>
</Alert>
<p className="text-sm text-muted-foreground">Configure bot settings, WireGuard VPN, and admin panel options. Changes require a restart.</p>
{!settings ? (
<div className="space-y-6">
{SECTIONS.map((s) => (
<Card key={s.title}>
<CardHeader className="pb-4">
<CardTitle className="flex items-center gap-2 text-base">
<s.icon className="h-4 w-4" />
{s.title}
</CardTitle>
<CardDescription>{s.description}</CardDescription>
</CardHeader>
<CardContent>
<SkeletonForm />
</CardContent>
</Card>
))}
</div>
) : (
<div className="space-y-6">
{SECTIONS.map((section) => {
const SectionIcon = section.icon;
return (
<Card key={section.title}>
<CardHeader className="pb-4">
<CardTitle className="flex items-center gap-2 text-base">
<SectionIcon className="h-4 w-4" />
{section.title}
</CardTitle>
<CardDescription>{section.description}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{section.keys.map((key) => {
const isMasked = masked.includes(key);
const isBool = key === "WG_ENABLED";
const value = String(settings[key] ?? "");
const meta = KEY_META[key];
return (
<div key={key} className="flex items-end gap-3">
<div className="flex-1 space-y-1.5">
<div className="flex items-center gap-1.5">
<Label htmlFor={key} className="text-sm">
{meta?.label || key}
</Label>
{meta && (
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground/50 cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{meta.description}
</TooltipContent>
</Tooltip>
)}
</div>
{meta && (
<p className="text-xs text-muted-foreground/60 mt-0.5">{meta.description}</p>
)}
{isBool ? (
<div className="flex items-center gap-2">
<Switch
id={key}
checked={settings[key] === true}
onCheckedChange={(checked) => handleSwitchChange(key, checked)}
/>
<span className="text-sm text-muted-foreground">
{settings[key] ? "Enabled" : "Disabled"}
</span>
</div>
) : isMasked ? (
<div className="flex gap-2 max-w-md">
<Input
id={key}
value={revealed[key] !== undefined ? revealed[key] : MASKED_PLACEHOLDER}
disabled={revealed[key] === undefined}
onChange={(e) => handleChange(key, e.target.value)}
className="flex-1 font-mono"
type={revealed[key] !== undefined ? "text" : "password"}
/>
<Button
size="icon"
variant="outline"
className="shrink-0"
onClick={() => handleReveal(key)}
disabled={revealing === key}
title={revealed[key] !== undefined ? "Hide value" : "Show value"}
>
{revealing === key ? (
<span className="h-4 w-4 animate-pulse">...</span>
) : revealed[key] !== undefined ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
) : (
<Input
id={key}
value={value}
onChange={(e) => handleChange(key, e.target.value)}
className="max-w-md"
/>
)}
</div>
{!isMasked && (
<Button
size="sm"
variant="outline"
onClick={() => handleSave(key)}
disabled={saving === key}
>
{saving === key ? (
"Saving..."
) : (
<>
<Save className="h-4 w-4 mr-1" />
Save
</>
)}
</Button>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
);
})}
</div>
)}
{/* Data Management Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Database className="h-4 w-4 text-orange-500" />
Data Management
</CardTitle>
<CardDescription>
Export all database records as JSON for backup, or import from a previous backup file.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-3">
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={handleExport}
disabled={exporting}
className="gap-2"
>
<Download className="h-4 w-4" />
{exporting ? "Exporting..." : "Export All Data"}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-4 w-4 text-muted-foreground/50 cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Exports all 10 database tables including users, wallets, purchases, and settings.
</TooltipContent>
</Tooltip>
</div>
{isSuperAdmin && (
<AlertDialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="gap-2">
<Upload className="h-4 w-4" />
Import Data
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>\u26A0\uFE0F Confirm Data Import</AlertDialogTitle>
<AlertDialogDescription>
Importing data will overwrite existing records. This is a dangerous operation
that cannot be undone. Make sure you have a recent backup before proceeding.
Only super admins can perform this action.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="py-2">
<input
ref={fileInputRef}
type="file"
accept=".json"
className="block w-full text-sm text-muted-foreground
file:mr-4 file:py-2 file:px-4
file:rounded-md file:border-0
file:text-sm file:font-semibold
file:bg-orange-50 file:text-orange-700
hover:file:bg-orange-100
dark:file:bg-orange-950 dark:file:text-orange-300"
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleImport}
disabled={importing}
>
{importing ? "Importing..." : "Proceed with Import"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</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>
);
}