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:
386
admin-next/src/components/settings/settings-page.tsx
Executable file
386
admin-next/src/components/settings/settings-page.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user