From 3b8e6416c412082b9a5f242259176f6f8cbc4643 Mon Sep 17 00:00:00 2001 From: NW Date: Sun, 9 Aug 2026 15:27:48 +0100 Subject: [PATCH] =?UTF-8?q?feat(admin):=20Bot=20Configuration=20=E2=80=94?= =?UTF-8?q?=20eye=20toggle=20to=20reveal=20masked=20values=20(token,=20sec?= =?UTF-8?q?rets)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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.) --- admin-next/src/app/api/settings/route.ts | 88 ++++++++++++++----- .../src/components/settings/settings-page.tsx | 64 ++++++++++++-- docker-compose.yml | 22 +++++ 3 files changed, 147 insertions(+), 27 deletions(-) diff --git a/admin-next/src/app/api/settings/route.ts b/admin-next/src/app/api/settings/route.ts index 7e21119..9255924 100755 --- a/admin-next/src/app/api/settings/route.ts +++ b/admin-next/src/app/api/settings/route.ts @@ -1,28 +1,72 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAuth } from '@/lib/auth-middleware'; -const SETTINGS: Record = { - 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 { + 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 { + 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 { diff --git a/admin-next/src/components/settings/settings-page.tsx b/admin-next/src/components/settings/settings-page.tsx index b391901..6dbc4f0 100755 --- a/admin-next/src/components/settings/settings-page.tsx +++ b/admin-next/src/components/settings/settings-page.tsx @@ -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(null); + const [revealed, setRevealed] = useState>({}); + const [revealing, setRevealing] = useState(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() { ) : isMasked ? ( - +
+ handleChange(key, e.target.value)} + className="flex-1 font-mono" + type={revealed[key] !== undefined ? "text" : "password"} + /> + +
) : (