feat: implement SQL dump import functionality and add Dev Management navigation

This commit is contained in:
seaznCode 2026-01-16 00:12:11 +01:00
parent 3c531daa91
commit 39cfda4729
4 changed files with 328 additions and 1 deletions

View File

@ -0,0 +1,49 @@
import { authFetch } from '../../../utils/authFetch';
import { log } from '../../../utils/logger';
export type SqlExecutionData = {
result?: any;
isMulti?: boolean;
};
export type SqlExecutionMeta = {
durationMs?: number;
};
export async function importSqlDump(file: File): Promise<{ ok: boolean; data?: SqlExecutionData; meta?: SqlExecutionMeta; message?: string }>{
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const url = `${BASE_URL}/api/admin/dev/sql`;
log('🧪 Dev SQL Import: POST', url);
try {
const form = new FormData();
form.append('file', file);
const res = await authFetch(url, {
method: 'POST',
headers: { Accept: 'application/json' },
body: form
});
let body: any = null;
try {
body = await res.clone().json();
} catch {
body = null;
}
if (res.status === 401) {
return { ok: false, message: 'Unauthorized. Please log in.' };
}
if (res.status === 403) {
return { ok: false, message: body?.error || 'Forbidden. Admin access required.' };
}
if (!res.ok) {
return { ok: false, message: body?.error || 'SQL execution failed.' };
}
return { ok: true, data: body?.data, meta: body?.meta };
} catch (e: any) {
log('❌ Dev SQL: network error', e?.message || e);
return { ok: false, message: 'Network error while executing SQL.' };
}
}

View File

@ -0,0 +1,197 @@
'use client'
import React from 'react'
import Header from '../../components/nav/Header'
import Footer from '../../components/Footer'
import PageTransitionEffect from '../../components/animation/pageTransitionEffect'
import { CommandLineIcon, PlayIcon, TrashIcon, ExclamationTriangleIcon, ArrowUpTrayIcon } from '@heroicons/react/24/outline'
import useAuthStore from '../../store/authStore'
import { useRouter } from 'next/navigation'
import { importSqlDump, SqlExecutionData, SqlExecutionMeta } from './hooks/executeSql'
export default function DevManagementPage() {
const router = useRouter()
const user = useAuthStore(s => s.user)
const isAdmin =
!!user &&
(
(user as any)?.role === 'admin' ||
(user as any)?.userType === 'admin' ||
(user as any)?.isAdmin === true ||
((user as any)?.roles?.includes?.('admin')) ||
(user as any)?.role === 'super_admin' ||
(user as any)?.userType === 'super_admin' ||
(user as any)?.isSuperAdmin === true ||
((user as any)?.roles?.includes?.('super_admin'))
)
const [authChecked, setAuthChecked] = React.useState(false)
React.useEffect(() => {
if (user === null) {
router.replace('/login')
return
}
if (user && !isAdmin) {
router.replace('/admin')
return
}
setAuthChecked(true)
}, [user, isAdmin, router])
const [selectedFile, setSelectedFile] = React.useState<File | null>(null)
const [loading, setLoading] = React.useState(false)
const [error, setError] = React.useState('')
const [result, setResult] = React.useState<SqlExecutionData | null>(null)
const [meta, setMeta] = React.useState<SqlExecutionMeta | null>(null)
const fileInputRef = React.useRef<HTMLInputElement | null>(null)
const runImport = async () => {
setError('')
if (!selectedFile) {
setError('Please select a SQL dump file.')
return
}
setLoading(true)
try {
const res = await importSqlDump(selectedFile)
if (!res.ok) {
setError(res.message || 'Failed to import SQL dump.')
return
}
setResult(res.data || null)
setMeta(res.meta || null)
} finally {
setLoading(false)
}
}
const clearResults = () => {
setResult(null)
setMeta(null)
setError('')
}
const onImportFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setSelectedFile(file)
e.target.value = ''
}
if (!authChecked) return null
return (
<PageTransitionEffect>
<div className="min-h-screen flex flex-col bg-gradient-to-br from-slate-50 via-white to-blue-50">
<Header />
<main className="flex-1 py-8 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto">
<header className="bg-white/90 backdrop-blur border border-blue-100 py-8 px-6 rounded-2xl shadow-lg flex flex-col gap-4">
<div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-xl bg-blue-100 border border-blue-200 flex items-center justify-center">
<CommandLineIcon className="h-6 w-6 text-blue-700" />
</div>
<div>
<h1 className="text-3xl font-extrabold text-blue-900">Dev Management</h1>
<p className="text-blue-700">Import SQL dump files to run database migrations.</p>
</div>
</div>
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 flex items-start gap-2">
<ExclamationTriangleIcon className="h-5 w-5 mt-0.5" />
<div>
<div className="font-semibold">Use with caution</div>
<div>SQL dumps run immediately and can modify production data.</div>
</div>
</div>
</header>
<section className="mt-8 grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 rounded-2xl bg-white border border-gray-100 shadow-lg p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-blue-900">SQL Dump Import</h2>
<div className="flex items-center gap-3">
<button
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<ArrowUpTrayIcon className="h-4 w-4" /> Import SQL
</button>
<button
onClick={clearResults}
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<TrashIcon className="h-4 w-4" /> Clear
</button>
<button
onClick={runImport}
disabled={loading}
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-800 disabled:opacity-60"
>
<PlayIcon className="h-4 w-4" /> {loading ? 'Importing...' : 'Import'}
</button>
</div>
</div>
<div className="rounded-xl border border-dashed border-gray-200 bg-slate-50 px-6 py-10 text-center">
<div className="text-sm text-gray-600">Select a .sql dump file using Import SQL.</div>
<div className="mt-2 text-xs text-gray-500">Only SQL dump files are supported.</div>
{selectedFile && (
<div className="mt-4 text-sm text-blue-900 font-semibold">Selected: {selectedFile.name}</div>
)}
</div>
<input ref={fileInputRef} type="file" accept=".sql,text/plain" className="hidden" onChange={onImportFile} />
{error && (
<div className="mt-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
)}
</div>
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-6">
<h3 className="text-lg font-semibold text-blue-900 mb-3">Result Summary</h3>
<div className="space-y-2 text-sm text-gray-700">
<div className="flex justify-between">
<span>Result Sets</span>
<span className="font-semibold text-blue-900">
{Array.isArray(result?.result) ? (result?.result as any[]).length : result?.result ? 1 : 0}
</span>
</div>
<div className="flex justify-between">
<span>Multi-statement</span>
<span className="font-semibold text-blue-900">{result?.isMulti ? 'Yes' : 'No'}</span>
</div>
<div className="flex justify-between">
<span>Duration</span>
<span className="font-semibold text-blue-900">{meta?.durationMs ? `${meta.durationMs} ms` : '-'}</span>
</div>
</div>
<div className="mt-6 text-xs text-gray-500">
Multi-statement SQL and dump files are supported. Use with caution.
</div>
</div>
</section>
<section className="mt-8 rounded-2xl bg-white border border-gray-100 shadow-lg p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-blue-900">Import Results</h2>
</div>
{!result && (
<div className="text-sm text-gray-500">No results yet. Import a SQL dump to see output.</div>
)}
{result?.result && (
<pre className="mt-2 rounded-lg bg-slate-50 border border-gray-200 p-4 text-xs text-gray-800 overflow-auto">
{JSON.stringify(result.result, null, 2)}
</pre>
)}
</section>
</div>
</main>
<Footer />
</div>
</PageTransitionEffect>
)
}

View File

@ -11,20 +11,36 @@ import {
ArrowRightIcon,
Squares2X2Icon,
BanknotesIcon,
ClipboardDocumentListIcon
ClipboardDocumentListIcon,
CommandLineIcon
} from '@heroicons/react/24/outline'
import { useMemo, useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useAdminUsers } from '../hooks/useAdminUsers'
import useAuthStore from '../store/authStore'
// env-based feature flags
const DISPLAY_MATRIX = process.env.NEXT_PUBLIC_DISPLAY_MATRIX !== 'false'
const DISPLAY_ABONEMENTS = process.env.NEXT_PUBLIC_DISPLAY_ABONEMMENTS !== 'false'
const DISPLAY_NEWS = process.env.NEXT_PUBLIC_DISPLAY_NEWS !== 'false'
const DISPLAY_DEV_MANAGEMENT = process.env.NEXT_PUBLIC_DISPLAY_DEV_MANAGEMENT !== 'false'
export default function AdminDashboardPage() {
const router = useRouter()
const { userStats, isAdmin } = useAdminUsers()
const user = useAuthStore(s => s.user)
const isAdminOrSuper =
!!user &&
(
(user as any)?.role === 'admin' ||
(user as any)?.userType === 'admin' ||
(user as any)?.isAdmin === true ||
((user as any)?.roles?.includes?.('admin')) ||
(user as any)?.role === 'super_admin' ||
(user as any)?.userType === 'super_admin' ||
(user as any)?.isSuperAdmin === true ||
((user as any)?.roles?.includes?.('super_admin'))
)
const [isClient, setIsClient] = useState(false)
const [isMobile, setIsMobile] = useState(false)
@ -318,6 +334,49 @@ export default function AdminDashboardPage() {
}`}
/>
</button>
{/* Dev Management */}
<button
type="button"
disabled={!DISPLAY_DEV_MANAGEMENT || !isAdminOrSuper}
onClick={DISPLAY_DEV_MANAGEMENT && isAdminOrSuper ? () => router.push('/admin/dev-management') : undefined}
className={`group w-full flex items-center justify-between rounded-lg px-4 py-4 ${
DISPLAY_DEV_MANAGEMENT && isAdminOrSuper
? 'border border-slate-200 bg-slate-50 hover:bg-slate-100 transform transition-transform duration-200 hover:scale-[1.02] hover:shadow-md'
: 'border border-gray-200 bg-gray-100 text-gray-400 cursor-not-allowed'
}`}
>
<div className="flex items-center gap-4">
<span
className={`inline-flex h-10 w-10 items-center justify-center rounded-md border ${
DISPLAY_DEV_MANAGEMENT && isAdminOrSuper
? 'bg-slate-100 border-slate-200 group-hover:animate-pulse'
: 'bg-gray-100 border-gray-300'
}`}
>
<CommandLineIcon className={`h-6 w-6 ${DISPLAY_DEV_MANAGEMENT && isAdminOrSuper ? 'text-slate-600' : 'text-gray-400'}`} />
</span>
<div className="text-left">
<div className="text-base font-semibold text-slate-900">Dev Management</div>
<div className="text-xs text-slate-700">Run SQL queries and dev tools</div>
{!DISPLAY_DEV_MANAGEMENT && (
<p className="mt-1 text-xs text-gray-500 italic">
This module is currently disabled in the system configuration.
</p>
)}
{DISPLAY_DEV_MANAGEMENT && !isAdminOrSuper && (
<p className="mt-1 text-xs text-gray-500 italic">
Admin access required.
</p>
)}
</div>
</div>
<ArrowRightIcon
className={`h-5 w-5 ${
DISPLAY_DEV_MANAGEMENT && isAdminOrSuper ? 'text-slate-600 opacity-70 group-hover:opacity-100' : 'text-gray-400 opacity-60'
}`}
/>
</button>
</div>
</div>
</div>

View File

@ -327,6 +327,18 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
)
const isAdmin = mounted && rawIsAdmin
const rawIsSuperAdmin =
!!user &&
(
(user as any)?.role === 'super_admin' ||
(user as any)?.userType === 'super_admin' ||
(user as any)?.isSuperAdmin === true ||
((user as any)?.roles?.includes?.('super_admin'))
)
const isSuperAdmin = mounted && rawIsSuperAdmin
const isAdminOrSuper = mounted && (rawIsAdmin || rawIsSuperAdmin)
// Only gate visibility by scroll on parallax-enabled pages
const headerVisible = parallaxEnabled ? animateIn && scrollY > 24 : animateIn
const parallaxOffset = parallaxEnabled ? Math.max(-16, -scrollY * 0.15) : 0
@ -656,6 +668,16 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
News Management
</button>
)}
{isAdminOrSuper && (
<button
onClick={() => { router.push('/admin/dev-management'); setAdminMgmtOpen(false); }}
className="w-full text-left px-4 py-2 text-sm text-[#0F1D37] hover:bg-[#F5F3EE]"
role="menuitem"
>
Dev Management
</button>
)}
</div>
</div>
)}