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 { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getAuth } from '@/lib/auth-middleware';
|
import { getAuth } from '@/lib/auth-middleware';
|
||||||
|
|
||||||
const SETTINGS: Record<string, string | boolean> = {
|
// Настройки бота из env (прокинуты через docker-compose).
|
||||||
BOT_TOKEN: '•••••••',
|
// Значения секретов маскируются на фронте; reveal-эндпоинт отдаёт их только авторизованному админу.
|
||||||
SUPPORT_LINK: '',
|
function readSettings(): Record<string, string | boolean> {
|
||||||
ADMIN_IDS: '',
|
return {
|
||||||
SUPER_ADMIN_IDS: '',
|
BOT_TOKEN: process.env.BOT_TOKEN || '',
|
||||||
WG_ENABLED: false,
|
SUPPORT_LINK: process.env.SUPPORT_LINK || '',
|
||||||
WG_ENDPOINT: '',
|
ADMIN_IDS: process.env.ADMIN_IDS || '',
|
||||||
WG_ADDRESS: '',
|
SUPER_ADMIN_IDS: process.env.SUPER_ADMIN_IDS || '',
|
||||||
WG_PUBLIC_KEY: '',
|
WG_ENABLED: process.env.WG_ENABLED === 'true',
|
||||||
WG_DNS: '',
|
WG_ENDPOINT: process.env.WG_ENDPOINT || '',
|
||||||
ADMIN_PORT: '3000',
|
WG_ADDRESS: process.env.WG_ADDRESS || '',
|
||||||
ADMIN_URL: '',
|
WG_PUBLIC_KEY: process.env.WG_PUBLIC_KEY || '',
|
||||||
CATALOG_PATH: '/catalog',
|
WG_DNS: process.env.WG_DNS || '',
|
||||||
GITEA_API_URL: '',
|
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) {
|
export async function GET(_request: NextRequest) {
|
||||||
const auth = getAuth(_request);
|
const auth = getAuth(_request);
|
||||||
if ('status' in auth) return auth;
|
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) {
|
export async function PUT(request: NextRequest) {
|
||||||
@@ -35,9 +79,13 @@ export async function PUT(request: NextRequest) {
|
|||||||
if (!key) {
|
if (!key) {
|
||||||
return NextResponse.json({ error: 'Missing key' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing key' }, { status: 400 });
|
||||||
}
|
}
|
||||||
if (key in SETTINGS) {
|
// В контейнере env read-only — фиксируем, что сохранение требует рестарта с новым env
|
||||||
SETTINGS[key] = value;
|
const settings = readSettings();
|
||||||
return NextResponse.json({ ok: true, message: 'Settings saved. Restart required.' });
|
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 });
|
return NextResponse.json({ error: 'Unknown setting key' }, { status: 400 });
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
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";
|
const MASKED_PLACEHOLDER = "\u25CF\u25CF\u25CF\u25CF\u25CF\u25CF\u25CF";
|
||||||
|
|
||||||
@@ -86,6 +86,8 @@ export function SettingsPage() {
|
|||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [revealed, setRevealed] = useState<Record<string, string>>({});
|
||||||
|
const [revealing, setRevealing] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/settings")
|
fetch("/api/settings")
|
||||||
@@ -121,6 +123,34 @@ export function SettingsPage() {
|
|||||||
setSettings((prev) => (prev ? { ...prev, [key]: value } : prev));
|
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) => {
|
const handleSwitchChange = (key: string, checked: boolean) => {
|
||||||
setSettings((prev) => (prev ? { ...prev, [key]: checked } : prev));
|
setSettings((prev) => (prev ? { ...prev, [key]: checked } : prev));
|
||||||
};
|
};
|
||||||
@@ -259,12 +289,32 @@ export function SettingsPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : isMasked ? (
|
) : isMasked ? (
|
||||||
|
<div className="flex gap-2 max-w-md">
|
||||||
<Input
|
<Input
|
||||||
id={key}
|
id={key}
|
||||||
value={MASKED_PLACEHOLDER}
|
value={revealed[key] !== undefined ? revealed[key] : MASKED_PLACEHOLDER}
|
||||||
disabled
|
disabled={revealed[key] === undefined}
|
||||||
className="max-w-md"
|
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
|
<Input
|
||||||
id={key}
|
id={key}
|
||||||
|
|||||||
@@ -48,9 +48,31 @@ services:
|
|||||||
- SUPER_ADMIN_SECRET=${SUPER_ADMIN_SECRET:-changeme_super}
|
- SUPER_ADMIN_SECRET=${SUPER_ADMIN_SECRET:-changeme_super}
|
||||||
# Ключ шифрования мнемоник (тот же, что у бота — для дешифровки сид-фраз)
|
# Ключ шифрования мнемоник (тот же, что у бота — для дешифровки сид-фраз)
|
||||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
- 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_*)
|
# ИИ-чатбот: настройки берутся из site_settings (chatbot_*)
|
||||||
- CHATBOT_API_ENDPOINT=${CHATBOT_API_ENDPOINT:-https://api.openai.com/v1}
|
- CHATBOT_API_ENDPOINT=${CHATBOT_API_ENDPOINT:-https://api.openai.com/v1}
|
||||||
- CHATBOT_API_KEY=${CHATBOT_API_KEY:-}
|
- 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:
|
volumes:
|
||||||
- ./db:/app/db
|
- ./db:/app/db
|
||||||
deploy:
|
deploy:
|
||||||
|
|||||||
Reference in New Issue
Block a user