first commit

This commit is contained in:
2026-08-26 14:21:53 +03:00
commit fef66cd9dd
38 changed files with 5783 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
node_modules
.next
.git
.gitignore
.env*.local
.env
npm-debug.log*
yarn-debug.log*
yarn-error.log*
Dockerfile
.dockerignore
README.md
+19
View File
@@ -0,0 +1,19 @@
# ─── PostgreSQL bağlantısı ─────────────────────────────────────────
# Format: postgresql://kullanici:sifre@host:port/veritabani
DATABASE_URL=postgres://postgres:Wn4gK6Jbz6LinyQg32kwkWX6gKcgRfcYe2TDctLqPSBkLM7DmjDF1DxNpq4auxrl@65.109.236.58:29435/postgres
# SSL (production'da genellikle true, lokal'de false)
DATABASE_SSL=false
# ─── NextAuth ──────────────────────────────────────────────────────
NEXTAUTH_SECRET=ZHOxnSJZI1hYP2m9BUQL5Ni7y9jA/kRVtaHUqT+eZMY=
NEXTAUTH_URL=http://localhost:3000
# ─── Admin credentials ─────────────────────────────────────────────
ADMIN_USERNAME=admin
# Hash üretmek için:
# node -e "const b=require('bcryptjs'); console.log(b.hashSync('şifren', 12))"
ADMIN_PASSWORD_HASH=$2a$12$O6QRIW1TWO9SCAdxYEj8e.qhqiI6DuLPRQ8/YXfLyQ.DLB4ytLL2a
# ─── Public URL (UI'da kod snippet için) ──────────────────────────
NEXT_PUBLIC_APP_URL=http://localhost:3000
+39
View File
@@ -0,0 +1,39 @@
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+53
View File
@@ -0,0 +1,53 @@
# 1. Base image
FROM node:20-alpine AS base
# 2. Dependencies
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies
COPY package.json package-lock.json* ./
RUN npm ci || npm install --legacy-peer-deps
# 3. Builder
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Environment variables for Next.js build
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
RUN npm run build
# 4. Runner
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Start the server
CMD ["node", "server.js"]
+166
View File
@@ -0,0 +1,166 @@
# ConfigVault — Kurulum Rehberi
## 1. PostgreSQL — Database kurulumu
Herhangi bir Postgres çalışır: kendi sunucun, Railway, Render, Neon, Fly.io...
```bash
# Lokal kurulum için (macOS):
brew install postgresql && brew services start postgresql
createdb configvault
# Veya Docker ile:
docker run -d --name configvault-db \
-e POSTGRES_DB=configvault \
-e POSTGRES_PASSWORD=password \
-p 5432:5432 postgres:16
```
Schema'yı yükle:
```bash
psql postgresql://postgres:password@localhost:5432/configvault < schema.sql
```
Veya `psql`'e bağlanıp `schema.sql` içeriğini yapıştır.
---
## 2. Proje kurulumu
```bash
npm install
cp .env.example .env.local
```
`.env.local` doldur:
```bash
DATABASE_URL=postgresql://postgres:password@localhost:5432/configvault
DATABASE_SSL=false
NEXTAUTH_SECRET=$(openssl rand -base64 32)
NEXTAUTH_URL=http://localhost:3000
ADMIN_USERNAME=admin
# Şifre hash'i oluştur:
node -e "const b=require('bcryptjs'); console.log(b.hashSync('şifren', 12))"
ADMIN_PASSWORD_HASH=$2a$12$...
NEXT_PUBLIC_APP_URL=http://localhost:3000
```
## 3. Çalıştır
```bash
npm run dev
# → http://localhost:3000
```
---
## Deploy seçenekleri
### Vercel + Railway (kolay)
1. Railway'de PostgreSQL aç → connection string'i al
2. Vercel'e deploy et, env variable'ları ekle
3. `DATABASE_SSL=true` yap (Railway SSL ister)
### Kendi sunucun (VPS)
```bash
# Sunucuda:
npm run build
npm start
# Veya PM2 ile:
pm2 start npm --name configvault -- start
```
### Docker Compose (her şey bir arada)
```yaml
version: '3.8'
services:
app:
build: .
ports: ["3000:3000"]
environment:
DATABASE_URL: postgresql://postgres:password@db:5432/configvault
DATABASE_SSL: "false"
NEXTAUTH_SECRET: your-secret
NEXTAUTH_URL: https://configvault.yourdomain.com
ADMIN_USERNAME: admin
ADMIN_PASSWORD_HASH: $2a$12$...
NEXT_PUBLIC_APP_URL: https://configvault.yourdomain.com
depends_on: [db]
db:
image: postgres:16
environment:
POSTGRES_DB: configvault
POSTGRES_PASSWORD: password
volumes:
- pgdata:/var/lib/postgresql/data
- ./schema.sql:/docker-entrypoint-initdb.d/schema.sql
volumes:
pgdata:
```
---
## React Native — Kullanım
Uygulamanın içinde `lib/config.ts` oluştur:
```typescript
import AsyncStorage from '@react-native-async-storage/async-storage'
const CONFIG_URL = 'https://configvault.yourdomain.com/api/v1/config'
const APP_API_KEY = 'buraya_dashboard_api_key' // Dashboard > App > API Key tab
export async function initConfig(env = 'production') {
try {
const res = await fetch(`${CONFIG_URL}?env=${env}`, {
headers: { 'X-Api-Key': APP_API_KEY },
})
if (!res.ok) throw new Error('Config fetch failed')
const config = await res.json()
// Offline fallback için cache
await AsyncStorage.setItem('app_config', JSON.stringify(config))
return config
} catch {
const cached = await AsyncStorage.getItem('app_config')
if (cached) return JSON.parse(cached)
throw new Error('No config available')
}
}
```
`App.tsx`:
```typescript
import { initConfig } from './lib/config'
import { createClient } from '@supabase/supabase-js'
let supabase: any
export default function App() {
useEffect(() => {
initConfig('production').then(config => {
supabase = createClient(config.SUPABASE_URL, config.SUPABASE_ANON_KEY)
// artık config.ICON_URL, config.API_ENDPOINT vs. kullanabilirsin
})
}, [])
}
```
**Config değişince:** Dashboard'a gir → ilgili key'i güncelle → kaydet.
Tüm kullanıcılar bir sonraki açılışta yeni değeri alır. Rebuild yok. 🎉
---
## Yeni özellik ekleme
**Yeni config tipi:** `schema.sql``type CHECK` + `types/index.ts` + `AppDetailClient.tsx` select
**Yeni tablo/özellik:**
1. `schema.sql`'e ekle, `psql` ile migrate et
2. `app/api/apps/[id]/` altına route ekle
3. Dashboard'a tab/section ekle
+148
View File
@@ -0,0 +1,148 @@
'use client'
import { useState } from 'react'
import { signIn } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { Shield, Lock, ArrowRight, Loader2, KeyRound, Sparkles, Eye, EyeOff } from 'lucide-react'
export default function LoginPage() {
const router = useRouter()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError('')
const result = await signIn('credentials', {
username,
password,
redirect: false,
})
setLoading(false)
if (result?.ok) {
router.push('/dashboard')
} else {
setError('Geçersiz kullanıcı adı veya şifre')
}
}
return (
<div className="min-h-screen flex items-center justify-center p-4 relative z-10">
<div className="w-full max-w-md animate-fade-in">
{/* Brand header */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center p-3.5 bg-gradient-to-tr from-emerald-600 to-emerald-400 rounded-2xl mb-4 shadow-xl shadow-emerald-500/20 border border-emerald-300/30 ring-4 ring-emerald-500/10">
<Shield className="w-8 h-8 text-slate-950 stroke-[2.2]" />
</div>
<h1 className="text-3xl font-extrabold text-white tracking-tight flex items-center justify-center gap-2">
ConfigVault
<span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
v1.0
</span>
</h1>
<p className="text-slate-400 text-sm mt-2 font-normal">
Merkezi Remote Config & Gizli Anahtar Yönetimi
</p>
</div>
{/* Glass Card */}
<div className="glass-panel rounded-3xl p-8 shadow-2xl relative overflow-hidden">
{/* Subtle top glow line */}
<div className="absolute top-0 left-0 right-0 h-[1px] bg-gradient-to-r from-transparent via-emerald-500/50 to-transparent" />
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-2">
Kullanıcı Adı
</label>
<div className="relative">
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
required
autoFocus
className="w-full glass-input rounded-xl px-4 py-3 text-white placeholder-slate-500 focus:outline-none text-sm font-medium"
placeholder="admin"
/>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-xs font-semibold text-slate-300 uppercase tracking-wider">
Şifre
</label>
</div>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={e => setPassword(e.target.value)}
required
className="w-full glass-input rounded-xl px-4 py-3 pr-11 text-white placeholder-slate-500 focus:outline-none text-sm font-medium"
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-200 transition-colors p-1"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
{error && (
<div className="bg-rose-500/10 border border-rose-500/30 rounded-xl p-3.5 text-rose-300 text-sm flex items-center gap-2.5 animate-slide-up">
<div className="w-2 h-2 rounded-full bg-rose-400 flex-shrink-0 animate-ping" />
<span>{error}</span>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-emerald-500 hover:bg-emerald-400 active:bg-emerald-600 disabled:opacity-50 disabled:cursor-not-allowed text-slate-950 font-bold py-3 px-4 rounded-xl transition-all duration-200 shadow-lg shadow-emerald-500/25 flex items-center justify-center gap-2 text-sm mt-3"
>
{loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span>Giriş Yapılıyor...</span>
</>
) : (
<>
<span>Yönetim Paneline Giriş</span>
<ArrowRight className="w-4 h-4 stroke-[2.5]" />
</>
)}
</button>
</form>
{/* Security details pill */}
<div className="mt-6 pt-6 border-t border-slate-800/80 flex items-center justify-between text-xs text-slate-500">
<div className="flex items-center gap-1.5">
<KeyRound className="w-3.5 h-3.5 text-emerald-500/80" />
<span>JWT & Bcrypt Korumalı</span>
</div>
<div className="flex items-center gap-1.5">
<Sparkles className="w-3.5 h-3.5 text-emerald-500/80" />
<span>SSL / TLS Güvenli</span>
</div>
</div>
</div>
{/* Footer info */}
<p className="text-center text-slate-600 text-xs mt-6">
ConfigVault &bull; Tüm ortam ve dinamik ayarlarınız güvende
</p>
</div>
</div>
)
}
@@ -0,0 +1,48 @@
import sql from '@/lib/db'
import { notFound } from 'next/navigation'
import { Navbar } from '@/components/Navbar'
import { AppDetailClient } from '@/components/AppDetailClient'
export const dynamic = 'force-dynamic'
async function getAppData(id: string) {
const [app] = await sql`SELECT * FROM apps WHERE id = ${id}`
if (!app) return null
const environments = await sql`
SELECT * FROM environments WHERE app_id = ${id} ORDER BY name
`
const envIds = environments.map((e: any) => e.id)
const configs = envIds.length
? await sql`SELECT * FROM config_entries WHERE environment_id = ANY(${envIds}) ORDER BY key`
: []
const auditLogs = await sql`
SELECT * FROM audit_logs WHERE app_id = ${id}
ORDER BY created_at DESC LIMIT 30
`
return { app, environments, configs, auditLogs }
}
export default async function AppDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const data = await getAppData(id)
if (!data) notFound()
return (
<div className="min-h-screen pb-16">
<Navbar appName={data.app.name} appId={data.app.id} iconUrl={data.app.icon_url} />
<main className="max-w-7xl mx-auto px-4 sm:px-6 pt-8">
<AppDetailClient
app={data.app as any}
environments={data.environments as any}
configs={data.configs as any}
auditLogs={data.auditLogs as any}
/>
</main>
</div>
)
}
+202
View File
@@ -0,0 +1,202 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft, Sparkles, Loader2, Rocket, Globe, Terminal, Smartphone } from 'lucide-react'
import { Navbar } from '@/components/Navbar'
import { IconPicker } from '@/components/IconPicker'
import { slugify } from '@/lib/utils'
export default function NewAppPage() {
const router = useRouter()
const [name, setName] = useState('')
const [slug, setSlug] = useState('')
const [description, setDescription] = useState('')
const [iconUrl, setIconUrl] = useState('')
const [slugEdited, setSlugEdited] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
function handleNameChange(value: string) {
setName(value)
if (!slugEdited) {
setSlug(slugify(value))
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError('')
try {
const res = await fetch('/api/apps', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, slug, description, icon_url: iconUrl || null }),
})
const data = await res.json()
setLoading(false)
if (!res.ok) {
setError(data.error ?? 'Uygulama oluşturulurken bir hata oluştu.')
return
}
router.push(`/dashboard/apps/${data.id}`)
} catch {
setLoading(false)
setError('Ağ hatası oluştu, lütfen tekrar deneyin.')
}
}
return (
<div className="min-h-screen pb-16">
<Navbar />
<main className="max-w-2xl mx-auto px-4 sm:px-6 pt-8 animate-fade-in">
<Link
href="/dashboard"
className="inline-flex items-center gap-2 text-slate-400 hover:text-white text-xs font-medium mb-6 transition-colors px-3 py-1.5 rounded-lg hover:bg-slate-900 border border-transparent hover:border-slate-800"
>
<ArrowLeft className="w-3.5 h-3.5" />
<span>Uygulamalara Geri Dön</span>
</Link>
{/* Page Title */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-white tracking-tight flex items-center gap-2.5">
<span>Yeni Uygulama Oluştur</span>
<div className="w-6 h-6 rounded-lg bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-emerald-400">
<Rocket className="w-3.5 h-3.5" />
</div>
</h1>
<p className="text-slate-400 text-sm mt-1">
Uygulamanız için dinamik config yönetimi ve otomatik 3 ortam (Development, Staging, Production) kurulacaktır.
</p>
</div>
{/* Creation Card */}
<div className="glass-panel rounded-3xl p-7 sm:p-8 shadow-2xl relative overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-[1px] bg-gradient-to-r from-transparent via-emerald-500/50 to-transparent" />
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-2">
Uygulama Adı <span className="text-rose-400">*</span>
</label>
<input
type="text"
value={name}
onChange={e => handleNameChange(e.target.value)}
required
autoFocus
placeholder="Örn: Trendyol Satıcı Paneli, Mobil E-Ticaret"
className="w-full glass-input rounded-xl px-4 py-3 text-white placeholder-slate-500 focus:outline-none text-sm font-medium"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-2">
Benzersiz Tanımlayıcı (Slug) <span className="text-rose-400">*</span>
</label>
<div className="flex items-center glass-input rounded-xl overflow-hidden focus-within:ring-2 focus-within:ring-emerald-500/30 focus-within:border-emerald-500/60">
<span className="px-3.5 text-slate-500 text-xs font-mono border-r border-slate-800 bg-slate-900/50 py-3 select-none">
app/
</span>
<input
type="text"
value={slug}
onChange={e => {
setSlug(e.target.value)
setSlugEdited(true)
}}
required
placeholder="mobil-e-ticaret"
pattern="[a-z0-9-]+"
className="flex-1 bg-transparent px-3.5 py-3 text-white placeholder-slate-500 focus:outline-none text-sm font-mono"
/>
</div>
<p className="text-slate-500 text-[11px] mt-1.5">
Yalnızca küçük harfler, rakamlar ve tire (-) kullanılabilir. API isteklerinde kullanılacaktır.
</p>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-2">
Açıklama <span className="text-slate-500 font-normal lowercase">(isteğe bağlı)</span>
</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Bu uygulama ne amaçla kullanılıyor ve hangi platformlarda çalışıyor?"
rows={3}
className="w-full glass-input rounded-xl px-4 py-3 text-white placeholder-slate-500 focus:outline-none text-sm font-medium resize-none leading-relaxed"
/>
</div>
{/* Icon Picker */}
<IconPicker
value={iconUrl}
onChange={setIconUrl}
appName={name}
/>
{/* Quick target platforms hint */}
<div className="p-4 rounded-2xl bg-slate-900/50 border border-slate-800/80 space-y-2">
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400 flex items-center gap-1.5">
<Sparkles className="w-3.5 h-3.5 text-emerald-400" />
Uyumlu Platformlar & Entegrasyon
</span>
<div className="flex flex-wrap gap-2 text-xs text-slate-400 pt-1">
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg bg-slate-800/80 border border-slate-700/50">
<Smartphone className="w-3.5 h-3.5 text-cyan-400" />
React Native / Expo
</span>
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg bg-slate-800/80 border border-slate-700/50">
<Globe className="w-3.5 h-3.5 text-emerald-400" />
Next.js & React Web
</span>
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg bg-slate-800/80 border border-slate-700/50">
<Terminal className="w-3.5 h-3.5 text-purple-400" />
Node.js / Express Backend
</span>
</div>
</div>
{error && (
<div className="bg-rose-500/10 border border-rose-500/30 rounded-xl p-3.5 text-rose-300 text-sm animate-slide-up">
{error}
</div>
)}
<div className="flex items-center gap-3 pt-2">
<Link
href="/dashboard"
className="flex-1 text-center bg-slate-800/80 hover:bg-slate-700/80 text-slate-300 text-sm font-semibold py-3 rounded-xl transition-colors border border-slate-700/60"
>
İptal
</Link>
<button
type="submit"
disabled={loading || !name || !slug}
className="flex-1 bg-emerald-500 hover:bg-emerald-400 active:bg-emerald-600 disabled:opacity-50 disabled:cursor-not-allowed text-slate-950 text-sm font-bold py-3 rounded-xl transition-all shadow-lg shadow-emerald-500/25 flex items-center justify-center gap-2"
>
{loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span>Oluşturuluyor...</span>
</>
) : (
<span>Uygulamayı Oluştur</span>
)}
</button>
</div>
</form>
</div>
</main>
</div>
)
}
+44
View File
@@ -0,0 +1,44 @@
import sql from '@/lib/db'
import { App } from '@/types'
import { Navbar } from '@/components/Navbar'
import { DashboardClient } from '@/components/DashboardClient'
export const dynamic = 'force-dynamic'
async function getDashboardData() {
const apps = await sql`
SELECT a.*, COUNT(ce.id) as config_count
FROM apps a
LEFT JOIN environments e ON e.app_id = a.id
LEFT JOIN config_entries ce ON ce.environment_id = e.id
GROUP BY a.id
ORDER BY a.created_at DESC
` as any
const [totalConfigs] = await sql`SELECT count(*)::int as count FROM config_entries`
const [totalAudit] = await sql`SELECT count(*)::int as count FROM audit_logs`
return {
apps,
totalConfigsCount: totalConfigs?.count || 0,
totalAuditCount: totalAudit?.count || 0,
}
}
export default async function DashboardPage() {
const { apps, totalConfigsCount, totalAuditCount } = await getDashboardData()
return (
<div className="min-h-screen pb-16">
<Navbar />
<main className="max-w-7xl mx-auto px-4 sm:px-6 pt-8">
<DashboardClient
initialApps={apps}
totalConfigsCount={totalConfigsCount}
totalAuditCount={totalAuditCount}
/>
</main>
</div>
)
}
@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import sql from '@/lib/db'
// PATCH /api/apps/[id]/config/[entryId]
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string; entryId: string }> }
) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { id, entryId } = await params
const { value, environment } = await req.json()
const [old] = await sql`SELECT key, value FROM config_entries WHERE id = ${entryId}`
const [entry] = await sql`
UPDATE config_entries SET value = ${value}
WHERE id = ${entryId}
RETURNING *
`
await sql`
INSERT INTO audit_logs (app_id, environment, action, key, old_value, actor)
VALUES (
${id}, ${environment}, 'update',
${old?.key}, ${old?.value?.slice(0, 100)},
${session.user?.name ?? 'admin'}
)
`
return NextResponse.json(entry)
}
// DELETE /api/apps/[id]/config/[entryId]
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string; entryId: string }> }
) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { id, entryId } = await params
const env = req.nextUrl.searchParams.get('environment') ?? ''
const [old] = await sql`SELECT key FROM config_entries WHERE id = ${entryId}`
await sql`DELETE FROM config_entries WHERE id = ${entryId}`
await sql`
INSERT INTO audit_logs (app_id, environment, action, key, actor)
VALUES (${id}, ${env}, 'delete', ${old?.key}, ${session.user?.name ?? 'admin'})
`
return NextResponse.json({ success: true })
}
+56
View File
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import sql from '@/lib/db'
// GET /api/apps/[id]/config?env=production
export async function GET(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 env = req.nextUrl.searchParams.get('env') ?? 'production'
const entries = await sql`
SELECT ce.*
FROM config_entries ce
JOIN environments e ON e.id = ce.environment_id
WHERE e.app_id = ${id} AND e.name = ${env}
ORDER BY ce.key
`
return NextResponse.json(entries)
}
// POST /api/apps/[id]/config
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, key, value, type, description, environment } = body
if (!environment_id || !key || value === undefined) {
return NextResponse.json({ error: 'environment_id, key, value are required' }, { status: 400 })
}
try {
const [entry] = await sql`
INSERT INTO config_entries (environment_id, key, value, type, description)
VALUES (${environment_id}, ${key.toUpperCase()}, ${value}, ${type ?? 'text'}, ${description ?? null})
RETURNING *
`
await sql`
INSERT INTO audit_logs (app_id, environment, action, key, actor)
VALUES (${id}, ${environment}, 'create', ${key}, ${session.user?.name ?? 'admin'})
`
return NextResponse.json(entry, { status: 201 })
} catch (err: any) {
if (err.code === '23505') {
return NextResponse.json({ error: `Key "${key}" already exists in this environment` }, { status: 409 })
}
return NextResponse.json({ error: err.message }, { status: 500 })
}
}
+26
View File
@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import sql from '@/lib/db'
import { randomBytes } from 'crypto'
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 newKey = randomBytes(32).toString('hex')
const [app] = await sql`
UPDATE apps SET api_key = ${newKey}
WHERE id = ${id}
RETURNING api_key
`
await sql`
INSERT INTO audit_logs (app_id, action, actor)
VALUES (${id}, 'rotate_key', ${session.user?.name ?? 'admin'})
`
return NextResponse.json({ api_key: app.api_key })
}
+32
View File
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import sql from '@/lib/db'
export async function DELETE(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
await sql`DELETE FROM apps WHERE id = ${id}`
return NextResponse.json({ success: true })
}
export async function PATCH(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 { name, description, icon_url } = await req.json()
const [app] = await sql`
UPDATE apps SET
name = COALESCE(${name}, name),
description = ${description !== undefined ? description : sql`description`},
icon_url = ${icon_url !== undefined ? icon_url : sql`icon_url`},
updated_at = NOW()
WHERE id = ${id}
RETURNING *
`
return NextResponse.json(app)
}
+48
View File
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import sql from '@/lib/db'
import { slugify } from '@/lib/utils'
// GET /api/apps
export async function GET() {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const apps = await sql`SELECT * FROM apps ORDER BY created_at DESC`
return NextResponse.json(apps)
}
// POST /api/apps
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const body = await req.json()
const { name, description, icon_url } = body
const slug = body.slug ?? slugify(name)
if (!name || !slug) {
return NextResponse.json({ error: 'name and slug are required' }, { status: 400 })
}
try {
const [app] = await sql`
INSERT INTO apps (name, slug, description, icon_url)
VALUES (${name}, ${slug}, ${description ?? null}, ${icon_url ?? null})
RETURNING *
`
await sql`
INSERT INTO audit_logs (app_id, action, actor)
VALUES (${app.id}, 'create_app', ${session.user?.name ?? 'admin'})
`
return NextResponse.json(app, { status: 201 })
} catch (err: any) {
if (err.code === '23505') {
return NextResponse.json({ error: 'Slug already exists.' }, { status: 409 })
}
return NextResponse.json({ error: err.message }, { status: 500 })
}
}
+5
View File
@@ -0,0 +1,5 @@
import NextAuth from 'next-auth'
import { authOptions } from '@/lib/auth'
const handler = NextAuth(authOptions)
export { handler as GET, handler as POST }
+62
View File
@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server'
import sql from '@/lib/db'
/**
* GET /api/v1/config?env=production
*
* Mobile app bu endpoint'i çağırır.
* Header: X-Api-Key: <app_api_key>
* Query: env = development | staging | production (default: production)
*
* Response: { "KEY": "value", "ANOTHER_KEY": "value2", ... }
*/
export async function GET(req: NextRequest) {
const apiKey =
req.headers.get('x-api-key') ??
req.headers.get('authorization')?.replace('Bearer ', '')
if (!apiKey) {
return NextResponse.json(
{ error: 'Missing API key. Pass X-Api-Key header.' },
{ status: 401 }
)
}
const env = req.nextUrl.searchParams.get('env') ?? 'production'
const validEnvs = ['development', 'staging', 'production']
if (!validEnvs.includes(env)) {
return NextResponse.json(
{ error: `Invalid env. Must be one of: ${validEnvs.join(', ')}` },
{ status: 400 }
)
}
// Find app by API key
const [app] = await sql`
SELECT id, name FROM apps WHERE api_key = ${apiKey} LIMIT 1
`
if (!app) {
return NextResponse.json({ error: 'Invalid API key' }, { status: 401 })
}
// Fetch config entries for this app + environment in one join
const entries = await sql`
SELECT ce.key, ce.value
FROM config_entries ce
JOIN environments e ON e.id = ce.environment_id
WHERE e.app_id = ${app.id} AND e.name = ${env}
`
const config: Record<string, string> = {}
for (const entry of entries) {
config[entry.key] = entry.value
}
return NextResponse.json(config, {
headers: {
'Cache-Control': 'no-store, no-cache, must-revalidate',
'X-App': app.name,
'X-Env': env,
},
})
}
+81
View File
@@ -0,0 +1,81 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--font-inter: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', Menlo, Monaco, Consolas, monospace;
}
* {
box-sizing: border-box;
}
html {
color-scheme: dark;
}
body {
@apply bg-[#080B11] text-slate-100 antialiased font-sans selection:bg-emerald-500/30 selection:text-emerald-200;
min-height: 100vh;
}
}
/* Custom modern scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.6);
}
::-webkit-scrollbar-thumb {
background: rgba(51, 65, 85, 0.6);
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(71, 85, 105, 0.9);
}
/* Glassmorphic utilities */
.glass-panel {
background: linear-gradient(135deg, rgba(20, 26, 38, 0.7) 0%, rgba(13, 17, 26, 0.8) 100%);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.glass-panel-interactive {
background: linear-gradient(135deg, rgba(20, 26, 38, 0.65) 0%, rgba(13, 17, 26, 0.75) 100%);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.07);
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
.glass-panel-interactive:hover {
border-color: rgba(16, 185, 129, 0.3);
box-shadow: 0 10px 30px -10px rgba(0, 0, 0, 0.5), 0 0 20px -5px rgba(16, 185, 129, 0.12);
transform: translateY(-2px);
}
.glass-input {
background: rgba(15, 20, 31, 0.75);
border: 1px solid rgba(255, 255, 255, 0.1);
transition: all 0.2s ease;
}
.glass-input:focus {
background: rgba(15, 20, 31, 0.95);
border-color: rgba(16, 185, 129, 0.6);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
}
/* Subtle background grid */
.bg-grid-pattern {
background-size: 32px 32px;
background-image:
linear-gradient(to right, rgba(255, 255, 255, 0.02) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255, 255, 255, 0.02) 1px, transparent 1px);
}
+39
View File
@@ -0,0 +1,39 @@
import type { Metadata } from 'next'
import { Inter, JetBrains_Mono } from 'next/font/google'
import './globals.css'
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
display: 'swap',
})
const mono = JetBrains_Mono({
subsets: ['latin'],
variable: '--font-mono',
display: 'swap',
})
export const metadata: Metadata = {
title: 'ConfigVault — Centralized Remote Config & Secret Manager',
description: 'Enterprise-grade centralized remote configuration and secret management for mobile & web apps.',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${inter.variable} ${mono.variable} dark`}>
<body className="min-h-screen bg-[#080B11] text-slate-100 font-sans relative overflow-x-hidden">
{/* Background ambient lighting */}
<div className="fixed inset-0 pointer-events-none z-0 overflow-hidden">
<div className="absolute -top-40 left-1/2 -translate-x-1/2 w-[800px] h-[400px] bg-emerald-500/10 rounded-full blur-[130px] opacity-70" />
<div className="absolute top-1/3 -left-40 w-[600px] h-[400px] bg-cyan-500/5 rounded-full blur-[140px] opacity-50" />
<div className="absolute bottom-10 -right-40 w-[600px] h-[400px] bg-emerald-600/5 rounded-full blur-[140px] opacity-50" />
<div className="absolute inset-0 bg-grid-pattern opacity-60" />
</div>
<div className="relative z-10">
{children}
</div>
</body>
</html>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function Home() {
redirect('/dashboard')
}
File diff suppressed because it is too large Load Diff
+80
View File
@@ -0,0 +1,80 @@
import React from 'react'
import { cn } from '@/lib/utils'
interface AppIconProps {
name: string
iconUrl?: string | null
className?: string
size?: 'sm' | 'md' | 'lg' | 'xl'
}
export function AppIcon({ name, iconUrl, className, size = 'md' }: AppIconProps) {
const sizeClasses = {
sm: 'w-7 h-7 text-xs rounded-lg',
md: 'w-10 h-10 text-base rounded-xl',
lg: 'w-12 h-12 text-lg rounded-2xl',
xl: 'w-16 h-16 text-2xl rounded-3xl',
}[size]
// If iconUrl is an image (starts with http, / or data:image)
const isImage = iconUrl && (
iconUrl.startsWith('http://') ||
iconUrl.startsWith('https://') ||
iconUrl.startsWith('/') ||
iconUrl.startsWith('data:image/')
)
// Gradient generator from app name
const initial = name ? name.charAt(0).toUpperCase() : 'A'
const gradients = [
'from-emerald-500 to-teal-700',
'from-cyan-500 to-blue-700',
'from-indigo-500 to-purple-700',
'from-amber-500 to-orange-700',
'from-rose-500 to-pink-700',
'from-violet-500 to-fuchsia-700',
]
const gradientIndex = (name.charCodeAt(0) || 0) % gradients.length
const bgGradient = gradients[gradientIndex]
if (isImage) {
return (
<div className={cn('relative overflow-hidden bg-slate-900 border border-slate-800 shadow-md flex items-center justify-center flex-shrink-0', sizeClasses, className)}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={iconUrl}
alt={name}
className="w-full h-full object-cover"
onError={(e) => {
// fallback if image fails to load
e.currentTarget.style.display = 'none'
}}
/>
</div>
)
}
// If iconUrl is an emoji or custom text
if (iconUrl && iconUrl.trim()) {
return (
<div className={cn(
'bg-slate-900 border border-slate-800/80 shadow-md flex items-center justify-center flex-shrink-0 select-none',
sizeClasses,
className
)}>
<span className="leading-none">{iconUrl}</span>
</div>
)
}
// Fallback initial with background gradient
return (
<div className={cn(
`bg-gradient-to-br ${bgGradient} p-[1px] shadow-lg flex items-center justify-center text-white font-extrabold flex-shrink-0 select-none`,
sizeClasses,
className
)}>
<span className="drop-shadow-sm">{initial}</span>
</div>
)
}
+239
View File
@@ -0,0 +1,239 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { Plus, Search, Layers, Key, ArrowUpRight, Clock, Sparkles, SlidersHorizontal, ShieldCheck, Zap } from 'lucide-react'
import { App } from '@/types'
import { AppIcon } from './AppIcon'
interface DashboardApp extends App {
config_count?: number | string
}
interface Props {
initialApps: DashboardApp[]
totalConfigsCount: number
totalAuditCount: number
}
export function DashboardClient({ initialApps, totalConfigsCount, totalAuditCount }: Props) {
const [search, setSearch] = useState('')
const filteredApps = initialApps.filter(app =>
app.name.toLowerCase().includes(search.toLowerCase()) ||
app.slug.toLowerCase().includes(search.toLowerCase()) ||
(app.description && app.description.toLowerCase().includes(search.toLowerCase()))
)
return (
<div className="space-y-8 animate-fade-in">
{/* Hero / Stat cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Stat 1 */}
<div className="glass-panel p-5 rounded-2xl relative overflow-hidden group">
<div className="absolute top-0 right-0 w-24 h-24 bg-emerald-500/10 rounded-full blur-2xl group-hover:bg-emerald-500/20 transition-colors" />
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Kayıtlı Uygulamalar</span>
<div className="w-8 h-8 rounded-xl bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-emerald-400">
<Layers className="w-4 h-4" />
</div>
</div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-extrabold text-white tracking-tight">{initialApps.length}</span>
<span className="text-xs text-emerald-400 font-medium">Aktif Servis</span>
</div>
</div>
{/* Stat 2 */}
<div className="glass-panel p-5 rounded-2xl relative overflow-hidden group">
<div className="absolute top-0 right-0 w-24 h-24 bg-cyan-500/10 rounded-full blur-2xl group-hover:bg-cyan-500/20 transition-colors" />
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Toplam Config & Secret</span>
<div className="w-8 h-8 rounded-xl bg-cyan-500/10 border border-cyan-500/20 flex items-center justify-center text-cyan-400">
<Key className="w-4 h-4" />
</div>
</div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-extrabold text-white tracking-tight">{totalConfigsCount}</span>
<span className="text-xs text-cyan-400 font-medium">Değer Tanımlı</span>
</div>
</div>
{/* Stat 3 */}
<div className="glass-panel p-5 rounded-2xl relative overflow-hidden group">
<div className="absolute top-0 right-0 w-24 h-24 bg-purple-500/10 rounded-full blur-2xl group-hover:bg-purple-500/20 transition-colors" />
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Ortam Desteği</span>
<div className="w-8 h-8 rounded-xl bg-purple-500/10 border border-purple-500/20 flex items-center justify-center text-purple-400">
<Zap className="w-4 h-4" />
</div>
</div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-extrabold text-white tracking-tight">3</span>
<span className="text-xs text-purple-400 font-medium">Dev · Stage · Prod</span>
</div>
</div>
{/* Stat 4 */}
<div className="glass-panel p-5 rounded-2xl relative overflow-hidden group">
<div className="absolute top-0 right-0 w-24 h-24 bg-amber-500/10 rounded-full blur-2xl group-hover:bg-amber-500/20 transition-colors" />
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">İşlem Denetimi</span>
<div className="w-8 h-8 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-center text-amber-400">
<ShieldCheck className="w-4 h-4" />
</div>
</div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-extrabold text-white tracking-tight">{totalAuditCount}</span>
<span className="text-xs text-amber-400 font-medium">Audit Kaydı</span>
</div>
</div>
</div>
{/* Action Header & Search */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4 pt-2">
<div className="relative flex-1 max-w-md">
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Uygulama adı veya slug ara..."
className="w-full glass-input rounded-xl pl-10 pr-4 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none"
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 hover:text-white"
>
Temizle
</button>
)}
</div>
<Link
href="/dashboard/apps/new"
className="inline-flex items-center justify-center gap-2 bg-emerald-500 hover:bg-emerald-400 active:bg-emerald-600 text-slate-950 font-bold text-sm px-5 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>Yeni Uygulama Ekle</span>
</Link>
</div>
{/* App Cards Grid */}
{filteredApps.length === 0 ? (
<div className="glass-panel text-center py-16 px-4 rounded-3xl border border-dashed border-slate-800">
<div className="w-14 h-14 bg-slate-900 border border-slate-800 rounded-2xl flex items-center justify-center mx-auto mb-4 text-slate-500">
<SlidersHorizontal className="w-7 h-7" />
</div>
<h3 className="text-white text-lg font-bold mb-1">
{search ? 'Eşleşen uygulama bulunamadı' : 'Henüz hiç uygulama eklenmemiş'}
</h3>
<p className="text-slate-400 text-sm max-w-md mx-auto mb-6">
{search
? `"${search}" aramasıyla eşleşen bir sonuç yok. Başka bir anahtar kelime deneyin.`
: 'ConfigVault ile mobil ve web uygulamalarınızın ortam değişkenlerini dinamik olarak yönetmeye başlamak için ilk uygulamanızı ekleyin.'}
</p>
{!search && (
<Link
href="/dashboard/apps/new"
className="inline-flex items-center gap-2 bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-bold text-sm px-5 py-2.5 rounded-xl shadow-lg shadow-emerald-500/25 transition-transform hover:scale-105"
>
<Plus className="w-4 h-4 stroke-[2.5]" />
<span>İlk Uygulamayı Oluştur</span>
</Link>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{filteredApps.map(app => (
<AppCard key={app.id} app={app} />
))}
</div>
)}
</div>
)
}
function AppCard({ app }: { app: DashboardApp }) {
const formattedDate = new Date(app.updated_at).toLocaleDateString('tr-TR', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
// Color theme generator based on app name initial
const initial = app.name.charAt(0).toUpperCase()
const gradients = [
'from-emerald-500 to-teal-700',
'from-cyan-500 to-blue-700',
'from-indigo-500 to-purple-700',
'from-amber-500 to-orange-700',
'from-rose-500 to-pink-700',
]
const gradientIndex = (initial.charCodeAt(0) || 0) % gradients.length
const bgGradient = gradients[gradientIndex]
return (
<Link
href={`/dashboard/apps/${app.id}`}
className="glass-panel-interactive rounded-2xl p-6 flex flex-col justify-between group relative overflow-hidden"
>
{/* Ambient background hover flare */}
<div className="absolute top-0 right-0 w-32 h-32 bg-emerald-500/5 group-hover:bg-emerald-500/10 rounded-full blur-2xl transition-all duration-300 pointer-events-none" />
<div>
{/* Top bar: Avatar & Arrow */}
<div className="flex items-start justify-between mb-4">
<AppIcon name={app.name} iconUrl={app.icon_url} size="lg" />
<div className="w-8 h-8 rounded-xl bg-slate-800/60 border border-slate-700/50 flex items-center justify-center text-slate-400 group-hover:text-emerald-400 group-hover:border-emerald-500/30 group-hover:bg-emerald-500/10 transition-all">
<ArrowUpRight className="w-4 h-4 transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</div>
</div>
{/* Title & Slug */}
<h3 className="text-white text-lg font-bold group-hover:text-emerald-300 transition-colors tracking-tight">
{app.name}
</h3>
<div className="inline-flex items-center gap-1 mt-1 px-2 py-0.5 rounded-md bg-slate-900/90 border border-slate-800 text-[11px] font-mono text-slate-400">
<span>slug:</span>
<span className="text-emerald-400 font-semibold">{app.slug}</span>
</div>
{/* Description */}
<p className="text-slate-400 text-xs mt-3 line-clamp-2 leading-relaxed min-h-[32px]">
{app.description || <span className="text-slate-600 italic">Açıklama girilmemiş</span>}
</p>
</div>
{/* Bottom meta details */}
<div className="mt-6 pt-4 border-t border-slate-800/80 space-y-3">
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-1.5 text-slate-400">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
<span className="font-mono text-[11px]">
{Number(app.config_count) || 0} config kaydı
</span>
</div>
<div className="flex items-center gap-1 text-[11px] text-slate-500">
<Clock className="w-3 h-3" />
<span>{formattedDate}</span>
</div>
</div>
{/* Env tag pills */}
<div className="flex items-center gap-1.5">
<span className="text-[10px] font-medium px-2 py-0.5 rounded bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
prod
</span>
<span className="text-[10px] font-medium px-2 py-0.5 rounded bg-amber-500/10 text-amber-400 border border-amber-500/20">
stage
</span>
<span className="text-[10px] font-medium px-2 py-0.5 rounded bg-cyan-500/10 text-cyan-400 border border-cyan-500/20">
dev
</span>
</div>
</div>
</Link>
)
}
+197
View File
@@ -0,0 +1,197 @@
'use client'
import { useState, useRef } from 'react'
import { Upload, Link as LinkIcon, Sparkles, X, Image as ImageIcon } from 'lucide-react'
import { AppIcon } from './AppIcon'
import { cn } from '@/lib/utils'
interface IconPickerProps {
value: string
onChange: (val: string) => void
appName: string
}
const PRESET_EMOJIS = [
'📱', '🚀', '🛍️', '💳', '⚡', '🛡️', '🤖', '📦',
'🎮', '📊', '🎧', '💬', '🌐', '🔒', '🍔', '🚗',
'✈️', '📈', '🎨', '🎵', '🏥', '🏢', '⚙️', '🔑'
]
export function IconPicker({ value, onChange, appName }: IconPickerProps) {
const [activeTab, setActiveTab] = useState<'presets' | 'url' | 'upload'>('presets')
const [urlInput, setUrlInput] = useState(value?.startsWith('http') ? value : '')
const fileInputRef = useRef<HTMLInputElement>(null)
function handleEmojiSelect(emoji: string) {
onChange(emoji)
}
function handleUrlChange(url: string) {
setUrlInput(url)
onChange(url)
}
function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
// Limit size to 1.5MB
if (file.size > 1.5 * 1024 * 1024) {
alert('İkon boyutu 1.5MB\'dan küçük olmalıdır.')
return
}
const reader = new FileReader()
reader.onload = () => {
if (typeof reader.result === 'string') {
onChange(reader.result)
}
}
reader.readAsDataURL(file)
}
function handleClear() {
onChange('')
setUrlInput('')
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="block text-xs font-semibold text-slate-300 uppercase tracking-wider">
Uygulama İkonu <span className="text-slate-500 font-normal lowercase">(isteğe bağlı)</span>
</label>
{value && (
<button
type="button"
onClick={handleClear}
className="text-[11px] text-rose-400 hover:text-rose-300 flex items-center gap-1 transition-colors"
>
<X className="w-3 h-3" />
<span>İkonu Kaldır</span>
</button>
)}
</div>
<div className="p-4 rounded-2xl bg-slate-900/70 border border-slate-800 space-y-4">
{/* Live Preview & Tabs Header */}
<div className="flex items-center gap-4 pb-3 border-b border-slate-800/80">
<div className="flex flex-col items-center gap-1.5">
<AppIcon name={appName || 'A'} iconUrl={value} size="lg" className="ring-2 ring-emerald-500/20" />
<span className="text-[10px] text-slate-500 font-medium">Önizleme</span>
</div>
{/* Mode Switcher */}
<div className="flex-1 flex gap-1 p-1 bg-slate-950/80 rounded-xl border border-slate-800">
<button
type="button"
onClick={() => setActiveTab('presets')}
className={cn(
'flex-1 py-1.5 px-2 rounded-lg text-xs font-medium transition-all flex items-center justify-center gap-1.5',
activeTab === 'presets' ? 'bg-slate-800 text-emerald-400 shadow-sm' : 'text-slate-400 hover:text-white'
)}
>
<Sparkles className="w-3 h-3" />
<span>Hazır İkonlar</span>
</button>
<button
type="button"
onClick={() => setActiveTab('upload')}
className={cn(
'flex-1 py-1.5 px-2 rounded-lg text-xs font-medium transition-all flex items-center justify-center gap-1.5',
activeTab === 'upload' ? 'bg-slate-800 text-emerald-400 shadow-sm' : 'text-slate-400 hover:text-white'
)}
>
<Upload className="w-3 h-3" />
<span>Görsel Yükle</span>
</button>
<button
type="button"
onClick={() => setActiveTab('url')}
className={cn(
'flex-1 py-1.5 px-2 rounded-lg text-xs font-medium transition-all flex items-center justify-center gap-1.5',
activeTab === 'url' ? 'bg-slate-800 text-emerald-400 shadow-sm' : 'text-slate-400 hover:text-white'
)}
>
<LinkIcon className="w-3 h-3" />
<span>URL</span>
</button>
</div>
</div>
{/* Tab 1: Presets */}
{activeTab === 'presets' && (
<div className="grid grid-cols-8 sm:grid-cols-12 gap-1.5 animate-fade-in">
{PRESET_EMOJIS.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => handleEmojiSelect(emoji)}
className={cn(
'h-9 rounded-xl flex items-center justify-center text-lg transition-all hover:scale-110 hover:bg-slate-800',
value === emoji
? 'bg-emerald-500/20 ring-2 ring-emerald-400'
: 'bg-slate-900/60 border border-slate-800/60'
)}
>
{emoji}
</button>
))}
</div>
)}
{/* Tab 2: Upload */}
{activeTab === 'upload' && (
<div className="space-y-2 animate-fade-in">
<input
type="file"
ref={fileInputRef}
onChange={handleFileUpload}
accept="image/png, image/jpeg, image/webp, image/svg+xml"
className="hidden"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="w-full py-4 border-2 border-dashed border-slate-700/80 hover:border-emerald-500/50 rounded-xl bg-slate-950/40 hover:bg-slate-900/40 flex flex-col items-center justify-center gap-1.5 transition-colors cursor-pointer"
>
<Upload className="w-5 h-5 text-emerald-400" />
<span className="text-xs font-semibold text-slate-300">
Görsel Seçin veya Sürükleyin
</span>
<span className="text-[10px] text-slate-500">
PNG, JPG, WebP veya SVG (Önerilen: 256x256)
</span>
</button>
</div>
)}
{/* Tab 3: URL */}
{activeTab === 'url' && (
<div className="space-y-2 animate-fade-in">
<div className="flex items-center glass-input rounded-xl overflow-hidden">
<span className="px-3 text-slate-500 text-xs border-r border-slate-800 py-2.5">
https://
</span>
<input
type="url"
value={urlInput}
onChange={(e) => handleUrlChange(e.target.value)}
placeholder="ornek.com/logo.png"
className="flex-1 bg-transparent px-3 py-2 text-white text-xs font-mono placeholder-slate-500 focus:outline-none"
/>
</div>
<p className="text-[11px] text-slate-500">
CDN veya harici doğrudan görsel linki yapıştırın.
</p>
</div>
)}
</div>
</div>
)
}
+57
View File
@@ -0,0 +1,57 @@
import Link from 'next/link'
import { Shield, Database, ExternalLink, Code2 } from 'lucide-react'
import { SignOutButton } from '@/components/SignOutButton'
import { AppIcon } from './AppIcon'
interface NavbarProps {
appName?: string
appId?: string
iconUrl?: string | null
}
export function Navbar({ appName, appId, iconUrl }: NavbarProps) {
return (
<header className="sticky top-0 z-40 border-b border-slate-800/80 bg-[#080B11]/80 backdrop-blur-xl">
<div className="max-w-7xl mx-auto px-4 sm:px-6 h-16 flex items-center justify-between">
{/* Brand & Breadcrumbs */}
<div className="flex items-center gap-4">
<Link href="/dashboard" className="flex items-center gap-2.5 group">
<div className="w-8 h-8 bg-gradient-to-tr from-emerald-600 to-emerald-400 rounded-xl flex items-center justify-center shadow-lg shadow-emerald-500/20 group-hover:scale-105 transition-transform">
<Shield className="w-4 h-4 text-slate-950 stroke-[2.4]" />
</div>
<span className="text-white font-bold text-base tracking-tight">ConfigVault</span>
</Link>
{appName && (
<div className="flex items-center gap-2 text-sm">
<span className="text-slate-600">/</span>
<Link
href="/dashboard"
className="text-slate-400 hover:text-slate-200 transition-colors text-xs font-medium"
>
Uygulamalar
</Link>
<span className="text-slate-600">/</span>
<span className="inline-flex items-center gap-1.5 text-emerald-400 font-semibold text-xs px-2 py-0.5 rounded-md bg-emerald-500/10 border border-emerald-500/20">
<AppIcon name={appName} iconUrl={iconUrl} size="sm" className="w-4 h-4 rounded text-[10px]" />
<span>{appName}</span>
</span>
</div>
)}
</div>
{/* Right tools & status */}
<div className="flex items-center gap-3">
<div className="hidden sm:flex items-center gap-2 px-2.5 py-1 rounded-full bg-slate-900/80 border border-slate-800 text-[11px] text-slate-400">
<div className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
<span>PostgreSQL Aktif</span>
</div>
<div className="h-4 w-[1px] bg-slate-800 hidden sm:block" />
<SignOutButton />
</div>
</div>
</header>
)
}
+17
View File
@@ -0,0 +1,17 @@
'use client'
import { signOut } from 'next-auth/react'
import { LogOut } from 'lucide-react'
export function SignOutButton() {
return (
<button
onClick={() => signOut({ callbackUrl: '/login' })}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-slate-400 hover:text-rose-300 hover:bg-rose-500/10 border border-transparent hover:border-rose-500/20 text-xs font-medium transition-all duration-150"
title="Çıkış Yap"
>
<LogOut className="w-3.5 h-3.5" />
<span>Çıkış Yap</span>
</button>
)
}
+59
View File
@@ -0,0 +1,59 @@
import { NextAuthOptions } from 'next-auth'
import CredentialsProvider from 'next-auth/providers/credentials'
import bcrypt from 'bcryptjs'
export const authOptions: NextAuthOptions = {
secret: process.env.NEXTAUTH_SECRET,
providers: [
CredentialsProvider({
name: 'credentials',
credentials: {
username: { label: 'Username', type: 'text' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
if (!credentials?.username || !credentials?.password) return null
const adminUsername = process.env.ADMIN_USERNAME
let adminPasswordHash = process.env.ADMIN_PASSWORD_HASH
if (!adminUsername || !adminPasswordHash) {
throw new Error('Admin credentials not configured')
}
// Handle base64 encoded hash to avoid Next.js .env $ expansion issues
if (adminPasswordHash.startsWith('JDJ') || (!adminPasswordHash.startsWith('$2') && !adminPasswordHash.startsWith('$2a'))) {
try {
const decoded = Buffer.from(adminPasswordHash, 'base64').toString('utf8')
if (decoded.startsWith('$2')) {
adminPasswordHash = decoded
}
} catch {
// keep original if decoding fails
}
}
if (credentials.username !== adminUsername) return null
const isValid = await bcrypt.compare(credentials.password, adminPasswordHash)
if (!isValid) return null
return { id: '1', name: adminUsername, email: `${adminUsername}@configvault` }
},
}),
],
session: { strategy: 'jwt' },
pages: {
signIn: '/login',
},
callbacks: {
async jwt({ token, user }) {
if (user) token.id = user.id
return token
},
async session({ session, token }) {
if (session.user) (session.user as any).id = token.id
return session
},
},
}
+15
View File
@@ -0,0 +1,15 @@
import postgres from 'postgres'
// DATABASE_URL örneği:
// postgresql://user:password@localhost:5432/configvault
// postgresql://user:password@your-server.com:5432/configvault?sslmode=require
const sql = postgres(process.env.DATABASE_URL!, {
max: 10, // max connection pool size
idle_timeout: 20, // saniye
connect_timeout: 10,
// SSL: production'da genellikle gerekli, lokal'de kapalı
ssl: process.env.DATABASE_SSL === 'true' ? 'require' : false,
})
export default sql
+80
View File
@@ -0,0 +1,80 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function slugify(text: string): string {
return text
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^\w-]+/g, '')
.replace(/--+/g, '-')
.trim()
}
export function maskSecret(value: string): string {
if (!value) return '••••••••'
if (value.length <= 8) return '••••••••••••'
return value.slice(0, 4) + '•'.repeat(Math.min(value.length - 8, 16)) + value.slice(-4)
}
export function envBadgeStyles(env: string) {
switch (env) {
case 'production':
return {
bg: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20 hover:border-emerald-500/40',
active: 'bg-emerald-500 text-slate-950 font-semibold shadow-lg shadow-emerald-500/25 border-emerald-400',
dot: 'bg-emerald-400',
}
case 'staging':
return {
bg: 'bg-amber-500/10 text-amber-400 border-amber-500/20 hover:border-amber-500/40',
active: 'bg-amber-500 text-slate-950 font-semibold shadow-lg shadow-amber-500/25 border-amber-400',
dot: 'bg-amber-400',
}
case 'development':
return {
bg: 'bg-cyan-500/10 text-cyan-400 border-cyan-500/20 hover:border-cyan-500/40',
active: 'bg-cyan-500 text-slate-950 font-semibold shadow-lg shadow-cyan-500/25 border-cyan-400',
dot: 'bg-cyan-400',
}
default:
return {
bg: 'bg-slate-500/10 text-slate-400 border-slate-500/20 hover:border-slate-500/40',
active: 'bg-slate-500 text-slate-950 font-semibold shadow-lg shadow-slate-500/25 border-slate-400',
dot: 'bg-slate-400',
}
}
}
export function envColor(env: string) {
switch (env) {
case 'production':
return 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20'
case 'staging':
return 'bg-amber-500/10 text-amber-400 border-amber-500/20'
case 'development':
return 'bg-cyan-500/10 text-cyan-400 border-cyan-500/20'
default:
return 'bg-slate-500/10 text-slate-400 border-slate-500/20'
}
}
export function typeColor(type: string) {
switch (type) {
case 'secret':
return 'bg-rose-500/10 text-rose-400 border-rose-500/20'
case 'url':
return 'bg-sky-500/10 text-sky-400 border-sky-500/20'
case 'json':
return 'bg-purple-500/10 text-purple-400 border-purple-500/20'
case 'boolean':
return 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20'
case 'number':
return 'bg-amber-500/10 text-amber-400 border-amber-500/20'
default:
return 'bg-slate-500/10 text-slate-300 border-slate-500/20'
}
}
+7
View File
@@ -0,0 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
output: 'standalone',
}
module.exports = nextConfig
+2469
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "configvault",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"clsx": "^2.1.1",
"lucide-react": "^0.378.0",
"nanoid": "^5.0.7",
"next": "^16.3.3",
"next-auth": "^4.24.7",
"postgres": "^3.4.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"tailwind-merge": "^2.3.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"autoprefixer": "^10.0.1",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+11
View File
@@ -0,0 +1,11 @@
import { withAuth } from 'next-auth/middleware'
export default withAuth({
pages: {
signIn: '/login',
},
})
export const config = {
matcher: ['/dashboard/:path*'],
}
+1
View File
@@ -0,0 +1 @@
# Keep public directory
+112
View File
@@ -0,0 +1,112 @@
-- ConfigVault Database Schema
-- Run this in your Supabase SQL Editor
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ============================================
-- APPS TABLE
-- ============================================
CREATE TABLE apps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
description TEXT,
icon_url TEXT,
api_key TEXT UNIQUE NOT NULL DEFAULT encode(gen_random_bytes(32), 'hex'),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ============================================
-- ENVIRONMENTS TABLE
-- ============================================
CREATE TABLE environments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
name TEXT NOT NULL CHECK (name IN ('development', 'staging', 'production')),
UNIQUE(app_id, name)
);
-- ============================================
-- CONFIG ENTRIES TABLE
-- ============================================
CREATE TABLE config_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
environment_id UUID NOT NULL REFERENCES environments(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'text' CHECK (type IN ('text', 'secret', 'url', 'json', 'boolean', 'number')),
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(environment_id, key)
);
-- ============================================
-- AUDIT LOGS TABLE
-- ============================================
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
app_id UUID REFERENCES apps(id) ON DELETE SET NULL,
environment TEXT,
action TEXT NOT NULL CHECK (action IN ('create', 'update', 'delete', 'rotate_key', 'create_app', 'delete_app')),
key TEXT,
old_value TEXT, -- null for creates
actor TEXT NOT NULL DEFAULT 'admin',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ============================================
-- AUTO-CREATE 3 ENVIRONMENTS ON NEW APP
-- ============================================
CREATE OR REPLACE FUNCTION create_default_environments()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO environments (app_id, name) VALUES
(NEW.id, 'development'),
(NEW.id, 'staging'),
(NEW.id, 'production');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER on_app_created
AFTER INSERT ON apps
FOR EACH ROW EXECUTE FUNCTION create_default_environments();
-- ============================================
-- AUTO-UPDATE updated_at
-- ============================================
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER apps_updated_at
BEFORE UPDATE ON apps
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER config_entries_updated_at
BEFORE UPDATE ON config_entries
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- ============================================
-- ROW LEVEL SECURITY (disable for service role)
-- ============================================
ALTER TABLE apps DISABLE ROW LEVEL SECURITY;
ALTER TABLE environments DISABLE ROW LEVEL SECURITY;
ALTER TABLE config_entries DISABLE ROW LEVEL SECURITY;
ALTER TABLE audit_logs DISABLE ROW LEVEL SECURITY;
-- ============================================
-- INDEXES
-- ============================================
CREATE INDEX idx_environments_app_id ON environments(app_id);
CREATE INDEX idx_config_entries_environment_id ON config_entries(environment_id);
CREATE INDEX idx_audit_logs_app_id ON audit_logs(app_id);
CREATE INDEX idx_apps_api_key ON apps(api_key);
CREATE INDEX idx_apps_slug ON apps(slug);
+68
View File
@@ -0,0 +1,68 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['var(--font-inter)', 'system-ui', 'sans-serif'],
mono: ['var(--font-mono)', 'monospace'],
},
colors: {
brand: {
50: '#ecfdf5',
100: '#d1fae5',
200: '#a7f3d0',
300: '#6ee7b7',
400: '#34d399',
500: '#10b981',
600: '#059669',
700: '#047857',
800: '#065f46',
900: '#064e3b',
950: '#022c22',
},
dark: {
50: '#f8fafc',
100: '#f1f5f9',
800: '#1e293b',
850: '#172033',
900: '#0f172a',
950: '#090d16',
1000: '#05070b',
}
},
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'glass-gradient': 'linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.01) 100%)',
},
animation: {
'glow-pulse': 'glow 3s ease-in-out infinite alternate',
'fade-in': 'fadeIn 0.25s ease-out forwards',
'slide-up': 'slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards',
},
keyframes: {
glow: {
'0%': { opacity: '0.4' },
'100%': { opacity: '0.8' },
},
fadeIn: {
'0%': { opacity: '0', transform: 'scale(0.98)' },
'100%': { opacity: '1', transform: 'scale(1)' },
},
slideUp: {
'0%': { opacity: '0', transform: 'translateY(8px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
}
}
},
},
plugins: [],
};
export default config;
+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
},
"target": "ES2017"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
+42
View File
@@ -0,0 +1,42 @@
export type Environment = 'development' | 'staging' | 'production'
export type ConfigType = 'text' | 'secret' | 'url' | 'json' | 'boolean' | 'number'
export interface App {
id: string
name: string
slug: string
description: string | null
icon_url?: string | null
api_key: string
created_at: string
updated_at: string
}
export interface AppEnvironment {
id: string
app_id: string
name: Environment
}
export interface ConfigEntry {
id: string
environment_id: string
key: string
value: string
type: ConfigType
description: string | null
created_at: string
updated_at: string
}
export interface AuditLog {
id: string
app_id: string | null
environment: string | null
action: string
key: string | null
old_value: string | null
actor: string
created_at: string
}