Files
config-vault/components/AppDetailClient.tsx
T
2026-08-26 14:21:53 +03:00

1107 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { App, AppEnvironment, AuditLog, ConfigEntry, ConfigType, Environment } from '@/types'
import { cn, envBadgeStyles, maskSecret, typeColor } from '@/lib/utils'
import { AppIcon } from './AppIcon'
import { IconPicker } from './IconPicker'
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
} from 'lucide-react'
interface Props {
app: App
environments: AppEnvironment[]
configs: ConfigEntry[]
auditLogs: AuditLog[]
}
type Tab = 'config' | 'api' | 'audit'
export function AppDetailClient({ app, environments, configs, auditLogs }: Props) {
const router = useRouter()
const [activeEnv, setActiveEnv] = useState<Environment>('production')
const [activeTab, setActiveTab] = useState<Tab>('config')
const [searchQuery, setSearchQuery] = useState('')
const [typeFilter, setTypeFilter] = useState<string>('all')
const [showApiKey, setShowApiKey] = useState(false)
const [copiedKey, setCopiedKey] = useState(false)
const [copiedEnv, setCopiedEnv] = useState(false)
const [copiedSlug, setCopiedSlug] = useState(false)
const [showAddModal, setShowAddModal] = useState(false)
const [showEditModal, setShowEditModal] = useState(false)
const [revealedIds, setRevealedIds] = useState<Set<string>>(new Set())
const [codeSnippetTab, setCodeSnippetTab] = useState<'rn' | 'next' | 'curl'>('rn')
const [isDeletingApp, setIsDeletingApp] = useState(false)
const currentEnv = environments.find(e => e.name === activeEnv)
// Filter configs for active environment, search query and type filter
const envConfigs = useMemo(() => {
return configs.filter(c => c.environment_id === currentEnv?.id)
}, [configs, currentEnv])
const filteredConfigs = useMemo(() => {
return envConfigs.filter(c => {
const matchesSearch =
c.key.toLowerCase().includes(searchQuery.toLowerCase()) ||
(c.description && c.description.toLowerCase().includes(searchQuery.toLowerCase())) ||
(c.type !== 'secret' && c.value.toLowerCase().includes(searchQuery.toLowerCase()))
const matchesType = typeFilter === 'all' || c.type === typeFilter
return matchesSearch && matchesType
})
}, [envConfigs, searchQuery, typeFilter])
function copyToClipboard(text: string, setCopied?: (v: boolean) => void) {
navigator.clipboard.writeText(text)
if (setCopied) {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
function exportAsEnv() {
const lines = envConfigs.map(c => {
const comment = c.description ? `# ${c.description}\n` : ''
return `${comment}${c.key}="${c.value.replace(/"/g, '\\"')}"`
}).join('\n')
copyToClipboard(lines, setCopiedEnv)
}
function exportAsJson() {
const obj: Record<string, string> = {}
envConfigs.forEach(c => {
obj[c.key] = c.value
})
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(obj, null, 2))
const downloadAnchor = document.createElement('a')
downloadAnchor.setAttribute("href", dataStr)
downloadAnchor.setAttribute("download", `${app.slug}-${activeEnv}-config.json`)
document.body.appendChild(downloadAnchor)
downloadAnchor.click()
downloadAnchor.remove()
}
async function rotateApiKey() {
if (!confirm('API anahtarını yenilemek istediğinize emin misiniz? Bu anahtarı kullanan tüm uygulamalar yenilenene kadar çalışmayacaktır.')) return
await fetch(`/api/apps/${app.id}/rotate-key`, { method: 'POST' })
router.refresh()
}
async function handleDeleteApp() {
const confirmation = prompt(`Bu uygulamayı ve tüm ayarlarını silmek için lütfen uygulamanın slug değerini yazın: "${app.slug}"`)
if (confirmation !== app.slug) {
alert('Slug eşleşmedi, silme işlemi iptal edildi.')
return
}
setIsDeletingApp(true)
await fetch(`/api/apps/${app.id}`, { method: 'DELETE' })
router.push('/dashboard')
}
return (
<div className="space-y-6 animate-fade-in">
{/* ─── APP HEADER ─── */}
<div className="glass-panel p-6 sm:p-7 rounded-3xl relative overflow-hidden flex flex-col md:flex-row md:items-center justify-between gap-6">
<div className="flex items-start sm:items-center gap-4">
<div className="relative group cursor-pointer" onClick={() => setShowEditModal(true)} title="İkonu Değiştir">
<AppIcon name={app.name} iconUrl={app.icon_url} size="xl" className="ring-2 ring-emerald-500/20 group-hover:ring-emerald-400 transition-all" />
<div className="absolute inset-0 bg-black/60 rounded-3xl opacity-0 group-hover:opacity-100 flex items-center justify-center text-white transition-opacity">
<Edit2 className="w-4 h-4 text-emerald-400" />
</div>
</div>
<div>
<div className="flex flex-wrap items-center gap-2.5">
<h1 className="text-2xl sm:text-3xl font-extrabold text-white tracking-tight">
{app.name}
</h1>
<button
onClick={() => copyToClipboard(app.slug, setCopiedSlug)}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-slate-900/90 border border-slate-800 text-xs font-mono text-slate-400 hover:text-emerald-400 hover:border-emerald-500/30 transition-colors"
title="Slug'ı Kopyala"
>
<span>/{app.slug}</span>
{copiedSlug ? <Check className="w-3 h-3 text-emerald-400" /> : <Copy className="w-3 h-3" />}
</button>
</div>
{app.description ? (
<p className="text-slate-400 text-sm mt-1.5 max-w-2xl leading-relaxed">
{app.description}
</p>
) : (
<p className="text-slate-600 text-xs mt-1 italic">Açıklama bulunmuyor</p>
)}
</div>
</div>
{/* Quick App Actions */}
<div className="flex items-center gap-2.5 self-start md:self-auto pt-2 md:pt-0">
<button
onClick={() => setShowEditModal(true)}
className="flex items-center gap-2 bg-slate-900 hover:bg-slate-800 border border-slate-800 hover:border-slate-700 text-slate-300 hover:text-white font-semibold text-xs sm:text-sm px-3.5 py-2.5 rounded-xl transition-all"
title="İkon ve Uygulama Bilgilerini Düzenle"
>
<Settings className="w-4 h-4" />
<span>İkon / Ayarlar</span>
</button>
<button
onClick={() => setShowAddModal(true)}
className="flex items-center gap-2 bg-emerald-500 hover:bg-emerald-400 active:bg-emerald-600 text-slate-950 font-bold text-xs sm:text-sm px-4 py-2.5 rounded-xl shadow-lg shadow-emerald-500/20 transition-all hover:scale-[1.02]"
>
<Plus className="w-4 h-4 stroke-[2.5]" />
<span>Config Ekle</span>
</button>
</div>
</div>
{/* ─── NAVIGATION TABS ─── */}
<div className="flex items-center justify-between border-b border-slate-800/80 pb-px">
<div className="flex gap-2">
<button
onClick={() => setActiveTab('config')}
className={cn(
'flex items-center gap-2 px-4 py-3 border-b-2 text-sm font-semibold transition-all -mb-px',
activeTab === 'config'
? 'border-emerald-400 text-emerald-400'
: 'border-transparent text-slate-400 hover:text-slate-200 hover:border-slate-700'
)}
>
<FileCode className="w-4 h-4" />
<span>Değişkenler & Secretlar</span>
<span className="ml-1 px-2 py-0.5 rounded-full text-[11px] bg-slate-800 text-slate-300 font-mono">
{envConfigs.length}
</span>
</button>
<button
onClick={() => setActiveTab('api')}
className={cn(
'flex items-center gap-2 px-4 py-3 border-b-2 text-sm font-semibold transition-all -mb-px',
activeTab === 'api'
? 'border-emerald-400 text-emerald-400'
: 'border-transparent text-slate-400 hover:text-slate-200 hover:border-slate-700'
)}
>
<Code2 className="w-4 h-4" />
<span>API & Entegrasyon</span>
</button>
<button
onClick={() => setActiveTab('audit')}
className={cn(
'flex items-center gap-2 px-4 py-3 border-b-2 text-sm font-semibold transition-all -mb-px',
activeTab === 'audit'
? 'border-emerald-400 text-emerald-400'
: 'border-transparent text-slate-400 hover:text-slate-200 hover:border-slate-700'
)}
>
<Clock className="w-4 h-4" />
<span>İşlem Geçmişi (Audit)</span>
<span className="ml-1 px-2 py-0.5 rounded-full text-[11px] bg-slate-800 text-slate-300 font-mono">
{auditLogs.length}
</span>
</button>
</div>
</div>
{/* ─── TAB 1: CONFIG ENTRIES ─── */}
{activeTab === 'config' && (
<div className="space-y-6 animate-fade-in">
{/* Environment Switcher & Top Toolbar */}
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
{/* Environment Pills */}
<div className="flex items-center gap-2 p-1.5 rounded-2xl bg-slate-900/90 border border-slate-800/80 w-fit">
{environments.map(env => {
const styles = envBadgeStyles(env.name)
const isActive = activeEnv === env.name
const count = configs.filter(c => c.environment_id === env.id).length
return (
<button
key={env.id}
onClick={() => setActiveEnv(env.name)}
className={cn(
'flex items-center gap-2 px-3.5 py-1.5 rounded-xl text-xs font-semibold transition-all',
isActive
? styles.active
: 'text-slate-400 hover:text-white hover:bg-slate-800/50'
)}
>
<span className={cn('w-1.5 h-1.5 rounded-full', isActive ? 'bg-slate-950' : styles.dot)} />
<span className="capitalize">{env.name}</span>
<span className={cn(
'text-[10px] px-1.5 py-0.2 rounded-md font-mono',
isActive ? 'bg-black/20 text-slate-950' : 'bg-slate-800 text-slate-400'
)}>
{count}
</span>
</button>
)
})}
</div>
{/* Quick Export Tools */}
<div className="flex flex-wrap items-center gap-2">
<button
onClick={exportAsEnv}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 hover:border-slate-700 text-xs font-medium text-slate-300 hover:text-white transition-colors"
title=".env formatında kopyala"
>
{copiedEnv ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copiedEnv ? 'Kopyalandı!' : '.env Kopyala'}</span>
</button>
<button
onClick={exportAsJson}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 hover:border-slate-700 text-xs font-medium text-slate-300 hover:text-white transition-colors"
title="JSON olarak indir"
>
<Download className="w-3.5 h-3.5" />
<span>JSON İndir</span>
</button>
</div>
</div>
{/* Search & Type Filter Bar */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
<div className="relative flex-1 max-w-md">
<Search className="w-4 h-4 text-slate-500 absolute left-3.5 top-1/2 -translate-y-1/2" />
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Key, açıklama veya değer ara..."
className="w-full glass-input rounded-xl pl-10 pr-4 py-2 text-xs sm:text-sm text-white placeholder-slate-500 focus:outline-none"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 hover:text-white"
>
</button>
)}
</div>
<div className="flex items-center gap-2">
<Filter className="w-3.5 h-3.5 text-slate-500" />
<select
value={typeFilter}
onChange={e => setTypeFilter(e.target.value)}
className="bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-slate-300 focus:outline-none focus:border-emerald-500/50"
>
<option value="all">Tüm Tipler</option>
<option value="text">Text</option>
<option value="secret">Secret (Gizli)</option>
<option value="url">URL</option>
<option value="json">JSON</option>
<option value="boolean">Boolean</option>
<option value="number">Number</option>
</select>
</div>
</div>
{/* Config Table Card */}
<div className="glass-panel rounded-3xl overflow-hidden shadow-xl">
{/* Header row */}
<div className="hidden sm:grid grid-cols-12 gap-4 px-6 py-3.5 bg-slate-900/80 border-b border-slate-800/80 text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
<div className="col-span-4">Anahtar (Key) & Açıklama</div>
<div className="col-span-2">Tip</div>
<div className="col-span-4">Değer (Value)</div>
<div className="col-span-2 text-right">İşlemler</div>
</div>
{/* Empty State */}
{filteredConfigs.length === 0 && (
<div className="text-center py-16 px-4">
<div className="w-12 h-12 rounded-2xl bg-slate-900 border border-slate-800 flex items-center justify-center mx-auto mb-3 text-slate-500">
<Key className="w-6 h-6" />
</div>
<h3 className="text-white text-sm font-semibold mb-1">
{searchQuery || typeFilter !== 'all'
? 'Filtreyle eşleşen config bulunamadı'
: `${activeEnv.toUpperCase()} ortamında henüz değişken yok`}
</h3>
<p className="text-slate-500 text-xs max-w-sm mx-auto mb-5">
{searchQuery || typeFilter !== 'all'
? 'Arama kriterlerinizi temizleyerek tekrar deneyin.'
: 'Uygulamanızın ihtiyaç duyduğu API URL, Secret veya Flag değişkenlerini ekleyin.'}
</p>
{!searchQuery && typeFilter === 'all' && (
<button
onClick={() => setShowAddModal(true)}
className="inline-flex items-center gap-1.5 bg-emerald-500 hover:bg-emerald-400 text-slate-950 text-xs font-bold px-4 py-2 rounded-xl transition-transform hover:scale-105 shadow-md shadow-emerald-500/20"
>
<Plus className="w-3.5 h-3.5 stroke-[2.5]" />
<span>İlk Değişkeni Ekle</span>
</button>
)}
</div>
)}
{/* Rows */}
{filteredConfigs.map(entry => (
<ConfigRow
key={entry.id}
entry={entry}
appId={app.id}
environment={activeEnv}
revealed={revealedIds.has(entry.id)}
onToggleReveal={() => {
const next = new Set(revealedIds)
if (next.has(entry.id)) next.delete(entry.id)
else next.add(entry.id)
setRevealedIds(next)
}}
onDeleted={() => router.refresh()}
onSaved={() => router.refresh()}
/>
))}
</div>
</div>
)}
{/* ─── TAB 2: API & INTEGRATION ─── */}
{activeTab === 'api' && (
<div className="space-y-6 animate-fade-in">
{/* API Key Box */}
<div className="glass-panel p-6 sm:p-7 rounded-3xl space-y-6">
<div>
<h2 className="text-lg font-bold text-white flex items-center gap-2">
<Key className="w-5 h-5 text-emerald-400" />
<span>Uygulama API Anahtarı (X-Api-Key)</span>
</h2>
<p className="text-slate-400 text-xs sm:text-sm mt-1">
Mobil uygulamanız veya backend servisiniz başlangıçta config verilerini güvenle çekmek için bu anahtarı kullanır.
</p>
</div>
<div>
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">
API Key
</label>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<div className="flex-1 glass-input rounded-xl px-4 py-3 font-mono text-xs sm:text-sm text-emerald-300 break-all select-all flex items-center justify-between">
<span>{showApiKey ? app.api_key : maskSecret(app.api_key)}</span>
{showApiKey && (
<span className="text-[10px] uppercase font-bold text-emerald-500 bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20">
Aktif
</span>
)}
</div>
<div className="flex items-center gap-2 self-end sm:self-auto">
<button
onClick={() => setShowApiKey(!showApiKey)}
className="p-3 bg-slate-900 border border-slate-800 hover:border-slate-700 rounded-xl text-slate-400 hover:text-white transition-colors"
title={showApiKey ? 'Gizle' : 'Göster'}
>
{showApiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
<button
onClick={() => copyToClipboard(app.api_key, setCopiedKey)}
className="flex items-center gap-1.5 px-4 py-3 bg-emerald-500 hover:bg-emerald-400 text-slate-950 rounded-xl font-bold text-xs transition-colors shadow-lg shadow-emerald-500/20"
>
{copiedKey ? <Check className="w-4 h-4 stroke-[2.5]" /> : <Copy className="w-4 h-4 stroke-[2.5]" />}
<span>{copiedKey ? 'Kopyalandı' : 'Kopyala'}</span>
</button>
</div>
</div>
</div>
{/* Code Snippets Accordion / Tabs */}
<div className="border-t border-slate-800/80 pt-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-white font-bold text-sm sm:text-base flex items-center gap-2">
<Sparkles className="w-4 h-4 text-emerald-400" />
<span>Hızlı Entegrasyon Kodları</span>
</h3>
<p className="text-slate-400 text-xs mt-0.5">
Projenize doğrudan yapıştırabileceğiniz hazır SDK ve istemci fonksiyonları:
</p>
</div>
<div className="flex p-1 rounded-xl bg-slate-900 border border-slate-800">
<button
onClick={() => setCodeSnippetTab('rn')}
className={cn(
'px-3 py-1 rounded-lg text-xs font-semibold transition-all flex items-center gap-1.5',
codeSnippetTab === 'rn' ? 'bg-slate-800 text-emerald-400 shadow-sm' : 'text-slate-400 hover:text-white'
)}
>
<Smartphone className="w-3.5 h-3.5" />
<span>React Native / Expo</span>
</button>
<button
onClick={() => setCodeSnippetTab('next')}
className={cn(
'px-3 py-1 rounded-lg text-xs font-semibold transition-all flex items-center gap-1.5',
codeSnippetTab === 'next' ? 'bg-slate-800 text-emerald-400 shadow-sm' : 'text-slate-400 hover:text-white'
)}
>
<Globe className="w-3.5 h-3.5" />
<span>Next.js / Node</span>
</button>
<button
onClick={() => setCodeSnippetTab('curl')}
className={cn(
'px-3 py-1 rounded-lg text-xs font-semibold transition-all flex items-center gap-1.5',
codeSnippetTab === 'curl' ? 'bg-slate-800 text-emerald-400 shadow-sm' : 'text-slate-400 hover:text-white'
)}
>
<Terminal className="w-3.5 h-3.5" />
<span>cURL</span>
</button>
</div>
</div>
{/* Code display */}
<div className="relative group">
<pre className="bg-[#05070B] border border-slate-800/90 rounded-2xl p-5 text-xs text-emerald-300/90 font-mono overflow-x-auto leading-relaxed shadow-inner">
{codeSnippetTab === 'rn' && `// lib/config.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
const API_KEY = '${showApiKey ? app.api_key : '<<YOUR_API_KEY>>'}';
const ENDPOINT = '${typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000'}/api/v1/config';
export async function initRemoteConfig(env: 'development' | 'staging' | 'production' = 'production') {
try {
const res = await fetch(\`\${ENDPOINT}?env=\${env}\`, {
headers: { 'X-Api-Key': API_KEY },
});
if (!res.ok) throw new Error('Config fetch failed');
const remoteConfig = await res.json();
// Offline kullanım için depola
await AsyncStorage.setItem('@app_config', JSON.stringify(remoteConfig));
return remoteConfig;
} catch (error) {
const cached = await AsyncStorage.getItem('@app_config');
if (cached) return JSON.parse(cached);
throw error;
}
}`}
{codeSnippetTab === 'next' && `// lib/remoteConfig.ts
export async function getAppConfig(env = 'production') {
const res = await fetch(\`${typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000'}/api/v1/config?env=\${env}\`, {
headers: {
'X-Api-Key': '${showApiKey ? app.api_key : '<<YOUR_API_KEY>>'}',
},
next: { revalidate: 60 }, // 60 saniye ISR cache
});
if (!res.ok) {
throw new Error('Config load failed');
}
return res.json();
}`}
{codeSnippetTab === 'curl' && `curl -X GET "${typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000'}/api/v1/config?env=production" \\
-H "X-Api-Key: ${showApiKey ? app.api_key : '<<YOUR_API_KEY>>'}"`}
</pre>
</div>
</div>
</div>
{/* Danger Zone */}
<div className="glass-panel p-6 sm:p-7 rounded-3xl border-rose-500/20 bg-rose-950/5 space-y-6">
<div className="flex items-center gap-2 text-rose-400">
<AlertTriangle className="w-5 h-5" />
<h3 className="text-base font-bold">Kritik İşlemler (Danger Zone)</h3>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-4 border-b border-rose-500/10">
<div>
<p className="text-sm font-semibold text-white">API Anahtarını Yenile (Rotate)</p>
<p className="text-slate-400 text-xs mt-0.5">
Eski anahtar anında geçersiz olur. Canlıdaki uygulamalar yeni anahtarı alana kadar config çekemez.
</p>
</div>
<button
onClick={rotateApiKey}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-amber-500/10 hover:bg-amber-500/20 text-amber-300 border border-amber-500/30 text-xs font-semibold transition-colors self-start sm:self-auto"
>
<RefreshCw className="w-3.5 h-3.5" />
<span>Anahtarı Yenile</span>
</button>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<p className="text-sm font-semibold text-white">Uygulamayı ve Tüm Verileri Sil</p>
<p className="text-slate-400 text-xs mt-0.5">
Bu uygulama, tüm ortamlardaki değişkenler ve audit logları kalıcı olarak silinecektir.
</p>
</div>
<button
onClick={handleDeleteApp}
disabled={isDeletingApp}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 border border-rose-500/30 text-xs font-semibold transition-colors self-start sm:self-auto"
>
<Trash2 className="w-3.5 h-3.5" />
<span>{isDeletingApp ? 'Siliniyor...' : 'Uygulamayı Sil'}</span>
</button>
</div>
</div>
</div>
)}
{/* ─── TAB 3: AUDIT LOGS ─── */}
{activeTab === 'audit' && (
<div className="glass-panel rounded-3xl overflow-hidden shadow-xl animate-fade-in">
<div className="px-6 py-4 border-b border-slate-800/80 flex items-center justify-between">
<div>
<h2 className="text-base font-bold text-white">İşlem & Güvenlik Denetim Kayıtları (Audit Log)</h2>
<p className="text-slate-400 text-xs mt-0.5">Son 30 konfigürasyon ve anahtar işlemi</p>
</div>
<span className="text-xs px-2.5 py-1 rounded-full bg-slate-900 border border-slate-800 text-slate-400 font-mono">
{auditLogs.length} Kayıt
</span>
</div>
{auditLogs.length === 0 ? (
<div className="text-center py-16 text-slate-500 text-sm">
Henüz kayıtlı bir işlem bulunmuyor
</div>
) : (
<div className="divide-y divide-slate-800/50">
{auditLogs.map(log => {
const actionStyles = {
create: { badge: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20', label: 'Eklendi' },
update: { badge: 'bg-cyan-500/10 text-cyan-400 border-cyan-500/20', label: 'Güncellendi' },
delete: { badge: 'bg-rose-500/10 text-rose-400 border-rose-500/20', label: 'Silindi' },
rotate_key: { badge: 'bg-amber-500/10 text-amber-400 border-amber-500/20', label: 'Key Yenilendi' },
create_app: { badge: 'bg-purple-500/10 text-purple-400 border-purple-500/20', label: 'App Oluşturuldu' },
delete_app: { badge: 'bg-rose-500/10 text-rose-400 border-rose-500/20', label: 'App Silindi' },
}[log.action] || { badge: 'bg-slate-500/10 text-slate-400 border-slate-500/20', label: log.action }
return (
<div key={log.id} className="p-5 flex flex-col sm:flex-row sm:items-center justify-between gap-3 hover:bg-slate-900/30 transition-colors">
<div className="flex items-start sm:items-center gap-3">
<span className={cn('text-xs px-2.5 py-1 rounded-lg border font-semibold', actionStyles.badge)}>
{actionStyles.label}
</span>
<div>
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-sm text-white font-bold">
{log.key || log.action}
</span>
{log.environment && (
<span className="text-[10px] font-semibold px-2 py-0.5 rounded bg-slate-800 text-slate-300 uppercase tracking-wide">
{log.environment}
</span>
)}
</div>
{log.old_value && (
<p className="text-slate-500 text-xs font-mono mt-1 line-clamp-1">
Eski değer: <span className="line-through">{maskSecret(log.old_value)}</span>
</p>
)}
</div>
</div>
<div className="flex items-center gap-4 text-xs text-slate-500 self-end sm:self-auto">
<span className="px-2 py-0.5 rounded bg-slate-900 border border-slate-800 text-slate-400">
{log.actor}
</span>
<div className="flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
<span>{new Date(log.created_at).toLocaleString('tr-TR')}</span>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
)}
{/* ─── ADD CONFIG MODAL ─── */}
{showAddModal && (
<AddConfigModal
environmentId={currentEnv?.id ?? ''}
environment={activeEnv}
appId={app.id}
onClose={() => setShowAddModal(false)}
onSaved={() => {
setShowAddModal(false)
router.refresh()
}}
/>
)}
{/* ─── EDIT APP MODAL ─── */}
{showEditModal && (
<EditAppModal
app={app}
onClose={() => setShowEditModal(false)}
onSaved={() => {
setShowEditModal(false)
router.refresh()
}}
/>
)}
</div>
)
}
// ─── ADD CONFIG MODAL ────────────────────────────────────────────────
function AddConfigModal({
environmentId, environment, appId, onClose, onSaved
}: {
environmentId: string
environment: string
appId: string
onClose: () => void
onSaved: () => void
}) {
const [key, setKey] = useState('')
const [value, setValue] = useState('')
const [type, setType] = useState<ConfigType>('text')
const [description, setDescription] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function handleSave(e: React.FormEvent) {
e.preventDefault()
if (!key || !value) return
setLoading(true)
setError('')
const res = await fetch(`/api/apps/${appId}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ environment_id: environmentId, key, value, type, description, environment }),
})
const data = await res.json()
setLoading(false)
if (!res.ok) {
setError(data.error || 'Kaydedilirken hata oluştu')
return
}
onSaved()
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-md animate-fade-in">
<div className="glass-panel w-full max-w-lg rounded-3xl p-6 sm:p-8 shadow-2xl border border-slate-700/80 relative overflow-hidden">
{/* Glow accent */}
<div className="absolute top-0 left-0 right-0 h-[1px] bg-gradient-to-r from-transparent via-emerald-500/60 to-transparent" />
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-bold text-white flex items-center gap-2">
<Plus className="w-5 h-5 text-emerald-400" />
<span>Yeni Config / Secret Ekle</span>
</h3>
<p className="text-slate-400 text-xs mt-0.5">
Hedef Ortam: <span className="text-emerald-400 font-semibold uppercase">{environment}</span>
</p>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-xl bg-slate-900 text-slate-400 hover:text-white border border-slate-800 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<form onSubmit={handleSave} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-1.5">
Anahtar (Key) <span className="text-rose-400">*</span>
</label>
<input
value={key}
onChange={e => setKey(e.target.value.toUpperCase().replace(/\s/g, '_'))}
placeholder="SUPABASE_ANON_KEY"
required
autoFocus
className="w-full glass-input rounded-xl px-3.5 py-2.5 text-white text-xs sm:text-sm font-mono focus:outline-none"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-1.5">
Veri Tipi <span className="text-rose-400">*</span>
</label>
<select
value={type}
onChange={e => setType(e.target.value as ConfigType)}
className="w-full glass-input rounded-xl px-3.5 py-2.5 text-white text-xs sm:text-sm focus:outline-none"
>
<option value="text">Text (Düz Metin)</option>
<option value="secret">Secret (Gizli/Şifreli)</option>
<option value="url">URL</option>
<option value="json">JSON</option>
<option value="boolean">Boolean (True/False)</option>
<option value="number">Number (Sayısal)</option>
</select>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-1.5">
Değer (Value) <span className="text-rose-400">*</span>
</label>
{type === 'json' ? (
<textarea
value={value}
onChange={e => setValue(e.target.value)}
placeholder='{"features": {"darkMode": true}}'
rows={4}
required
className="w-full glass-input rounded-xl px-3.5 py-2.5 text-white text-xs sm:text-sm font-mono focus:outline-none resize-none"
/>
) : (
<input
type={type === 'secret' ? 'password' : 'text'}
value={value}
onChange={e => setValue(e.target.value)}
placeholder={
type === 'url' ? 'https://api.myapp.com' :
type === 'secret' ? 'eyJhbGciOiJIUzI1NiIsIn...' :
type === 'boolean' ? 'true' :
type === 'number' ? '5000' : 'Değer girin...'
}
required
className="w-full glass-input rounded-xl px-3.5 py-2.5 text-white text-xs sm:text-sm font-mono focus:outline-none"
/>
)}
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-1.5">
Açıklama <span className="text-slate-500 font-normal lowercase">(isteğe bağlı)</span>
</label>
<input
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Örn: Supabase istemci kimlik doğrulaması"
className="w-full glass-input rounded-xl px-3.5 py-2.5 text-white text-xs sm:text-sm focus:outline-none"
/>
</div>
{error && (
<div className="bg-rose-500/10 border border-rose-500/30 rounded-xl p-3 text-rose-300 text-xs">
{error}
</div>
)}
<div className="flex items-center gap-3 pt-3">
<button
type="button"
onClick={onClose}
className="flex-1 bg-slate-800 hover:bg-slate-700 text-slate-300 font-semibold text-xs py-2.5 rounded-xl transition-colors"
>
İptal
</button>
<button
type="submit"
disabled={loading || !key || !value}
className="flex-1 bg-emerald-500 hover:bg-emerald-400 active:bg-emerald-600 disabled:opacity-50 text-slate-950 font-bold text-xs py-2.5 rounded-xl transition-all shadow-lg shadow-emerald-500/25 flex items-center justify-center gap-1.5"
>
{loading ? 'Kaydediliyor...' : 'Değişkeni Kaydet'}
</button>
</div>
</form>
</div>
</div>
)
}
// ─── CONFIG ROW COMPONENT ────────────────────────────────────────────
function ConfigRow({
entry, appId, environment, revealed, onToggleReveal, onDeleted, onSaved
}: {
entry: ConfigEntry
appId: string
environment: string
revealed: boolean
onToggleReveal: () => void
onDeleted: () => void
onSaved: () => void
}) {
const [editing, setEditing] = useState(false)
const [editValue, setEditValue] = useState(entry.value)
const [loading, setLoading] = useState(false)
const [copied, setCopied] = useState(false)
const isSecret = entry.type === 'secret'
async function handleDelete() {
if (!confirm(`"${entry.key}" değişkenini silmek istediğinize emin misiniz?`)) return
await fetch(`/api/apps/${appId}/config/${entry.id}?environment=${environment}`, { method: 'DELETE' })
onDeleted()
}
async function handleSave() {
setLoading(true)
await fetch(`/api/apps/${appId}/config/${entry.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: editValue, environment }),
})
setLoading(false)
setEditing(false)
onSaved()
}
const displayValue = isSecret && !revealed ? maskSecret(entry.value) : entry.value
return (
<div className="grid grid-cols-1 sm:grid-cols-12 gap-3 sm:gap-4 px-6 py-4 border-b border-slate-800/50 last:border-0 hover:bg-slate-800/25 transition-colors items-center group">
{/* Key & Description */}
<div className="sm:col-span-4 min-w-0">
<p className="font-mono text-xs sm:text-sm text-white font-semibold truncate select-all">
{entry.key}
</p>
{entry.description && (
<p className="text-slate-500 text-xs mt-0.5 truncate leading-tight">
{entry.description}
</p>
)}
</div>
{/* Type Badge */}
<div className="sm:col-span-2 flex items-center">
<span className={cn('text-[11px] px-2 py-0.5 rounded-md border font-medium uppercase tracking-wider', typeColor(entry.type))}>
{entry.type}
</span>
</div>
{/* Value */}
<div className="sm:col-span-4 min-w-0">
{editing ? (
<div className="flex items-center gap-2">
<input
value={editValue}
onChange={e => setEditValue(e.target.value)}
autoFocus
onKeyDown={e => {
if (e.key === 'Enter') handleSave()
if (e.key === 'Escape') setEditing(false)
}}
className="w-full glass-input rounded-lg px-2.5 py-1.5 text-white text-xs font-mono focus:outline-none"
/>
</div>
) : (
<div className="flex items-center gap-2 min-w-0">
<p className={cn(
'font-mono text-xs sm:text-sm truncate select-all',
isSecret && !revealed ? 'text-slate-500 font-sans tracking-widest' : 'text-emerald-300/90'
)}>
{displayValue}
</p>
</div>
)}
</div>
{/* Action buttons */}
<div className="sm:col-span-2 flex items-center justify-end gap-1 pt-2 sm:pt-0 border-t sm:border-t-0 border-slate-800/60">
{isSecret && (
<button
onClick={onToggleReveal}
className="p-1.5 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors"
title={revealed ? 'Gizle' : 'Değeri Göster'}
>
{revealed ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
</button>
)}
<button
onClick={() => {
navigator.clipboard.writeText(entry.value)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}}
className="p-1.5 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors"
title="Değeri Kopyala"
>
{copied ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
</button>
{editing ? (
<>
<button
onClick={() => setEditing(false)}
className="p-1.5 text-slate-400 hover:text-rose-400 hover:bg-slate-800 rounded-lg transition-colors"
title="İptal (Esc)"
>
<X className="w-3.5 h-3.5" />
</button>
<button
onClick={handleSave}
disabled={loading}
className="p-1.5 text-emerald-400 hover:text-emerald-300 hover:bg-slate-800 rounded-lg transition-colors"
title="Kaydet (Enter)"
>
<Check className="w-3.5 h-3.5 stroke-[2.5]" />
</button>
</>
) : (
<button
onClick={() => setEditing(true)}
className="p-1.5 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors"
title="Düzenle"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
)}
<button
onClick={handleDelete}
className="p-1.5 text-slate-400 hover:text-rose-400 hover:bg-rose-500/10 rounded-lg transition-colors"
title="Sil"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
)
}
// ─── EDIT APP MODAL ──────────────────────────────────────────────────
function EditAppModal({
app, onClose, onSaved
}: {
app: App
onClose: () => void
onSaved: () => void
}) {
const [name, setName] = useState(app.name)
const [description, setDescription] = useState(app.description || '')
const [iconUrl, setIconUrl] = useState(app.icon_url || '')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function handleSave(e: React.FormEvent) {
e.preventDefault()
if (!name) return
setLoading(true)
setError('')
try {
const res = await fetch(`/api/apps/${app.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
description: description || null,
icon_url: iconUrl || null,
}),
})
const data = await res.json()
setLoading(false)
if (!res.ok) {
setError(data.error || 'Uygulama güncellenirken hata oluştu')
return
}
onSaved()
} catch {
setLoading(false)
setError('Ağ hatası oluştu, lütfen tekrar deneyin.')
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-md animate-fade-in">
<div className="glass-panel w-full max-w-lg rounded-3xl p-6 sm:p-8 shadow-2xl border border-slate-700/80 relative overflow-hidden max-h-[90vh] overflow-y-auto">
{/* Glow accent */}
<div className="absolute top-0 left-0 right-0 h-[1px] bg-gradient-to-r from-transparent via-emerald-500/60 to-transparent" />
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-bold text-white flex items-center gap-2">
<Settings className="w-5 h-5 text-emerald-400" />
<span>Uygulama & İkon Ayarları</span>
</h3>
<p className="text-slate-400 text-xs mt-0.5">
İkonu, ismi ve açıklamayı güncelleyin
</p>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-xl bg-slate-900 text-slate-400 hover:text-white border border-slate-800 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<form onSubmit={handleSave} className="space-y-5">
<div>
<label className="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-1.5">
Uygulama Adı <span className="text-rose-400">*</span>
</label>
<input
value={name}
onChange={e => setName(e.target.value)}
required
className="w-full glass-input rounded-xl px-3.5 py-2.5 text-white text-xs sm:text-sm font-medium focus:outline-none"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-1.5">
Açıklama
</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
rows={2}
className="w-full glass-input rounded-xl px-3.5 py-2.5 text-white text-xs sm:text-sm resize-none focus:outline-none"
/>
</div>
{/* Icon Picker */}
<IconPicker
value={iconUrl}
onChange={setIconUrl}
appName={name}
/>
{error && (
<div className="bg-rose-500/10 border border-rose-500/30 rounded-xl p-3 text-rose-300 text-xs">
{error}
</div>
)}
<div className="flex items-center gap-3 pt-3">
<button
type="button"
onClick={onClose}
className="flex-1 bg-slate-800 hover:bg-slate-700 text-slate-300 font-semibold text-xs py-2.5 rounded-xl transition-colors"
>
İptal
</button>
<button
type="submit"
disabled={loading || !name}
className="flex-1 bg-emerald-500 hover:bg-emerald-400 active:bg-emerald-600 disabled:opacity-50 text-slate-950 font-bold text-xs py-2.5 rounded-xl transition-all shadow-lg shadow-emerald-500/25 flex items-center justify-center gap-1.5"
>
{loading ? 'Kaydediliyor...' : 'Değişiklikleri Kaydet'}
</button>
</div>
</form>
</div>
</div>
)
}