diff --git a/app/api/apps/[id]/config/bulk/route.ts b/app/api/apps/[id]/config/bulk/route.ts new file mode 100644 index 0000000..74d5dd2 --- /dev/null +++ b/app/api/apps/[id]/config/bulk/route.ts @@ -0,0 +1,85 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import sql from '@/lib/db' + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await getServerSession(authOptions) + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const { id } = await params + const body = await req.json() + const { environment_id, environment, entries, overwrite = true } = body + + if (!environment_id || !Array.isArray(entries) || entries.length === 0) { + return NextResponse.json( + { error: 'environment_id ve en az bir config girişi gereklidir.' }, + { status: 400 } + ) + } + + const actor = session.user?.name ?? 'admin' + const insertedOrUpdated: any[] = [] + + try { + await sql.begin(async (tx) => { + for (const item of entries) { + if (!item.key || item.value === undefined) continue + + const formattedKey = item.key.trim().toUpperCase().replace(/\s+/g, '_') + const formattedValue = String(item.value).trim() + const formattedType = item.type || 'text' + const formattedDesc = item.description || null + + if (overwrite) { + const [res] = await tx` + INSERT INTO config_entries (environment_id, key, value, type, description) + VALUES (${environment_id}, ${formattedKey}, ${formattedValue}, ${formattedType}, ${formattedDesc}) + ON CONFLICT (environment_id, key) + DO UPDATE SET + value = EXCLUDED.value, + type = EXCLUDED.type, + description = COALESCE(EXCLUDED.description, config_entries.description), + updated_at = NOW() + RETURNING * + ` + insertedOrUpdated.push(res) + } else { + const [res] = await tx` + INSERT INTO config_entries (environment_id, key, value, type, description) + VALUES (${environment_id}, ${formattedKey}, ${formattedValue}, ${formattedType}, ${formattedDesc}) + ON CONFLICT (environment_id, key) + DO NOTHING + RETURNING * + ` + if (res) insertedOrUpdated.push(res) + } + } + + // Record batch audit log + if (insertedOrUpdated.length > 0) { + await tx` + INSERT INTO audit_logs (app_id, environment, action, key, actor) + VALUES ( + ${id}, + ${environment}, + 'create', + ${`${insertedOrUpdated.length} adet config toplu aktarıldı`}, + ${actor} + ) + ` + } + }) + + return NextResponse.json({ + success: true, + count: insertedOrUpdated.length, + entries: insertedOrUpdated, + }) + } catch (err: any) { + return NextResponse.json({ error: err.message }, { status: 500 }) + } +} diff --git a/components/AppDetailClient.tsx b/components/AppDetailClient.tsx index 538e414..3c70fd8 100644 --- a/components/AppDetailClient.tsx +++ b/components/AppDetailClient.tsx @@ -7,12 +7,14 @@ import { App, AppEnvironment, AuditLog, ConfigEntry, ConfigType, Environment } f import { cn, envBadgeStyles, maskSecret, typeColor } from '@/lib/utils' import { AppIcon } from './AppIcon' import { IconPicker } from './IconPicker' +import { BulkImportModal } from './BulkImportModal' import { Eye, EyeOff, Copy, Plus, Trash2, Edit2, Check, X, Key, RefreshCw, Clock, Search, Download, FileCode, Shield, Code2, AlertTriangle, Terminal, Smartphone, Globe, Sparkles, Filter, ChevronRight, CheckCircle2, - Lock, ExternalLink, HelpCircle, Settings, Settings2, ImageIcon + Lock, ExternalLink, HelpCircle, Settings, Settings2, ImageIcon, + FileText, ClipboardPaste } from 'lucide-react' interface Props { @@ -36,6 +38,7 @@ export function AppDetailClient({ app, environments, configs, auditLogs }: Props const [copiedSlug, setCopiedSlug] = useState(false) const [showAddModal, setShowAddModal] = useState(false) const [showEditModal, setShowEditModal] = useState(false) + const [showBulkModal, setShowBulkModal] = useState(false) const [revealedIds, setRevealedIds] = useState>(new Set()) const [codeSnippetTab, setCodeSnippetTab] = useState<'rn' | 'next' | 'curl'>('rn') const [isDeletingApp, setIsDeletingApp] = useState(false) @@ -144,7 +147,7 @@ export function AppDetailClient({ app, environments, configs, auditLogs }: Props {/* Quick App Actions */} -
+
+ +
- {/* Quick Export Tools */} + {/* Quick Import & Export Tools */}
+ + +
+ + + +
)}
)} @@ -656,6 +687,21 @@ export async function getAppConfig(env = 'production') { }} /> )} + + {/* ─── BULK IMPORT / PASTE MODAL ─── */} + {showBulkModal && ( + c.key)} + onClose={() => setShowBulkModal(false)} + onSaved={() => { + setShowBulkModal(false) + router.refresh() + }} + /> + )}
) } diff --git a/components/BulkImportModal.tsx b/components/BulkImportModal.tsx new file mode 100644 index 0000000..869d8cf --- /dev/null +++ b/components/BulkImportModal.tsx @@ -0,0 +1,513 @@ +'use client' + +import { useState, useMemo, useRef } from 'react' +import { ConfigType, Environment } from '@/types' +import { cn, typeColor } from '@/lib/utils' +import { + FileText, Upload, Sparkles, AlertCircle, Check, + X, Trash2, ArrowRight, Loader2, Key, Database, RefreshCw +} from 'lucide-react' + +interface BulkImportModalProps { + environmentId: string + environment: Environment + appId: string + existingKeys: string[] + onClose: () => void + onSaved: () => void +} + +interface ParsedEntry { + id: string + key: string + value: string + type: ConfigType + description?: string + isConflict: boolean +} + +// Smart type detector +function detectType(key: string, value: string): ConfigType { + const upperKey = key.toUpperCase() + const lowerVal = value.toLowerCase().trim() + + if ( + upperKey.includes('SECRET') || + upperKey.includes('KEY') || + upperKey.includes('TOKEN') || + upperKey.includes('PASSWORD') || + upperKey.includes('AUTH') || + upperKey.includes('PRIVATE') || + upperKey.includes('CERT') || + upperKey.includes('SIGNING') + ) { + return 'secret' + } + + if (lowerVal.startsWith('http://') || lowerVal.startsWith('https://')) { + return 'url' + } + + if (lowerVal === 'true' || lowerVal === 'false') { + return 'boolean' + } + + if (/^-?\d+(\.\d+)?$/.test(lowerVal) && !lowerVal.startsWith('0') && lowerVal.length < 15) { + return 'number' + } + + if ( + (lowerVal.startsWith('{') && lowerVal.endsWith('}')) || + (lowerVal.startsWith('[') && lowerVal.endsWith(']')) + ) { + try { + JSON.parse(value) + return 'json' + } catch { + // not valid JSON + } + } + + return 'text' +} + +export function BulkImportModal({ + environmentId, + environment, + appId, + existingKeys, + onClose, + onSaved, +}: BulkImportModalProps) { + const [rawText, setRawText] = useState('') + const [overwrite, setOverwrite] = useState(true) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [customParsed, setCustomParsed] = useState(null) + const fileInputRef = useRef(null) + + const existingKeysSet = useMemo(() => new Set(existingKeys.map(k => k.toUpperCase())), [existingKeys]) + + // Parse raw text into structured entries + const parsedEntries = useMemo(() => { + if (customParsed !== null) return customParsed + + if (!rawText.trim()) return [] + + const text = rawText.trim() + + // 1. Try JSON parsing + if (text.startsWith('{') && text.endsWith('}')) { + try { + const obj = JSON.parse(text) + if (typeof obj === 'object' && obj !== null) { + const list: ParsedEntry[] = [] + Object.entries(obj).forEach(([k, v], idx) => { + const strVal = typeof v === 'object' ? JSON.stringify(v) : String(v) + const cleanKey = k.trim().toUpperCase().replace(/\s+/g, '_') + list.push({ + id: `json-${idx}-${cleanKey}`, + key: cleanKey, + value: strVal, + type: detectType(cleanKey, strVal), + isConflict: existingKeysSet.has(cleanKey), + }) + }) + return list + } + } catch { + // Fallback to line by line .env parsing + } + } + + // 2. Line by line .env format parsing + const lines = text.split(/\r?\n/) + const list: ParsedEntry[] = [] + let pendingComment = '' + + lines.forEach((line, idx) => { + const trimmed = line.trim() + if (!trimmed) { + pendingComment = '' + return + } + + // Comment line + if (trimmed.startsWith('#')) { + pendingComment = trimmed.replace(/^#+\s*/, '') + return + } + + // Check if export PREFIX exists (e.g. export KEY=VALUE) + let lineToParse = trimmed + if (lineToParse.startsWith('export ')) { + lineToParse = lineToParse.slice(7).trim() + } + + // Split at first `=` + const eqIdx = lineToParse.indexOf('=') + if (eqIdx !== -1) { + const k = lineToParse.slice(0, eqIdx).trim() + let v = lineToParse.slice(eqIdx + 1).trim() + + // Strip surrounding quotes if matching: "value" or 'value' + if ( + (v.startsWith('"') && v.endsWith('"') && v.length >= 2) || + (v.startsWith("'") && v.endsWith("'") && v.length >= 2) + ) { + v = v.slice(1, -1) + } + + const cleanKey = k.toUpperCase().replace(/\s+/g, '_') + if (cleanKey) { + list.push({ + id: `line-${idx}-${cleanKey}`, + key: cleanKey, + value: v, + type: detectType(cleanKey, v), + description: pendingComment || undefined, + isConflict: existingKeysSet.has(cleanKey), + }) + pendingComment = '' + } + } + }) + + return list + }, [rawText, customParsed, existingKeysSet]) + + function handleTypeChange(id: string, newType: ConfigType) { + const updated = parsedEntries.map(e => e.id === id ? { ...e, type: newType } : e) + setCustomParsed(updated) + } + + function handleKeyChange(id: string, newKey: string) { + const cleanKey = newKey.toUpperCase().replace(/\s+/g, '_') + const updated = parsedEntries.map(e => e.id === id ? { ...e, key: cleanKey, isConflict: existingKeysSet.has(cleanKey) } : e) + setCustomParsed(updated) + } + + function handleValueChange(id: string, newVal: string) { + const updated = parsedEntries.map(e => e.id === id ? { ...e, value: newVal } : e) + setCustomParsed(updated) + } + + function handleRemove(id: string) { + const updated = parsedEntries.filter(e => e.id !== id) + setCustomParsed(updated) + } + + function handleFileUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0] + if (!file) return + + const reader = new FileReader() + reader.onload = () => { + if (typeof reader.result === 'string') { + setCustomParsed(null) + setRawText(reader.result) + } + } + reader.readAsText(file) + } + + async function handleImport() { + if (parsedEntries.length === 0) return + + setLoading(true) + setError('') + + try { + const res = await fetch(`/api/apps/${appId}/config/bulk`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + environment_id: environmentId, + environment, + overwrite, + entries: parsedEntries.map(e => ({ + key: e.key, + value: e.value, + type: e.type, + description: e.description, + })), + }), + }) + + const data = await res.json() + setLoading(false) + + if (!res.ok) { + setError(data.error || 'İçe aktarma sırasında bir hata oluştu.') + return + } + + onSaved() + } catch { + setLoading(false) + setError('Ağ hatası oluştu. Lütfen tekrar deneyin.') + } + } + + const conflictsCount = parsedEntries.filter(e => e.isConflict).length + + return ( +
+
+ {/* Top glow accent */} +
+ + {/* Modal Header */} +
+
+

+ + Toplu Config & Secret Yapıştır / İçe Aktar +

+

+ Hedef Ortam: {environment} • + .env veya JSON formatında doğrudan yapıştırabilirsiniz. +

+
+ +
+ + {/* Modal Body */} +
+ {/* Paste Input Area */} +
+
+ + +
+ + + + {rawText && ( + + )} +
+
+ +