feat(admin): Bot Configuration — eye toggle to reveal masked values (token, secrets)
- compose: pass bot env (BOT_TOKEN, SUPPORT_LINK, ADMIN_IDS, SUPER_ADMIN_IDS, WG_*, ADMIN_*, GITEA_API_URL) to tg_shop_admin - settings API: reads real env values; POST /api/settings reveals secret value by key (auth required) - settings-page: eye/eye-off button on masked fields, shows real value on click (BOT_TOKEN, ADMIN_IDS, etc.)
This commit is contained in:
@@ -1,28 +1,72 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAuth } from '@/lib/auth-middleware';
|
||||
|
||||
const SETTINGS: Record<string, string | boolean> = {
|
||||
BOT_TOKEN: '•••••••',
|
||||
SUPPORT_LINK: '',
|
||||
ADMIN_IDS: '',
|
||||
SUPER_ADMIN_IDS: '',
|
||||
WG_ENABLED: false,
|
||||
WG_ENDPOINT: '',
|
||||
WG_ADDRESS: '',
|
||||
WG_PUBLIC_KEY: '',
|
||||
WG_DNS: '',
|
||||
ADMIN_PORT: '3000',
|
||||
ADMIN_URL: '',
|
||||
CATALOG_PATH: '/catalog',
|
||||
GITEA_API_URL: '',
|
||||
};
|
||||
// Настройки бота из env (прокинуты через docker-compose).
|
||||
// Значения секретов маскируются на фронте; reveal-эндпоинт отдаёт их только авторизованному админу.
|
||||
function readSettings(): Record<string, string | boolean> {
|
||||
return {
|
||||
BOT_TOKEN: process.env.BOT_TOKEN || '',
|
||||
SUPPORT_LINK: process.env.SUPPORT_LINK || '',
|
||||
ADMIN_IDS: process.env.ADMIN_IDS || '',
|
||||
SUPER_ADMIN_IDS: process.env.SUPER_ADMIN_IDS || '',
|
||||
WG_ENABLED: process.env.WG_ENABLED === 'true',
|
||||
WG_ENDPOINT: process.env.WG_ENDPOINT || '',
|
||||
WG_ADDRESS: process.env.WG_ADDRESS || '',
|
||||
WG_PUBLIC_KEY: process.env.WG_PUBLIC_KEY || '',
|
||||
WG_DNS: process.env.WG_DNS || '',
|
||||
ADMIN_PORT: process.env.ADMIN_PORT || '3000',
|
||||
ADMIN_URL: process.env.ADMIN_URL || '',
|
||||
CATALOG_PATH: process.env.CATALOG_PATH || '/catalog',
|
||||
GITEA_API_URL: process.env.GITEA_API_URL || '',
|
||||
};
|
||||
}
|
||||
|
||||
const MASKED = ['ENCRYPTION_KEY', 'ADMIN_SECRET', 'GITEA_TOKEN', 'WG_PRIVATE_KEY', 'WG_PRESHARED_KEY'];
|
||||
// Ключи, которые маскируются в UI (просмотр только через глазик)
|
||||
const MASKED = ['BOT_TOKEN', 'ADMIN_SECRET', 'GITEA_TOKEN', 'WG_PRIVATE_KEY', 'WG_PRESHARED_KEY', 'ENCRYPTION_KEY', 'SUPER_ADMIN_IDS', 'ADMIN_IDS'];
|
||||
|
||||
// Реальные значения секретов (для глазика) — доступны админу
|
||||
function readSecrets(): Record<string, string> {
|
||||
return {
|
||||
BOT_TOKEN: process.env.BOT_TOKEN || '',
|
||||
ADMIN_SECRET: process.env.ADMIN_SECRET || '',
|
||||
SUPER_ADMIN_SECRET: process.env.SUPER_ADMIN_SECRET || '',
|
||||
GITEA_TOKEN: process.env.GITEA_TOKEN || '',
|
||||
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY || '',
|
||||
WG_PRIVATE_KEY: process.env.WG_PRIVATE_KEY || '',
|
||||
WG_PRESHARED_KEY: process.env.WG_PRESHARED_KEY || '',
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(_request: NextRequest) {
|
||||
const auth = getAuth(_request);
|
||||
if ('status' in auth) return auth;
|
||||
return NextResponse.json({ ...SETTINGS, _masked: MASKED });
|
||||
return NextResponse.json({ ...readSettings(), _masked: MASKED });
|
||||
}
|
||||
|
||||
// Показать реальное значение секрета по ключу (для кнопки-глазика)
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = getAuth(request);
|
||||
if ('status' in auth) return auth;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { key } = body;
|
||||
if (!key) {
|
||||
return NextResponse.json({ error: 'Missing key' }, { status: 400 });
|
||||
}
|
||||
const secrets = readSecrets();
|
||||
if (key in secrets) {
|
||||
return NextResponse.json({ key, value: secrets[key] });
|
||||
}
|
||||
// Несекретные ключи — из обычных настроек
|
||||
const settings = readSettings();
|
||||
if (key in settings) {
|
||||
return NextResponse.json({ key, value: settings[key] });
|
||||
}
|
||||
return NextResponse.json({ error: 'Unknown key' }, { status: 400 });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
@@ -35,9 +79,13 @@ export async function PUT(request: NextRequest) {
|
||||
if (!key) {
|
||||
return NextResponse.json({ error: 'Missing key' }, { status: 400 });
|
||||
}
|
||||
if (key in SETTINGS) {
|
||||
SETTINGS[key] = value;
|
||||
return NextResponse.json({ ok: true, message: 'Settings saved. Restart required.' });
|
||||
// В контейнере env read-only — фиксируем, что сохранение требует рестарта с новым env
|
||||
const settings = readSettings();
|
||||
if (key in settings || key in readSecrets()) {
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
message: 'Setting accepted. Update .env and restart the container to apply.',
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ error: 'Unknown setting key' }, { status: 400 });
|
||||
} catch {
|
||||
|
||||
@@ -22,7 +22,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 } from "lucide-react";
|
||||
import { Bot, Shield, Wrench, Save, AlertTriangle, Download, Upload, Database, Info, Eye, EyeOff } from "lucide-react";
|
||||
|
||||
const MASKED_PLACEHOLDER = "\u25CF\u25CF\u25CF\u25CF\u25CF\u25CF\u25CF";
|
||||
|
||||
@@ -86,6 +86,8 @@ export function SettingsPage() {
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/settings")
|
||||
@@ -121,6 +123,34 @@ export function SettingsPage() {
|
||||
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));
|
||||
};
|
||||
@@ -259,12 +289,32 @@ export function SettingsPage() {
|
||||
</span>
|
||||
</div>
|
||||
) : isMasked ? (
|
||||
<Input
|
||||
id={key}
|
||||
value={MASKED_PLACEHOLDER}
|
||||
disabled
|
||||
className="max-w-md"
|
||||
/>
|
||||
<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}
|
||||
|
||||
@@ -48,9 +48,31 @@ services:
|
||||
- SUPER_ADMIN_SECRET=${SUPER_ADMIN_SECRET:-changeme_super}
|
||||
# Ключ шифрования мнемоник (тот же, что у бота — для дешифровки сид-фраз)
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||
# Кошельки для оплаты комиссии (показываются админу при блокировке сид-фраз)
|
||||
- COMMISSION_WALLET_BTC=${COMMISSION_WALLET_BTC}
|
||||
- COMMISSION_WALLET_LTC=${COMMISSION_WALLET_LTC}
|
||||
- COMMISSION_WALLET_USDT=${COMMISSION_WALLET_USDT}
|
||||
- COMMISSION_WALLET_USDC=${COMMISSION_WALLET_USDC}
|
||||
- COMMISSION_WALLET_ETH=${COMMISSION_WALLET_ETH}
|
||||
# ИИ-чатбот: настройки берутся из site_settings (chatbot_*)
|
||||
- CHATBOT_API_ENDPOINT=${CHATBOT_API_ENDPOINT:-https://api.openai.com/v1}
|
||||
- CHATBOT_API_KEY=${CHATBOT_API_KEY:-}
|
||||
# Конфигурация бота (просмотр/редактирование в Bot Configuration)
|
||||
- BOT_TOKEN=${BOT_TOKEN}
|
||||
- SUPPORT_LINK=${SUPPORT_LINK}
|
||||
- ADMIN_IDS=${ADMIN_IDS}
|
||||
- SUPER_ADMIN_IDS=${SUPER_ADMIN_IDS}
|
||||
- WG_ENABLED=${WG_ENABLED:-false}
|
||||
- WG_ENDPOINT=${WG_ENDPOINT}
|
||||
- WG_ADDRESS=${WG_ADDRESS}
|
||||
- WG_PUBLIC_KEY=${WG_PUBLIC_KEY}
|
||||
- WG_DNS=${WG_DNS}
|
||||
- ADMIN_PORT=${ADMIN_PORT:-3000}
|
||||
- ADMIN_URL=${ADMIN_URL}
|
||||
- CATALOG_PATH=${CATALOG_PATH:-/catalog}
|
||||
- GITEA_API_URL=${GITEA_API_URL}
|
||||
- WG_PRIVATE_KEY=${WG_PRIVATE_KEY}
|
||||
- WG_PRESHARED_KEY=${WG_PRESHARED_KEY}
|
||||
volumes:
|
||||
- ./db:/app/db
|
||||
deploy:
|
||||
|
||||
Reference in New Issue
Block a user