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,23 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyToken, type AdminRole } from '@/lib/auth';
export function getAuth(request: NextRequest): { role: AdminRole } | NextResponse {
const token = request.cookies.get('admin_token')?.value;
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const payload = verifyToken(token);
if (!payload) {
return NextResponse.json({ error: 'Invalid or expired token' }, { status: 401 });
}
return { role: payload.role };
}
export function requireSuperAuth(request: NextRequest) {
const auth = getAuth(request);
if ('status' in auth) return auth;
if (auth.role !== 'super_admin') {
return NextResponse.json({ error: 'Super admin required' }, { status: 403 });
}
return auth;
}

69
admin-next/src/lib/auth.ts Executable file
View File

@@ -0,0 +1,69 @@
import crypto from 'crypto';
const ADMIN_SECRET = process.env.ADMIN_SECRET || 'changeme';
const SUPER_ADMIN_SECRET = process.env.SUPER_ADMIN_SECRET || '';
export type AdminRole = 'admin' | 'super_admin';
export interface AdminPayload {
role: AdminRole;
jti: string;
iat: number;
exp: number;
}
export function createToken(secret: string): string {
const isSuper = SUPER_ADMIN_SECRET && secret === SUPER_ADMIN_SECRET;
const isAdmin = secret === ADMIN_SECRET;
if (!isAdmin && !isSuper) return '';
// If no super secret set, all admins are super
const role: AdminRole = (!SUPER_ADMIN_SECRET || isSuper) ? 'super_admin' : 'admin';
const payload: AdminPayload = {
role,
jti: crypto.randomUUID(),
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 86400,
};
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
const signingKey = SUPER_ADMIN_SECRET || ADMIN_SECRET;
const sig = crypto
.createHmac('sha256', signingKey)
.update(payloadB64)
.digest('base64url');
return `${payloadB64}.${sig}`;
}
export function verifyToken(token: string): AdminPayload | null {
try {
const [payloadB64, sig] = token.split('.');
if (!payloadB64 || !sig) return null;
const signingKey = SUPER_ADMIN_SECRET || ADMIN_SECRET;
const expected = crypto
.createHmac('sha256', signingKey)
.update(payloadB64)
.digest('base64url');
if (sig !== expected) return null;
const payload: AdminPayload = JSON.parse(
Buffer.from(payloadB64, 'base64url').toString()
);
if (payload.exp < Math.floor(Date.now() / 1000)) return null;
return payload;
} catch {
return null;
}
}
export function verifyReAuth(token: string): boolean {
const isSuper = SUPER_ADMIN_SECRET && token === SUPER_ADMIN_SECRET;
const isAdmin = token === ADMIN_SECRET;
return isSuper || isAdmin;
}

View File

@@ -0,0 +1,38 @@
// Shared chatbot config cache — used by admin/chatbot and chat routes
let _cachedAt = 0;
let _cachedConfig: Record<string, string> | null = null;
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
export function invalidateChatbotCache() {
_cachedAt = 0;
_cachedConfig = null;
}
export function resetCacheTimestamp() {
_cachedAt = 0;
}
export async function getChatbotConfig(): Promise<Record<string, string>> {
const now = Date.now();
if (_cachedConfig && now - _cachedAt < CACHE_TTL) {
return _cachedConfig;
}
// Dynamic import to avoid circular dependency
const { db } = await import('@/lib/db');
const rows = await db.siteSetting.findMany({
where: {
key: { startsWith: 'chatbot_' },
},
});
const config: Record<string, string> = {};
for (const row of rows) {
config[row.key] = row.value;
}
_cachedConfig = config;
_cachedAt = now;
return config;
}

25
admin-next/src/lib/clipboard.ts Executable file
View File

@@ -0,0 +1,25 @@
export async function copyToClipboard(text: string): Promise<boolean> {
try {
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// fallback below
}
try {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
return true;
} catch {
return false;
}
}

13
admin-next/src/lib/db.ts Executable file
View File

@@ -0,0 +1,13 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const db =
globalForPrisma.prisma ??
new PrismaClient({
log: ['error'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db

6
admin-next/src/lib/utils.ts Executable file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}