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:
23
admin-next/src/lib/auth-middleware.ts
Executable file
23
admin-next/src/lib/auth-middleware.ts
Executable 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
69
admin-next/src/lib/auth.ts
Executable 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;
|
||||
}
|
||||
38
admin-next/src/lib/chatbot-config.ts
Executable file
38
admin-next/src/lib/chatbot-config.ts
Executable 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
25
admin-next/src/lib/clipboard.ts
Executable 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
13
admin-next/src/lib/db.ts
Executable 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
6
admin-next/src/lib/utils.ts
Executable 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))
|
||||
}
|
||||
Reference in New Issue
Block a user