feat(admin): integrate Next.js admin panel (admin-next/) from feat/nextjs-admin

- Add admin-next/: full Next.js + Prisma + shadcn admin panel (45 API routes, 76 components)
- docker-compose: tg_shop_admin service (port 3000, shared db/shop.db, prisma), bot talks to it via ADMIN_CHAT_URL=http://tg_shop_admin:3000/api/chat
- tor-proxy now proxies onion admin to tg_shop_admin:3000 (new panel)
- chatbotService: ADMIN_CHAT_URL default = http://tg_shop_admin:3000/api/chat (removed localhost:3100 anachronism)
- .env admin secrets gitignored (admin-next/.env)
This commit is contained in:
NW
2026-08-08 01:31:45 +01:00
parent 62534dbe85
commit a4a5fd449d
161 changed files with 25681 additions and 5 deletions

View File

@@ -0,0 +1,386 @@
"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 { 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 } 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);
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 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 ? (
<Input
id={key}
value={MASKED_PLACEHOLDER}
disabled
className="max-w-md"
/>
) : (
<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>
</div>
);
}