first commit
This commit is contained in:
@@ -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
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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 • 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
output: 'standalone',
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
Generated
+2469
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { withAuth } from 'next-auth/middleware'
|
||||
|
||||
export default withAuth({
|
||||
pages: {
|
||||
signIn: '/login',
|
||||
},
|
||||
})
|
||||
|
||||
export const config = {
|
||||
matcher: ['/dashboard/:path*'],
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# Keep public directory
|
||||
+112
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user