feat(tutorials): add video training academy management with YouTube parser and live preview

This commit is contained in:
mstfyldz
2026-08-22 13:49:00 +03:00
parent 2d149f1178
commit 7f690cc6d3
29 changed files with 2476 additions and 1034 deletions
+7 -1
View File
@@ -39,4 +39,10 @@
- coolify-deploy → deploy pipeline
## Proje Özel Notlar
<!-- Buraya proje bazlı notlar ekle -->
- Bu proje Prisma KULLANMIYOR (scaffold'daki prisma/ ve lib/db.ts kaldırıldı
Prisma 7 schema formatıyla uyumsuzdu ve kullanılmıyordu). Veri katmanı
`lib/supabaseAdmin.ts` üzerinden AyrisLegal'ın gerçek self-hosted Supabase
projesine (laawos/laawos-backend ile AYNI proje) service_role key ile
bağlanıyor — server-only, RLS bypass edilir. Admin paneli girişi (NextAuth)
ise gerçek kullanıcı sistemine değil, ADMIN_EMAIL/ADMIN_PASSWORD env
değişkenlerine bağlı, ayrı ve basit bir kontrol.
+1 -4
View File
@@ -11,10 +11,7 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
# Prisma generate için dummy URL — build sırasında gerçek DB gerekmez
ARG DATABASE_URL=postgresql://dummy:dummy@localhost:5432/dummy
ENV DATABASE_URL=$DATABASE_URL
RUN npx prisma generate
# SUPABASE_SERVICE_ROLE_KEY build-time'da gerekmez (server-only, runtime'da okunuyor).
RUN npm run build
FROM base AS runner
+4 -1
View File
@@ -3,7 +3,7 @@
import { signOut } from 'next-auth/react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { LayoutDashboard, Users, Settings, LogOut, Menu, X } from 'lucide-react'
import { LayoutDashboard, Users, KeyRound, ScrollText, Settings, Video, LogOut, Menu, X } from 'lucide-react'
import { useState } from 'react'
export default function AdminLayout({ children }: { children: React.ReactNode }) {
@@ -13,6 +13,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const navigation = [
{ name: 'Dashboard', href: '/admin', icon: LayoutDashboard },
{ name: 'Kullanıcılar', href: '/admin/users', icon: Users },
{ name: 'Lisanslar', href: '/admin/licenses', icon: KeyRound },
{ name: 'Eğitim Videoları', href: '/admin/tutorials', icon: Video },
{ name: 'Loglar', href: '/admin/logs', icon: ScrollText },
{ name: 'Ayarlar', href: '/admin/settings', icon: Settings },
]
@@ -0,0 +1,57 @@
'use client'
import { useState } from 'react'
import { deleteLicenseAction } from '../actions'
const CONFIRM_WORD = 'SİL'
export default function DeleteLicenseButton({ id }: { id: string }) {
const [confirmText, setConfirmText] = useState('')
const [open, setOpen] = useState(false)
const canDelete = confirmText.trim().toUpperCase() === CONFIRM_WORD
if (!open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
className="rounded-md border border-red-300 dark:border-red-900 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30 text-sm font-medium px-4 py-2 transition-colors"
>
Lisansı Sil
</button>
)
}
return (
<form action={deleteLicenseAction} className="space-y-3">
<input type="hidden" name="id" value={id} />
<p className="text-sm text-gray-600 dark:text-gray-400">
Bu işlem geri alınamaz lisans ve bağlı cihaz aktivasyonları kalıcı olarak silinir. Sadece kullanımı durdurmak için "Durum" alanını <span className="font-mono">revoked</span> yapmanız yeterli olabilir.
Onaylamak için <span className="font-mono text-gray-900 dark:text-white">{CONFIRM_WORD}</span> yazın:
</p>
<input
type="text"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={CONFIRM_WORD}
className="w-full max-w-xs rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-red-500"
/>
<div className="flex items-center gap-3">
<button
type="submit"
disabled={!canDelete}
className="rounded-md bg-red-600 hover:bg-red-700 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium px-4 py-2 transition-colors"
>
Kalıcı Olarak Sil
</button>
<button
type="button"
onClick={() => { setOpen(false); setConfirmText('') }}
className="text-sm text-gray-500 hover:text-gray-900 dark:hover:text-white"
>
Vazgeç
</button>
</div>
</form>
)
}
+203
View File
@@ -0,0 +1,203 @@
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { supabaseAdmin, listAuthUsers, listCustomerOptions } from '@/lib/supabaseAdmin'
import { updateLicenseAction, removeActivationAction } from '../actions'
import DeleteLicenseButton from './DeleteLicenseButton'
interface LicenseRow {
id: string
customer_id: string | null
plan: string
status: string
max_devices: number
expires_at: string | null
created_at: string
}
interface ActivationRow {
id: string
device_id: string
device_name: string | null
platform: string | null
app_version: string | null
last_seen_at: string | null
}
function toDateInputValue(iso: string | null) {
if (!iso) return ''
return new Date(iso).toISOString().slice(0, 10)
}
function formatDateTime(iso: string | null) {
if (!iso) return '—'
return new Date(iso).toLocaleString('tr-TR', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
}
export default async function LicenseEditPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>
searchParams: Promise<{ error?: string; success?: string }>
}) {
const { id } = await params
const { error, success } = await searchParams
const [{ data: license }, { data: profiles }, authUsers, customers, { data: activations }] = await Promise.all([
supabaseAdmin.from('licenses').select('id, customer_id, plan, status, max_devices, expires_at, created_at').eq('id', id).maybeSingle(),
supabaseAdmin.from('profiles').select('id, full_name'),
listAuthUsers(),
listCustomerOptions(),
supabaseAdmin.from('license_activations').select('id, device_id, device_name, platform, app_version, last_seen_at').eq('license_id', id).order('last_seen_at', { ascending: false }),
])
if (!license) notFound()
const l = license as LicenseRow
const profileNameById = new Map((profiles || []).map((p: { id: string; full_name: string | null }) => [p.id, p.full_name]))
const currentEmail = l.customer_id ? authUsers.get(l.customer_id)?.email || '' : ''
const currentName = l.customer_id ? profileNameById.get(l.customer_id) : null
return (
<div className="space-y-6 max-w-2xl">
<div>
<Link href="/admin/licenses" className="text-sm text-gray-500 hover:text-gray-900 dark:hover:text-white"> Lisanslar</Link>
<h2 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-white mt-2">Lisans Düzenle</h2>
<p className="text-gray-500 dark:text-gray-400 mt-1 font-mono text-xs">{l.id}</p>
</div>
{error && (
<div className="rounded-md bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-900 px-4 py-3 text-sm text-red-700 dark:text-red-400">
{error}
</div>
)}
{success && (
<div className="rounded-md bg-green-50 dark:bg-green-950/40 border border-green-200 dark:border-green-900 px-4 py-3 text-sm text-green-700 dark:text-green-400">
Değişiklikler kaydedildi.
</div>
)}
<form action={updateLicenseAction} className="rounded-lg bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 shadow-sm p-6 space-y-5">
<input type="hidden" name="id" value={l.id} />
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Müşteri</label>
<select
name="customer_email"
defaultValue={currentEmail}
className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value=""> Bağlantı yok </option>
{currentEmail && !customers.some((c) => c.email === currentEmail) && (
<option value={currentEmail}>{currentName || '(isimsiz)'} {currentEmail} (profil listede yok)</option>
)}
{customers.map((c) => (
<option key={c.id} value={c.email}>
{c.name || '(isimsiz)'} {c.email}
</option>
))}
</select>
<p className="mt-1 text-xs text-gray-400">
{currentName ? `Şu an: ${currentName} (${currentEmail})` : currentEmail ? `Şu an: ${currentEmail}` : 'Şu an bağlı kullanıcı yok.'}
{' '}Kullanıcılar listesinden seçin. "Bağlantı yok" seçilirse bağlantı kaldırılır.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Plan</label>
<input
type="text"
name="plan"
defaultValue={l.plan}
list="plan-options"
className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
<datalist id="plan-options">
<option value="trial" />
<option value="starter" />
<option value="professional" />
<option value="enterprise" />
</datalist>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Durum</label>
<select
name="status"
defaultValue={l.status}
className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="active">active</option>
<option value="expired">expired</option>
<option value="revoked">revoked</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Maks. Cihaz</label>
<input
type="number"
name="max_devices"
min={1}
defaultValue={l.max_devices}
className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Bitiş Tarihi</label>
<input
type="date"
name="expires_at"
defaultValue={toDateInputValue(l.expires_at)}
className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
</div>
<div className="flex items-center gap-3 pt-2">
<button
type="submit"
className="rounded-md bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium px-4 py-2 transition-colors"
>
Kaydet
</button>
<Link href="/admin/licenses" className="text-sm text-gray-500 hover:text-gray-900 dark:hover:text-white">
Vazgeç
</Link>
</div>
</form>
<div className="rounded-lg bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 shadow-sm p-6">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-3">
Aktif Cihazlar ({(activations as ActivationRow[] | null)?.length || 0} / {l.max_devices})
</h3>
{!activations || activations.length === 0 ? (
<p className="text-sm text-gray-500">Bu lisansta aktive edilmiş cihaz yok.</p>
) : (
<ul className="divide-y divide-gray-100 dark:divide-gray-800">
{(activations as ActivationRow[]).map((a) => (
<li key={a.id} className="py-2.5 flex items-center justify-between gap-4 text-sm">
<div className="min-w-0">
<div className="text-gray-900 dark:text-white truncate">{a.device_name || a.platform || 'Bilinmeyen cihaz'} <span className="text-gray-400 font-normal">· v{a.app_version || '?'}</span></div>
<div className="text-xs text-gray-400 font-mono truncate">{a.device_id} · son görülme {formatDateTime(a.last_seen_at)}</div>
</div>
<form action={removeActivationAction}>
<input type="hidden" name="activation_id" value={a.id} />
<input type="hidden" name="license_id" value={l.id} />
<button type="submit" className="text-red-600 dark:text-red-400 hover:underline whitespace-nowrap">Kaldır</button>
</form>
</li>
))}
</ul>
)}
</div>
<div className="rounded-lg bg-white dark:bg-gray-950 border border-red-200 dark:border-red-900 shadow-sm p-6">
<h3 className="text-sm font-semibold text-red-700 dark:text-red-400 uppercase tracking-wide mb-3">Tehlikeli Bölge</h3>
<DeleteLicenseButton id={l.id} />
</div>
</div>
)
}
+186
View File
@@ -0,0 +1,186 @@
'use server'
import { randomBytes, createHash } from 'crypto'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { auth } from '@/lib/auth'
import { supabaseAdmin, listAuthUsers } from '@/lib/supabaseAdmin'
import { setProfileAccess } from '../users/actions'
async function requireAdmin() {
const session = await auth()
if (!session || (session.user as any)?.role !== 'ADMIN') {
throw new Error('Yetkisiz erişim')
}
}
function toIsoOrNull(dateInput: FormDataEntryValue | null): string | null {
const value = String(dateInput || '').trim()
if (!value) return null
const d = new Date(`${value}T23:59:59.999Z`)
if (isNaN(d.getTime())) return null
return d.toISOString()
}
// null dönerse "e-posta girildi ama eşleşen kullanıcı yok" (hata), undefined dönerse "e-posta boş" (dokunma)
async function resolveCustomerIdByEmail(email: string): Promise<string | null | undefined> {
const trimmed = email.trim().toLowerCase()
if (!trimmed) return undefined
const authUsers = await listAuthUsers()
const match = [...authUsers.values()].find((u) => (u.email || '').toLowerCase() === trimmed)
return match?.id ?? null
}
// profiles.license_status, licenses tablosundan ayrı bir alan ama artık uygulamanın
// TEK gerçek erişim kapısı (bkz. users/actions.ts::setProfileAccess) — buradan bir
// lisans kaydedildiğinde bağlı müşterinin profilini de aynı ortak fonksiyonla senkron
// tutuyoruz, yoksa "lisansı aktif yaptım ama uygulama hâlâ süresi dolmuş diyor" durumu oluşuyor.
async function syncProfileLicenseStatus(customerId: string, licenseStatus: string, expiresAt: string | null) {
const profileStatus = licenseStatus === 'active' ? 'active' : 'expired' // 'expired' | 'revoked' -> ikisi de erişimi kapatmalı
await setProfileAccess(customerId, profileStatus, expiresAt)
}
export async function updateLicenseAction(formData: FormData) {
await requireAdmin()
const id = String(formData.get('id') || '')
if (!id) throw new Error('Lisans id eksik')
const plan = String(formData.get('plan') || '').trim()
const status = String(formData.get('status') || '').trim()
const maxDevicesRaw = String(formData.get('max_devices') || '').trim()
const maxDevices = maxDevicesRaw ? parseInt(maxDevicesRaw, 10) : null
const expiresAt = toIsoOrNull(formData.get('expires_at'))
const email = String(formData.get('customer_email') || '')
const update: Record<string, unknown> = {}
if (plan) update.plan = plan
if (status) update.status = status
if (maxDevices && !isNaN(maxDevices)) update.max_devices = maxDevices
update.expires_at = expiresAt
const customerId = await resolveCustomerIdByEmail(email)
if (customerId === null) {
redirect(`/admin/licenses/${id}?error=${encodeURIComponent('Bu e-posta ile kayıtlı kullanıcı bulunamadı')}`)
}
update.customer_id = customerId === undefined ? null : customerId
const { error } = await supabaseAdmin.from('licenses').update(update).eq('id', id)
if (error) {
redirect(`/admin/licenses/${id}?error=${encodeURIComponent(error.message)}`)
}
const finalCustomerId = update.customer_id as string | null
if (finalCustomerId) {
await syncProfileLicenseStatus(finalCustomerId, status || 'active', expiresAt)
}
revalidatePath('/admin/licenses')
revalidatePath(`/admin/licenses/${id}`)
revalidatePath('/admin/users')
redirect(`/admin/licenses/${id}?success=1`)
}
// XXXX-XXXX-XXXX-XXXX — 0/O/1/I hariç, elle okurken/yazarken karışmasın diye
const KEY_CHARSET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
function generateLicenseKey(): string {
const groups: string[] = []
for (let g = 0; g < 4; g++) {
const bytes = randomBytes(4)
let group = ''
for (let i = 0; i < 4; i++) group += KEY_CHARSET[bytes[i] % KEY_CHARSET.length]
groups.push(group)
}
return groups.join('-')
}
// license Edge Function'daki algoritmayla birebir aynı olmalı (supa.ayris.tech/functions/v1/license):
// licenseKey.trim().toUpperCase() -> SHA-256 hex digest, salt yok.
function hashLicenseKey(key: string): string {
return createHash('sha256').update(key.trim().toUpperCase()).digest('hex')
}
export interface CreateLicenseState {
error?: string
result?: { id: string; licenseKey: string }
}
export async function createLicenseAction(_prevState: CreateLicenseState, formData: FormData): Promise<CreateLicenseState> {
await requireAdmin()
const plan = String(formData.get('plan') || 'professional').trim() || 'professional'
const status = String(formData.get('status') || 'active').trim() || 'active'
const maxDevicesRaw = String(formData.get('max_devices') || '2').trim()
const maxDevices = parseInt(maxDevicesRaw, 10) || 2
const expiresAt = toIsoOrNull(formData.get('expires_at'))
const email = String(formData.get('customer_email') || '')
const customerId = await resolveCustomerIdByEmail(email)
if (customerId === null) {
return { error: 'Bu e-posta ile kayıtlı kullanıcı bulunamadı' }
}
let licenseKey = ''
let licenseKeyHash = ''
for (let attempt = 0; attempt < 5; attempt++) {
licenseKey = generateLicenseKey()
licenseKeyHash = hashLicenseKey(licenseKey)
const { data: collision } = await supabaseAdmin.from('licenses').select('id').eq('license_key_hash', licenseKeyHash).maybeSingle()
if (!collision) break
}
const { data, error } = await supabaseAdmin
.from('licenses')
.insert({
license_key_hash: licenseKeyHash,
customer_id: customerId ?? null,
plan,
status,
max_devices: maxDevices,
expires_at: expiresAt,
})
.select('id')
.single()
if (error || !data) {
return { error: error?.message || 'Lisans oluşturulamadı' }
}
if (customerId) {
await syncProfileLicenseStatus(customerId, status, expiresAt)
}
revalidatePath('/admin/licenses')
revalidatePath('/admin/users')
return { result: { id: data.id, licenseKey } }
}
export async function deleteLicenseAction(formData: FormData) {
await requireAdmin()
const id = String(formData.get('id') || '')
if (!id) throw new Error('Lisans id eksik')
await supabaseAdmin.from('license_activations').delete().eq('license_id', id)
const { error } = await supabaseAdmin.from('licenses').delete().eq('id', id)
if (error) {
redirect(`/admin/licenses/${id}?error=${encodeURIComponent(error.message)}`)
}
revalidatePath('/admin/licenses')
redirect('/admin/licenses?deleted=1')
}
export async function removeActivationAction(formData: FormData) {
await requireAdmin()
const activationId = String(formData.get('activation_id') || '')
const licenseId = String(formData.get('license_id') || '')
if (!activationId || !licenseId) throw new Error('Eksik parametre')
await supabaseAdmin.from('license_activations').delete().eq('id', activationId)
revalidatePath(`/admin/licenses/${licenseId}`)
redirect(`/admin/licenses/${licenseId}?success=1`)
}
@@ -0,0 +1,145 @@
'use client'
import { useActionState, useState } from 'react'
import Link from 'next/link'
import { createLicenseAction, type CreateLicenseState } from '../actions'
import type { CustomerOption } from '@/lib/supabaseAdmin'
const initialState: CreateLicenseState = {}
export default function NewLicenseForm({ customers }: { customers: CustomerOption[] }) {
const [state, formAction, pending] = useActionState(createLicenseAction, initialState)
const [copied, setCopied] = useState(false)
if (state.result) {
const { id, licenseKey } = state.result
return (
<div className="rounded-lg bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 shadow-sm p-6 space-y-5">
<div className="rounded-md bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-900 px-4 py-3 text-sm text-amber-800 dark:text-amber-400">
Bu anahtar bir daha gösterilmeyecek şimdi kopyalayıp müşteriye iletin.
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Lisans Anahtarı</label>
<div className="flex items-center gap-2">
<code className="flex-1 rounded-md border border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 px-3 py-2 text-lg font-mono tracking-wider text-gray-900 dark:text-white">
{licenseKey}
</code>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(licenseKey)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}}
className="rounded-md bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium px-4 py-2 transition-colors whitespace-nowrap"
>
{copied ? 'Kopyalandı ✓' : 'Kopyala'}
</button>
</div>
</div>
<div className="flex items-center gap-3 pt-2">
<Link href={`/admin/licenses/${id}`} className="rounded-md bg-gray-900 hover:bg-gray-800 dark:bg-white dark:hover:bg-gray-200 text-white dark:text-gray-900 text-sm font-medium px-4 py-2 transition-colors">
Lisans Detayına Git
</Link>
<Link href="/admin/licenses" className="text-sm text-gray-500 hover:text-gray-900 dark:hover:text-white">
Lisanslara Dön
</Link>
</div>
</div>
)
}
return (
<form action={formAction} className="rounded-lg bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 shadow-sm p-6 space-y-5">
{state.error && (
<div className="rounded-md bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-900 px-4 py-3 text-sm text-red-700 dark:text-red-400">
{state.error}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Müşteri (opsiyonel)</label>
<select
name="customer_email"
defaultValue=""
className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value=""> Bağlantı yok </option>
{customers.map((c) => (
<option key={c.id} value={c.email}>
{c.name || '(isimsiz)'} {c.email}
</option>
))}
</select>
<p className="mt-1 text-xs text-gray-400">Kullanıcılar listesinden seçin. Boş bırakırsanız sonra da eşleştirebilirsiniz.</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Plan</label>
<input
type="text"
name="plan"
defaultValue="professional"
list="plan-options"
className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
<datalist id="plan-options">
<option value="trial" />
<option value="starter" />
<option value="professional" />
<option value="enterprise" />
</datalist>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Durum</label>
<select
name="status"
defaultValue="active"
className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="active">active</option>
<option value="expired">expired</option>
<option value="revoked">revoked</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Maks. Cihaz</label>
<input
type="number"
name="max_devices"
min={1}
defaultValue={2}
className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Bitiş Tarihi (opsiyonel)</label>
<input
type="date"
name="expires_at"
className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
</div>
<div className="flex items-center gap-3 pt-2">
<button
type="submit"
disabled={pending}
className="rounded-md bg-indigo-600 hover:bg-indigo-700 disabled:opacity-60 text-white text-sm font-medium px-4 py-2 transition-colors"
>
{pending ? 'Oluşturuluyor...' : 'Lisans Oluştur'}
</button>
<Link href="/admin/licenses" className="text-sm text-gray-500 hover:text-gray-900 dark:hover:text-white">
Vazgeç
</Link>
</div>
</form>
)
}
+19
View File
@@ -0,0 +1,19 @@
import Link from 'next/link'
import { listCustomerOptions } from '@/lib/supabaseAdmin'
import NewLicenseForm from './NewLicenseForm'
export default async function NewLicensePage() {
const customers = await listCustomerOptions()
return (
<div className="space-y-6 max-w-2xl">
<div>
<Link href="/admin/licenses" className="text-sm text-gray-500 hover:text-gray-900 dark:hover:text-white"> Lisanslar</Link>
<h2 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-white mt-2">Yeni Lisans Oluştur</h2>
<p className="text-gray-500 dark:text-gray-400 mt-1">Satış yaptığınızda burada bir lisans anahtarı üretin ve müşteriye iletin.</p>
</div>
<NewLicenseForm customers={customers} />
</div>
)
}
+124
View File
@@ -0,0 +1,124 @@
import Link from 'next/link'
import { supabaseAdmin, listAuthUsers } from '@/lib/supabaseAdmin'
interface LicenseRow {
id: string
customer_id: string
plan: string
status: string
max_devices: number
expires_at: string | null
created_at: string
}
function statusBadge(status: string) {
const map: Record<string, string> = {
active: 'bg-green-50 text-green-700 border-green-200 dark:bg-green-950/40 dark:text-green-400 dark:border-green-900',
expired: 'bg-red-50 text-red-700 border-red-200 dark:bg-red-950/40 dark:text-red-400 dark:border-red-900',
revoked: 'bg-gray-50 text-gray-600 border-gray-200 dark:bg-gray-900 dark:text-gray-400 dark:border-gray-800',
}
const cls = map[status] || 'bg-gray-50 text-gray-600 border-gray-200 dark:bg-gray-900 dark:text-gray-400 dark:border-gray-800'
return <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border ${cls}`}>{status}</span>
}
function formatDate(iso: string | null) {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('tr-TR', { day: 'numeric', month: 'short', year: 'numeric' })
}
export default async function LicensesPage({ searchParams }: { searchParams: Promise<{ deleted?: string }> }) {
const { deleted } = await searchParams
const [{ data: licenses }, { data: activations }, { data: profiles }, authUsers] = await Promise.all([
supabaseAdmin.from('licenses').select('id, customer_id, plan, status, max_devices, expires_at, created_at').order('created_at', { ascending: false }),
supabaseAdmin.from('license_activations').select('id, license_id, device_name, platform, last_seen_at'),
supabaseAdmin.from('profiles').select('id, full_name'),
listAuthUsers(),
])
const profileNameById = new Map((profiles || []).map((p: { id: string; full_name: string | null }) => [p.id, p.full_name]))
const activationsByLicense = new Map<string, { id: string; device_name: string | null; platform: string | null; last_seen_at: string | null }[]>()
for (const a of activations || []) {
const list = activationsByLicense.get(a.license_id) || []
list.push(a)
activationsByLicense.set(a.license_id, list)
}
const rows = (licenses as LicenseRow[] | null) || []
return (
<div className="space-y-6">
{deleted && (
<div className="rounded-md bg-green-50 dark:bg-green-950/40 border border-green-200 dark:border-green-900 px-4 py-3 text-sm text-green-700 dark:text-green-400">
Lisans silindi.
</div>
)}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-white">Lisanslar</h2>
<p className="text-gray-500 dark:text-gray-400 mt-2">Toplam {rows.length} lisans.</p>
</div>
<Link
href="/admin/licenses/new"
className="rounded-md bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium px-4 py-2 transition-colors whitespace-nowrap"
>
+ Yeni Lisans Oluştur
</Link>
</div>
<div className="rounded-lg bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
{['Müşteri', 'Plan', 'Durum', 'Cihazlar', 'Bitiş', 'Oluşturma', ''].map((h) => (
<th key={h} className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{rows.map((l) => {
const devices = activationsByLicense.get(l.id) || []
const customerName = l.customer_id ? profileNameById.get(l.customer_id) : null
const customerEmail = l.customer_id ? authUsers.get(l.customer_id)?.email : null
return (
<tr key={l.id} className="hover:bg-gray-50 dark:hover:bg-gray-900/50">
<td className="px-4 py-3 whitespace-nowrap">
{l.customer_id ? (
<>
<div className="text-sm font-medium text-gray-900 dark:text-white">{customerName || '(isimsiz)'}</div>
<div className="text-xs text-gray-500">{customerEmail || l.customer_id}</div>
</>
) : (
<span className="text-sm text-gray-400"></span>
)}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">{l.plan}</td>
<td className="px-4 py-3 whitespace-nowrap">{statusBadge(l.status)}</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
{devices.length} / {l.max_devices}
{devices.length > 0 && (
<div className="text-xs text-gray-400 mt-0.5">{devices.map((d) => d.platform).filter(Boolean).join(', ')}</div>
)}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">{formatDate(l.expires_at)}</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">{formatDate(l.created_at)}</td>
<td className="px-4 py-3 whitespace-nowrap text-right text-sm">
<Link href={`/admin/licenses/${l.id}`} className="text-indigo-600 dark:text-indigo-400 hover:underline">
Düzenle
</Link>
</td>
</tr>
)
})}
{rows.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-sm text-gray-500">Henüz lisans yok.</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
+137
View File
@@ -0,0 +1,137 @@
'use client'
import { useState } from 'react'
interface CrashRow {
id: string
event_type: string | null
severity: string
error_name: string | null
error_message: string | null
module: string | null
app_version: string | null
os: string | null
device_id: string | null
created_at: string
}
interface LicenseEventRow {
id: string
event_type: string
license_id: string
device_id: string | null
ip_address: string | null
app_version: string | null
created_at: string
}
function formatDateTime(iso: string) {
return new Date(iso).toLocaleString('tr-TR', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
}
function severityBadge(severity: string) {
const map: Record<string, string> = {
fatal: 'bg-red-100 text-red-700 border-red-300 dark:bg-red-950/60 dark:text-red-400 dark:border-red-900',
error: 'bg-red-50 text-red-700 border-red-200 dark:bg-red-950/40 dark:text-red-400 dark:border-red-900',
warning: 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950/40 dark:text-amber-400 dark:border-amber-900',
info: 'bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-950/40 dark:text-blue-400 dark:border-blue-900',
}
const cls = map[severity] || 'bg-gray-50 text-gray-600 border-gray-200 dark:bg-gray-900 dark:text-gray-400 dark:border-gray-800'
return <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border shrink-0 ${cls}`}>{severity}</span>
}
function TabButton({ active, onClick, children, count }: { active: boolean; onClick: () => void; children: React.ReactNode; count: number }) {
return (
<button
onClick={onClick}
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px ${
active
? 'border-indigo-600 text-indigo-600 dark:text-indigo-400'
: 'border-transparent text-gray-500 hover:text-gray-900 dark:hover:text-white'
}`}
>
{children} <span className="text-xs text-gray-400">({count})</span>
</button>
)
}
export default function LogsTabs({ crashes, licenseEvents }: { crashes: CrashRow[]; licenseEvents: LicenseEventRow[] }) {
const [tab, setTab] = useState<'crashes' | 'events'>('crashes')
return (
<div>
<div className="border-b border-gray-200 dark:border-gray-800 flex gap-2 mb-4">
<TabButton active={tab === 'crashes'} onClick={() => setTab('crashes')} count={crashes.length}>
Hata Kayıtları
</TabButton>
<TabButton active={tab === 'events'} onClick={() => setTab('events')} count={licenseEvents.length}>
Lisans Olayları
</TabButton>
</div>
{tab === 'crashes' && (
<div className="rounded-lg bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
{['Zaman', 'Önem', 'Tür', 'Hata', 'Modül', 'Sürüm / OS'].map((h) => (
<th key={h} className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{crashes.map((c) => (
<tr key={c.id} className="hover:bg-gray-50 dark:hover:bg-gray-900/50">
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">{formatDateTime(c.created_at)}</td>
<td className="px-4 py-3 whitespace-nowrap">{severityBadge(c.severity)}</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">{c.event_type}</td>
<td className="px-4 py-3 text-sm text-gray-900 dark:text-white max-w-md truncate" title={c.error_message || undefined}>
{c.error_name ? `${c.error_name}: ` : ''}{c.error_message || '—'}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">{c.module || '—'}</td>
<td className="px-4 py-3 whitespace-nowrap text-xs text-gray-400">v{c.app_version} · {c.os || '—'}</td>
</tr>
))}
{crashes.length === 0 && (
<tr><td colSpan={6} className="px-4 py-8 text-center text-sm text-gray-500">Hata kaydı yok.</td></tr>
)}
</tbody>
</table>
</div>
</div>
)}
{tab === 'events' && (
<div className="rounded-lg bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
{['Zaman', 'Olay', 'Lisans', 'Cihaz', 'IP', 'Sürüm'].map((h) => (
<th key={h} className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{licenseEvents.map((e) => (
<tr key={e.id} className="hover:bg-gray-50 dark:hover:bg-gray-900/50">
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">{formatDateTime(e.created_at)}</td>
<td className="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">{e.event_type}</td>
<td className="px-4 py-3 whitespace-nowrap text-xs text-gray-400 font-mono">{e.license_id?.slice(0, 8)}</td>
<td className="px-4 py-3 whitespace-nowrap text-xs text-gray-400 font-mono">{e.device_id ? `${e.device_id.slice(0, 8)}` : '—'}</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">{e.ip_address || '—'}</td>
<td className="px-4 py-3 whitespace-nowrap text-xs text-gray-400">{e.app_version || '—'}</td>
</tr>
))}
{licenseEvents.length === 0 && (
<tr><td colSpan={6} className="px-4 py-8 text-center text-sm text-gray-500">Lisans olayı yok.</td></tr>
)}
</tbody>
</table>
</div>
</div>
)}
</div>
)
}
+28
View File
@@ -0,0 +1,28 @@
import { supabaseAdmin } from '@/lib/supabaseAdmin'
import LogsTabs from './LogsTabs'
export default async function LogsPage() {
const [{ data: crashes }, { data: licenseEvents }] = await Promise.all([
supabaseAdmin
.from('crash_reports')
.select('id, event_type, severity, error_name, error_message, module, app_version, os, device_id, created_at')
.order('created_at', { ascending: false })
.limit(50),
supabaseAdmin
.from('license_events')
.select('id, event_type, license_id, device_id, ip_address, app_version, created_at')
.order('created_at', { ascending: false })
.limit(50),
])
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-white">Loglar</h2>
<p className="text-gray-500 dark:text-gray-400 mt-2">Son 50 hata kaydı ve lisans olayı.</p>
</div>
<LogsTabs crashes={crashes || []} licenseEvents={licenseEvents || []} />
</div>
)
}
+60 -13
View File
@@ -1,34 +1,74 @@
import { auth } from '@/lib/auth'
import { supabaseAdmin } from '@/lib/supabaseAdmin'
import Link from 'next/link'
function timeAgo(iso: string) {
const diffMs = Date.now() - new Date(iso).getTime()
const mins = Math.floor(diffMs / 60000)
if (mins < 1) return 'az önce'
if (mins < 60) return `${mins} dk önce`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours} sa önce`
const days = Math.floor(hours / 24)
return `${days} gün önce`
}
export default async function AdminDashboardPage() {
const session = await auth()
const now = new Date().toISOString()
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString()
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
const [
{ count: totalUsers },
{ count: activeLicenses },
{ count: trialExpiringSoon },
{ count: crashes24h },
{ data: recentCrashes },
{ data: recentLicenseEvents },
] = await Promise.all([
supabaseAdmin.from('profiles').select('id', { count: 'exact', head: true }),
supabaseAdmin.from('licenses').select('id', { count: 'exact', head: true }).eq('status', 'active'),
supabaseAdmin.from('profiles').select('id', { count: 'exact', head: true }).eq('license_status', 'trial').gte('trial_end_date', now).lte('trial_end_date', new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString()),
supabaseAdmin.from('crash_reports').select('id', { count: 'exact', head: true }).gte('created_at', oneDayAgo),
supabaseAdmin.from('crash_reports').select('id, error_message, severity, app_version, created_at').order('created_at', { ascending: false }).limit(5),
supabaseAdmin.from('license_events').select('id, event_type, license_id, created_at').order('created_at', { ascending: false }).limit(5),
])
const activity = [
...(recentCrashes || []).map((c) => ({ id: c.id, text: `Hata: ${c.error_message || c.severity} (v${c.app_version})`, created_at: c.created_at, tone: 'danger' as const })),
...(recentLicenseEvents || []).map((e) => ({ id: e.id, text: `Lisans olayı: ${e.event_type}`, created_at: e.created_at, tone: 'neutral' as const })),
].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()).slice(0, 8)
const stats = [
{ name: 'Toplam Kullanıcı', stat: totalUsers ?? 0, href: '/admin/users' },
{ name: 'Aktif Lisans', stat: activeLicenses ?? 0, href: '/admin/licenses' },
{ name: 'Denemesi 3 Gün İçinde Bitecek', stat: trialExpiringSoon ?? 0, href: '/admin/users' },
{ name: 'Son 24 Saatte Hata', stat: crashes24h ?? 0, href: '/admin/logs' },
]
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-white">Dashboard</h2>
<p className="text-gray-500 dark:text-gray-400 mt-2">
Hoş geldiniz, {session?.user?.name || session?.user?.email}. İşte projenizin genel görünümü.
Hoş geldiniz, {session?.user?.name || session?.user?.email}. İşte AyrisLegal&apos;ın canlı durumu.
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{/* Placeholder Stat Cards */}
{[
{ name: 'Toplam Kullanıcı', stat: '1,245' },
{ name: 'Aktif Oturumlar', stat: '42' },
{ name: 'Yeni Kayıtlar', stat: '8' },
{ name: 'Sistem Durumu', stat: 'Online' },
].map((item) => (
<div
{stats.map((item) => (
<Link
key={item.name}
className="overflow-hidden rounded-lg bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 px-4 py-5 shadow-sm sm:p-6"
href={item.href}
className="overflow-hidden rounded-lg bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 px-4 py-5 shadow-sm sm:p-6 hover:border-gray-300 dark:hover:border-gray-700 transition-colors"
>
<dt className="truncate text-sm font-medium text-gray-500 dark:text-gray-400">{item.name}</dt>
<dd className="mt-1 text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
{item.stat}
</dd>
</div>
</Link>
))}
</div>
@@ -36,9 +76,16 @@ export default async function AdminDashboardPage() {
<div className="p-6">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Son Aktiviteler</h3>
<div className="mt-4 border-t border-gray-100 dark:border-gray-800">
<div className="py-4 text-sm text-gray-500">
Henüz aktivite kaydı bulunmuyor.
{activity.length === 0 ? (
<div className="py-4 text-sm text-gray-500">Henüz aktivite kaydı bulunmuyor.</div>
) : (
activity.map((a) => (
<div key={a.id} className="py-3 border-b border-gray-100 dark:border-gray-800 last:border-0 flex items-center justify-between gap-4">
<span className={`text-sm ${a.tone === 'danger' ? 'text-red-600 dark:text-red-400' : 'text-gray-700 dark:text-gray-300'}`}>{a.text}</span>
<span className="text-xs text-gray-400 whitespace-nowrap">{timeAgo(a.created_at)}</span>
</div>
))
)}
</div>
</div>
</div>
+35
View File
@@ -0,0 +1,35 @@
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { auth } from '@/lib/auth'
import { supabaseAdmin } from '@/lib/supabaseAdmin'
async function requireAdmin() {
const session = await auth()
if (!session || (session.user as any)?.role !== 'ADMIN') {
throw new Error('Yetkisiz erişim')
}
}
export async function updateSettingsAction(formData: FormData) {
await requireAdmin()
const trialDaysRaw = String(formData.get('trial_days') || '').trim()
const trialDays = parseInt(trialDaysRaw, 10)
if (!trialDaysRaw || isNaN(trialDays) || trialDays < 1) {
redirect('/admin/settings?error=' + encodeURIComponent('Deneme süresi 1 veya daha büyük bir tam sayı olmalı'))
}
const { error } = await supabaseAdmin
.from('app_settings')
.update({ trial_days: trialDays, updated_at: new Date().toISOString() })
.eq('id', true)
if (error) {
redirect('/admin/settings?error=' + encodeURIComponent(error.message))
}
revalidatePath('/admin/settings')
redirect('/admin/settings?success=1')
}
+61
View File
@@ -0,0 +1,61 @@
import { supabaseAdmin } from '@/lib/supabaseAdmin'
import { updateSettingsAction } from './actions'
interface AppSettingsRow {
trial_days: number
}
export default async function SettingsPage({
searchParams,
}: {
searchParams: Promise<{ error?: string; success?: string }>
}) {
const { error, success } = await searchParams
const { data: settings } = await supabaseAdmin.from('app_settings').select('trial_days').maybeSingle()
const trialDays = (settings as AppSettingsRow | null)?.trial_days ?? 7
return (
<div className="space-y-6 max-w-2xl">
<div>
<h2 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-white">Ayarlar</h2>
<p className="text-gray-500 dark:text-gray-400 mt-1">Genel uygulama ayarları.</p>
</div>
{error && (
<div className="rounded-md bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-900 px-4 py-3 text-sm text-red-700 dark:text-red-400">
{error}
</div>
)}
{success && (
<div className="rounded-md bg-green-50 dark:bg-green-950/40 border border-green-200 dark:border-green-900 px-4 py-3 text-sm text-green-700 dark:text-green-400">
Değişiklikler kaydedildi.
</div>
)}
<form action={updateSettingsAction} className="rounded-lg bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 shadow-sm p-6 space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Deneme Süresi (gün)</label>
<input
type="number"
name="trial_days"
min={1}
defaultValue={trialDays}
className="w-full max-w-xs rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
<p className="mt-1 text-xs text-gray-400">
Yeni kayıt olan kullanıcılara otomatik açılan ücretsiz deneme süresi. Sadece bundan sonra kayıt olacaklar için geçerli mevcut kullanıcıların deneme süresini etkilemez.
</p>
</div>
<div className="flex items-center gap-3 pt-2">
<button
type="submit"
className="rounded-md bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium px-4 py-2 transition-colors"
>
Kaydet
</button>
</div>
</form>
</div>
)
}
@@ -0,0 +1,557 @@
'use client'
import React, { useState } from 'react'
import { Plus, Play, Edit, Trash2, CheckCircle2, XCircle, Search, ExternalLink, Video } from 'lucide-react'
import {
createTutorialAction,
updateTutorialAction,
deleteTutorialAction,
toggleTutorialStatusAction,
} from './actions'
import { extractYoutubeId } from './utils'
export interface TutorialItem {
id: string
title: string
description: string
category: string
category_label: string
duration: string
youtube_id: string
target_view: string | null
target_view_label: string | null
highlights: string[]
sort_order: number
is_active: boolean
created_at: string
}
const CATEGORY_OPTIONS = [
{ id: 'basics', label: '🚀 Hızlı Başlangıç' },
{ id: 'uyap', label: '⚖️ UYAP & Eklenti' },
{ id: 'ai_drafting', label: '🤖 AI Dilekçe & Savunma' },
{ id: 'jurisprudence', label: '🔍 18M İçtihat' },
{ id: 'cases', label: '📑 Dava & Tensip Analizi' },
{ id: 'templates', label: '📚 Şablonlar & Mevzuat' },
]
const TARGET_VIEWS = [
{ id: 'overview', label: 'Genel Bakış' },
{ id: 'cases', label: 'Dava Dosyaları' },
{ id: 'research', label: 'İçtihat Arama' },
{ id: 'drafting', label: 'Dilekçe Yazımı' },
{ id: 'templates', label: 'Şablon Kütüphanesi' },
{ id: 'clients', label: 'Müvekkiller' },
{ id: 'chat', label: 'AI Hukuk Asistanı' },
]
export function TutorialsClient({ initialTutorials }: { initialTutorials: TutorialItem[] }) {
const [tutorials, setTutorials] = useState<TutorialItem[]>(initialTutorials)
const [search, setSearch] = useState('')
const [categoryFilter, setCategoryFilter] = useState('all')
const [previewVideo, setPreviewVideo] = useState<TutorialItem | null>(null)
// Form modal state
const [isFormOpen, setIsFormOpen] = useState(false)
const [editingItem, setEditingItem] = useState<TutorialItem | null>(null)
const [isSaving, setIsSaving] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
// Form Fields
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [category, setCategory] = useState('basics')
const [duration, setDuration] = useState('03:45')
const [youtubeInput, setYoutubeInput] = useState('')
const [targetView, setTargetView] = useState('')
const [targetViewLabel, setTargetViewLabel] = useState('')
const [highlightsText, setHighlightsText] = useState('')
const [sortOrder, setSortOrder] = useState('0')
const [isActive, setIsActive] = useState(true)
const openNewForm = () => {
setEditingItem(null)
setTitle('')
setDescription('')
setCategory('basics')
setDuration('03:00')
setYoutubeInput('')
setTargetView('overview')
setTargetViewLabel('Genel Bakışa Git')
setHighlightsText('')
setSortOrder((tutorials.length + 1).toString())
setIsActive(true)
setErrorMessage(null)
setIsFormOpen(true)
}
const openEditForm = (item: TutorialItem) => {
setEditingItem(item)
setTitle(item.title)
setDescription(item.description)
setCategory(item.category)
setDuration(item.duration)
setYoutubeInput(`https://www.youtube.com/watch?v=/${item.youtube_id}`)
setTargetView(item.target_view || '')
setTargetViewLabel(item.target_view_label || '')
setHighlightsText((item.highlights || []).join('\n'))
setSortOrder(item.sort_order.toString())
setIsActive(item.is_active)
setErrorMessage(null)
setIsFormOpen(true)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSaving(true)
setErrorMessage(null)
try {
const parsedCat = CATEGORY_OPTIONS.find((c) => c.id === category)
const catLabel = parsedCat ? parsedCat.label : '🚀 Hızlı Başlangıç'
const formData = new FormData()
if (editingItem) formData.append('id', editingItem.id)
formData.append('title', title)
formData.append('description', description)
formData.append('category', category)
formData.append('category_label', catLabel)
formData.append('duration', duration)
formData.append('youtube_id', youtubeInput)
formData.append('target_view', targetView)
formData.append('target_view_label', targetViewLabel)
formData.append('highlights', highlightsText)
formData.append('sort_order', sortOrder)
if (isActive) formData.append('is_active', 'true')
if (editingItem) {
await updateTutorialAction(formData)
const updatedYt = extractYoutubeId(youtubeInput)
setTutorials((prev) =>
prev.map((t) =>
t.id === editingItem.id
? {
...t,
title,
description,
category,
category_label: catLabel,
duration,
youtube_id: updatedYt,
target_view: targetView || null,
target_view_label: targetViewLabel || null,
highlights: highlightsText.split('\n').filter(Boolean),
sort_order: parseInt(sortOrder, 10) || 0,
is_active: isActive,
}
: t
)
)
} else {
await createTutorialAction(formData)
// Refresh full page or optimistic append
window.location.reload()
}
setIsFormOpen(false)
} catch (err: any) {
setErrorMessage(err?.message || 'Bir hata oluştu')
} finally {
setIsSaving(false)
}
}
const handleDelete = async (id: string) => {
if (!window.confirm('Bu eğitim videosunu silmek istediğinize emin misiniz?')) return
try {
await deleteTutorialAction(id)
setTutorials((prev) => prev.filter((t) => t.id !== id))
} catch (err: any) {
alert(`Silinemedi: ${err?.message}`)
}
}
const handleToggleActive = async (item: TutorialItem) => {
const nextStatus = !item.is_active
try {
await toggleTutorialStatusAction(item.id, nextStatus)
setTutorials((prev) =>
prev.map((t) => (t.id === item.id ? { ...t, is_active: nextStatus } : t))
)
} catch (err: any) {
alert(`Güncellenemedi: ${err?.message}`)
}
}
const filtered = tutorials.filter((t) => {
const matchesCat = categoryFilter === 'all' || t.category === categoryFilter
const matchesSearch =
search.trim() === '' ||
t.title.toLowerCase().includes(search.toLowerCase()) ||
t.description.toLowerCase().includes(search.toLowerCase())
return matchesCat && matchesSearch
})
return (
<div className="space-y-6">
{/* Header Bar */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-white flex items-center gap-2.5">
<Video className="w-7 h-7 text-indigo-600 dark:text-indigo-400" />
Eğitim Videoları & Akademi
</h2>
<p className="text-gray-500 dark:text-gray-400 mt-1">
AyrisLegal masaüstü uygulamasında görünen YouTube kullanım ve eğitim videolarını yönetin.
</p>
</div>
<button
onClick={openNewForm}
className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold px-4 py-2.5 shadow-sm transition-colors cursor-pointer shrink-0"
>
<Plus className="w-4 h-4" />
+ Yeni Video Ekle
</button>
</div>
{/* Filter & Search Bar */}
<div className="flex flex-col sm:flex-row items-center gap-3">
<div className="relative flex-1 w-full">
<Search className="w-4 h-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
placeholder="Video başlığı veya açıklamasında ara..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2 text-sm bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-lg outline-none focus:border-indigo-500 text-gray-900 dark:text-white"
/>
</div>
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="w-full sm:w-56 px-3 py-2 text-sm bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-lg outline-none text-gray-900 dark:text-white"
>
<option value="all">Tüm Kategoriler ({tutorials.length})</option>
{CATEGORY_OPTIONS.map((c) => (
<option key={c.id} value={c.id}>
{c.label}
</option>
))}
</select>
</div>
{/* Video Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{filtered.map((item) => (
<div
key={item.id}
className="rounded-xl bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 shadow-sm overflow-hidden flex flex-col justify-between transition-all hover:shadow-md"
>
<div>
{/* Thumbnail / Embed Preview */}
<div className="relative aspect-video w-full bg-gray-900 group flex items-center justify-center overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`https://img.youtube.com/vi/${item.youtube_id}/mqdefault.jpg`}
alt={item.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
/>
<button
onClick={() => setPreviewVideo(item)}
className="absolute inset-0 flex items-center justify-center bg-black/40 hover:bg-black/60 transition-colors group-hover:scale-110 cursor-pointer"
title="Önizlemeyi Başlat"
>
<div className="w-12 h-12 rounded-full bg-red-600 flex items-center justify-center text-white shadow-lg">
<Play className="w-5 h-5 fill-current ml-0.5" />
</div>
</button>
<div className="absolute bottom-2 right-2 px-2 py-0.5 rounded text-[11px] font-mono bg-black/80 text-white font-bold">
{item.duration}
</div>
<div className="absolute top-2 left-2 px-2.5 py-0.5 rounded-md text-xs font-semibold bg-gray-900/90 text-indigo-300 border border-indigo-500/30">
{item.category_label}
</div>
</div>
{/* Body */}
<div className="p-4">
<div className="flex items-start justify-between gap-2 mb-1.5">
<h3 className="text-sm font-bold text-gray-900 dark:text-white line-clamp-1">
{item.title}
</h3>
<span className="text-xs font-mono text-gray-400 shrink-0">#{item.sort_order}</span>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-2 mb-3">
{item.description}
</p>
{item.highlights && item.highlights.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{item.highlights.slice(0, 2).map((h, i) => (
<span
key={i}
className="text-[10.5px] px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-900 text-gray-600 dark:text-gray-400"
>
{h}
</span>
))}
{item.highlights.length > 2 && (
<span className="text-[10.5px] px-1.5 py-0.5 text-gray-400">
+{item.highlights.length - 2}
</span>
)}
</div>
)}
</div>
</div>
{/* Card Footer Actions */}
<div className="px-4 py-3 bg-gray-50 dark:bg-gray-900/60 border-t border-gray-100 dark:border-gray-800 flex items-center justify-between">
<button
onClick={() => handleToggleActive(item)}
className={`inline-flex items-center gap-1 text-xs font-medium px-2 py-1 rounded-md transition-colors ${
item.is_active
? 'bg-green-100 dark:bg-green-950/50 text-green-700 dark:text-green-400 hover:bg-green-200'
: 'bg-gray-200 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-300'
}`}
>
{item.is_active ? <CheckCircle2 className="w-3.5 h-3.5" /> : <XCircle className="w-3.5 h-3.5" />}
{item.is_active ? 'Yayında' : 'Pasif'}
</button>
<div className="flex items-center gap-2">
<button
onClick={() => openEditForm(item)}
className="p-1.5 text-gray-500 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
title="Düzenle"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(item.id)}
className="p-1.5 text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors"
title="Sil"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
{filtered.length === 0 && (
<div className="p-12 text-center bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl text-gray-500">
Aramanıza uygun video bulunamadı.
</div>
)}
{/* YOUTUBE LIVE PREVIEW MODAL */}
{previewVideo && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm">
<div className="relative w-full max-w-3xl bg-gray-950 border border-gray-800 rounded-2xl overflow-hidden shadow-2xl">
<div className="flex items-center justify-between px-5 py-3.5 border-b border-gray-800 bg-gray-900/70">
<h3 className="text-sm font-bold text-white truncate">{previewVideo.title}</h3>
<button
onClick={() => setPreviewVideo(null)}
className="text-gray-400 hover:text-white text-lg"
>
</button>
</div>
<div className="aspect-video w-full">
<iframe
src={`https://www.youtube.com/embed/${previewVideo.youtube_id}?autoplay=1`}
title={previewVideo.title}
className="w-full h-full border-0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
</div>
</div>
)}
{/* CREATE / EDIT VIDEO DRAWER MODAL */}
{isFormOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm overflow-y-auto">
<div className="relative w-full max-w-xl bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl shadow-2xl p-6 my-8">
<div className="flex items-center justify-between pb-4 border-b border-gray-100 dark:border-gray-800 mb-5">
<h3 className="text-lg font-bold text-gray-900 dark:text-white">
{editingItem ? 'Eğitim Videosunu Düzenle' : 'Yeni Eğitim Videosu Ekle'}
</h3>
<button
onClick={() => setIsFormOpen(false)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-white"
>
</button>
</div>
{errorMessage && (
<div className="p-3 mb-4 rounded-lg bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-900 text-xs text-red-700 dark:text-red-400">
{errorMessage}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1">
Video Başlığı *
</label>
<input
type="text"
required
placeholder="Örn: 18 Milyon İçtihat Arama Rehberi"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-3 py-2 text-sm bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg outline-none focus:border-indigo-500 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1">
Açıklama
</label>
<textarea
rows={2}
placeholder="Videonun içeriği ve kullanıcıya faydası..."
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-3 py-2 text-sm bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg outline-none focus:border-indigo-500 text-gray-900 dark:text-white"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1">
Kategori
</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full px-3 py-2 text-sm bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg outline-none text-gray-900 dark:text-white"
>
{CATEGORY_OPTIONS.map((c) => (
<option key={c.id} value={c.id}>
{c.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1">
Süre (Örn: 04:30)
</label>
<input
type="text"
value={duration}
onChange={(e) => setDuration(e.target.value)}
className="w-full px-3 py-2 text-sm bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg outline-none text-gray-900 dark:text-white font-mono"
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1">
YouTube Video Linki veya Video ID *
</label>
<input
type="text"
required
placeholder="https://www.youtube.com/watch?v=... veya dQw4w9WgXcQ"
value={youtubeInput}
onChange={(e) => setYoutubeInput(e.target.value)}
className="w-full px-3 py-2 text-sm bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg outline-none focus:border-indigo-500 text-gray-900 dark:text-white font-mono"
/>
<p className="text-[11px] text-gray-400 mt-1">
Normal YouTube linki, Shorts veya direct ID girebilirsiniz. Otomatik ayrıştırılır.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1">
Yönlendirilecek Sayfa (Opsiyonel)
</label>
<select
value={targetView}
onChange={(e) => {
setTargetView(e.target.value)
const found = TARGET_VIEWS.find((v) => v.id === e.target.value)
if (found) setTargetViewLabel(`${found.label} Sayfasına Git`)
}}
className="w-full px-3 py-2 text-sm bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg outline-none text-gray-900 dark:text-white"
>
<option value="">Seçiniz</option>
{TARGET_VIEWS.map((v) => (
<option key={v.id} value={v.id}>
{v.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1">
Sıralama Önceliği
</label>
<input
type="number"
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value)}
className="w-full px-3 py-2 text-sm bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg outline-none text-gray-900 dark:text-white font-mono"
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1">
Öğrenilecek Başlıklar (Her satıra bir madde)
</label>
<textarea
rows={3}
placeholder="Panel kullanımı&#10;Emsal arama filtreleri&#10;Dilekçe taslağı çıkarma"
value={highlightsText}
onChange={(e) => setHighlightsText(e.target.value)}
className="w-full px-3 py-2 text-sm bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg outline-none focus:border-indigo-500 text-gray-900 dark:text-white"
/>
</div>
<div className="flex items-center gap-2 pt-2">
<input
type="checkbox"
id="isActiveToggle"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
className="w-4 h-4 rounded text-indigo-600 focus:ring-indigo-500 border-gray-300"
/>
<label htmlFor="isActiveToggle" className="text-xs font-semibold text-gray-700 dark:text-gray-300">
Bu videoyu uygulamada yayına al (Aktif)
</label>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-100 dark:border-gray-800">
<button
type="button"
onClick={() => setIsFormOpen(false)}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
İptal
</button>
<button
type="submit"
disabled={isSaving}
className="px-5 py-2 text-sm font-semibold text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors shadow-sm disabled:opacity-50"
>
{isSaving ? 'Kaydediliyor...' : editingItem ? 'Güncelle' : 'Kaydet ve Yayınla'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}
+143
View File
@@ -0,0 +1,143 @@
'use server'
import { revalidatePath } from 'next/cache'
import { auth } from '@/lib/auth'
import { supabaseAdmin } from '@/lib/supabaseAdmin'
import { extractYoutubeId } from './utils'
async function requireAdmin() {
const session = await auth()
if (!session || (session.user as any)?.role !== 'ADMIN') {
throw new Error('Yetkisiz erişim')
}
}
export async function createTutorialAction(formData: FormData) {
await requireAdmin()
const title = String(formData.get('title') || '').trim()
const description = String(formData.get('description') || '').trim()
const category = String(formData.get('category') || 'basics').trim()
const categoryLabel = String(formData.get('category_label') || '🚀 Hızlı Başlangıç').trim()
const duration = String(formData.get('duration') || '03:00').trim()
const rawYoutube = String(formData.get('youtube_id') || '').trim()
const youtubeId = extractYoutubeId(rawYoutube)
const targetView = String(formData.get('target_view') || '').trim() || null
const targetViewLabel = String(formData.get('target_view_label') || '').trim() || null
const highlightsRaw = String(formData.get('highlights') || '').trim()
const sortOrder = parseInt(String(formData.get('sort_order') || '0'), 10) || 0
const isActive = formData.get('is_active') === 'on' || formData.get('is_active') === 'true'
const highlights = highlightsRaw
? highlightsRaw.split('\n').map((s) => s.trim()).filter(Boolean)
: []
if (!title) throw new Error('Video başlığı zorunludur')
if (!youtubeId) throw new Error('Geçerli bir YouTube video linki veya ID giriniz')
const { error } = await supabaseAdmin.from('tutorials').insert({
title,
description,
category,
category_label: categoryLabel,
duration,
youtube_id: youtubeId,
target_view: targetView,
target_view_label: targetViewLabel,
highlights,
sort_order: sortOrder,
is_active: isActive,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
if (error) {
throw new Error(`Kayıt hatası: ${error.message}`)
}
revalidatePath('/admin/tutorials')
return { success: true }
}
export async function updateTutorialAction(formData: FormData) {
await requireAdmin()
const id = String(formData.get('id') || '').trim()
if (!id) throw new Error('Video ID eksik')
const title = String(formData.get('title') || '').trim()
const description = String(formData.get('description') || '').trim()
const category = String(formData.get('category') || 'basics').trim()
const categoryLabel = String(formData.get('category_label') || '🚀 Hızlı Başlangıç').trim()
const duration = String(formData.get('duration') || '03:00').trim()
const rawYoutube = String(formData.get('youtube_id') || '').trim()
const youtubeId = extractYoutubeId(rawYoutube)
const targetView = String(formData.get('target_view') || '').trim() || null
const targetViewLabel = String(formData.get('target_view_label') || '').trim() || null
const highlightsRaw = String(formData.get('highlights') || '').trim()
const sortOrder = parseInt(String(formData.get('sort_order') || '0'), 10) || 0
const isActive = formData.get('is_active') === 'on' || formData.get('is_active') === 'true'
const highlights = highlightsRaw
? highlightsRaw.split('\n').map((s) => s.trim()).filter(Boolean)
: []
if (!title) throw new Error('Video başlığı zorunludur')
if (!youtubeId) throw new Error('Geçerli bir YouTube video linki veya ID giriniz')
const { error } = await supabaseAdmin
.from('tutorials')
.update({
title,
description,
category,
category_label: categoryLabel,
duration,
youtube_id: youtubeId,
target_view: targetView,
target_view_label: targetViewLabel,
highlights,
sort_order: sortOrder,
is_active: isActive,
updated_at: new Date().toISOString(),
})
.eq('id', id)
if (error) {
throw new Error(`Güncelleme hatası: ${error.message}`)
}
revalidatePath('/admin/tutorials')
return { success: true }
}
export async function deleteTutorialAction(id: string) {
await requireAdmin()
if (!id) throw new Error('Video ID eksik')
const { error } = await supabaseAdmin.from('tutorials').delete().eq('id', id)
if (error) {
throw new Error(`Silme hatası: ${error.message}`)
}
revalidatePath('/admin/tutorials')
return { success: true }
}
export async function toggleTutorialStatusAction(id: string, newStatus: boolean) {
await requireAdmin()
const { error } = await supabaseAdmin
.from('tutorials')
.update({ is_active: newStatus, updated_at: new Date().toISOString() })
.eq('id', id)
if (error) {
throw new Error(`Durum güncelleme hatası: ${error.message}`)
}
revalidatePath('/admin/tutorials')
return { success: true }
}
+29
View File
@@ -0,0 +1,29 @@
import { supabaseAdmin } from '@/lib/supabaseAdmin'
import { TutorialsClient, TutorialItem } from './TutorialsClient'
export const dynamic = 'force-dynamic'
export default async function AdminTutorialsPage() {
const { data: tutorials, error } = await supabaseAdmin
.from('tutorials')
.select('*')
.order('sort_order', { ascending: true })
const items: TutorialItem[] = (tutorials || []).map((t: any) => ({
id: t.id,
title: t.title,
description: t.description || '',
category: t.category || 'basics',
category_label: t.category_label || '🚀 Hızlı Başlangıç',
duration: t.duration || '03:00',
youtube_id: t.youtube_id,
target_view: t.target_view,
target_view_label: t.target_view_label,
highlights: Array.isArray(t.highlights) ? t.highlights : [],
sort_order: t.sort_order || 0,
is_active: t.is_active !== false,
created_at: t.created_at,
}))
return <TutorialsClient initialTutorials={items} />
}
+27
View File
@@ -0,0 +1,27 @@
export function extractYoutubeId(input: string): string {
const trimmed = input.trim()
if (!trimmed) return ''
// Direct 11-char ID
if (/^[a-zA-Z0-9_-]{11}$/.test(trimmed)) {
return trimmed
}
// youtu.be/ID
const shortMatch = trimmed.match(/youtu\.be\/([a-zA-Z0-9_-]{11})/)
if (shortMatch) return shortMatch[1]
// youtube.com/watch?v=ID
const watchMatch = trimmed.match(/[?&]v=([a-zA-Z0-9_-]{11})/)
if (watchMatch) return watchMatch[1]
// youtube.com/embed/ID
const embedMatch = trimmed.match(/youtube\.com\/embed\/([a-zA-Z0-9_-]{11})/)
if (embedMatch) return embedMatch[1]
// youtube.com/shorts/ID
const shortsMatch = trimmed.match(/youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/)
if (shortsMatch) return shortsMatch[1]
return trimmed
}
@@ -0,0 +1,55 @@
'use client'
import { useState } from 'react'
import { deleteUserAction } from '../actions'
export default function DeleteUserButton({ id, email }: { id: string; email: string }) {
const [confirmText, setConfirmText] = useState('')
const [open, setOpen] = useState(false)
const canDelete = confirmText.trim().toLowerCase() === email.toLowerCase()
if (!open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
className="rounded-md border border-red-300 dark:border-red-900 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30 text-sm font-medium px-4 py-2 transition-colors"
>
Kullanıcıyı Sil
</button>
)
}
return (
<form action={deleteUserAction} className="space-y-3">
<input type="hidden" name="id" value={id} />
<p className="text-sm text-gray-600 dark:text-gray-400">
Bu işlem geri alınamaz kullanıcının Supabase Auth hesabı ve profili silinir. Bu kullanıcıya bağlı lisanslar askıda kalabilir, gerekirse Lisanslar sayfasından bağlantısını kaldırın.
Onaylamak için e-postasını yazın: <span className="font-mono text-gray-900 dark:text-white">{email}</span>
</p>
<input
type="text"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={email}
className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-red-500"
/>
<div className="flex items-center gap-3">
<button
type="submit"
disabled={!canDelete}
className="rounded-md bg-red-600 hover:bg-red-700 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium px-4 py-2 transition-colors"
>
Kalıcı Olarak Sil
</button>
<button
type="button"
onClick={() => { setOpen(false); setConfirmText('') }}
className="text-sm text-gray-500 hover:text-gray-900 dark:hover:text-white"
>
Vazgeç
</button>
</div>
</form>
)
}
+198
View File
@@ -0,0 +1,198 @@
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { supabaseAdmin, listAuthUsers } from '@/lib/supabaseAdmin'
import { updateUserAction } from '../actions'
import DeleteUserButton from './DeleteUserButton'
interface ProfileRow {
id: string
full_name: string | null
bar_association_no: string | null
bar_name: string | null
phone: string | null
license_status: string | null
license_expires_at: string | null
license_started_at: string | null
trial_start_date: string | null
trial_end_date: string | null
org_id: string | null
created_at: string
}
interface LicenseRow {
id: string
plan: string
status: string
max_devices: number
expires_at: string | null
}
function toDateInputValue(iso: string | null) {
if (!iso) return ''
return new Date(iso).toISOString().slice(0, 10)
}
function formatDate(iso: string | null) {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('tr-TR', { day: 'numeric', month: 'short', year: 'numeric' })
}
export default async function UserEditPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>
searchParams: Promise<{ error?: string; success?: string }>
}) {
const { id } = await params
const { error, success } = await searchParams
const [{ data: profile }, authUsers, { data: licenses }] = await Promise.all([
supabaseAdmin
.from('profiles')
.select('id, full_name, bar_association_no, bar_name, phone, license_status, license_expires_at, license_started_at, trial_start_date, trial_end_date, org_id, created_at')
.eq('id', id)
.maybeSingle(),
listAuthUsers(),
supabaseAdmin.from('licenses').select('id, plan, status, max_devices, expires_at').eq('customer_id', id),
])
if (!profile) notFound()
const p = profile as ProfileRow
const email = authUsers.get(p.id)?.email || ''
const linkedLicenses = (licenses as LicenseRow[] | null) || []
return (
<div className="space-y-6 max-w-2xl">
<div>
<Link href="/admin/users" className="text-sm text-gray-500 hover:text-gray-900 dark:hover:text-white"> Kullanıcılar</Link>
<h2 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-white mt-2">Kullanıcı Düzenle</h2>
<p className="text-gray-500 dark:text-gray-400 mt-1 text-sm">{email || p.id}</p>
</div>
{error && (
<div className="rounded-md bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-900 px-4 py-3 text-sm text-red-700 dark:text-red-400">
{error}
</div>
)}
{success && (
<div className="rounded-md bg-green-50 dark:bg-green-950/40 border border-green-200 dark:border-green-900 px-4 py-3 text-sm text-green-700 dark:text-green-400">
Değişiklikler kaydedildi.
</div>
)}
<form action={updateUserAction} className="rounded-lg bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 shadow-sm p-6 space-y-5">
<input type="hidden" name="id" value={p.id} />
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Ad Soyad</label>
<input
type="text"
name="full_name"
defaultValue={p.full_name || ''}
className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Baro</label>
<input
type="text"
name="bar_name"
defaultValue={p.bar_name || ''}
className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Baro Sicil No</label>
<input
type="text"
name="bar_association_no"
defaultValue={p.bar_association_no || ''}
className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Telefon (WhatsApp)</label>
<input
type="text"
name="phone"
placeholder="+905XXXXXXXXX"
defaultValue={p.phone || ''}
className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
<p className="mt-1 text-xs text-gray-400">Ülke koduyla (+90) girin WhatsApp AI hattının bu numarayı hesapla eşleştirmesi için kullanılır.</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Lisans Durumu</label>
<select
name="license_status"
defaultValue={p.license_status || 'active'}
className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="trial">trial</option>
<option value="active">active</option>
<option value="expired">expired</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Lisans Bitiş</label>
<input
type="date"
name="license_expires_at"
defaultValue={toDateInputValue(p.license_expires_at)}
className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Deneme Bitiş Tarihi</label>
<input
type="date"
name="trial_end_date"
defaultValue={toDateInputValue(p.trial_end_date)}
className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
</div>
<div className="flex items-center gap-3 pt-2">
<button
type="submit"
className="rounded-md bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium px-4 py-2 transition-colors"
>
Kaydet
</button>
<Link href="/admin/users" className="text-sm text-gray-500 hover:text-gray-900 dark:hover:text-white">
Vazgeç
</Link>
</div>
</form>
<div className="rounded-lg bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 shadow-sm p-6">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-3">Bağlı Lisanslar</h3>
{linkedLicenses.length === 0 ? (
<p className="text-sm text-gray-500">Bu kullanıcıya bağlı lisans yok.</p>
) : (
<ul className="divide-y divide-gray-100 dark:divide-gray-800">
{linkedLicenses.map((l) => (
<li key={l.id} className="py-2.5 flex items-center justify-between text-sm">
<span className="text-gray-700 dark:text-gray-300">{l.plan} · {l.status} · {l.max_devices} cihaz · bitiş {formatDate(l.expires_at)}</span>
<Link href={`/admin/licenses/${l.id}`} className="text-indigo-600 dark:text-indigo-400 hover:underline">Düzenle</Link>
</li>
))}
</ul>
)}
</div>
<div className="rounded-lg bg-white dark:bg-gray-950 border border-red-200 dark:border-red-900 shadow-sm p-6">
<h3 className="text-sm font-semibold text-red-700 dark:text-red-400 uppercase tracking-wide mb-3">Tehlikeli Bölge</h3>
<DeleteUserButton id={p.id} email={email || p.id} />
</div>
</div>
)
}
+89
View File
@@ -0,0 +1,89 @@
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { auth } from '@/lib/auth'
import { supabaseAdmin, deleteAuthUser } from '@/lib/supabaseAdmin'
async function requireAdmin() {
const session = await auth()
if (!session || (session.user as any)?.role !== 'ADMIN') {
throw new Error('Yetkisiz erişim')
}
}
function toIsoOrNull(dateInput: FormDataEntryValue | null): string | null {
const value = String(dateInput || '').trim()
if (!value) return null
const d = new Date(`${value}T23:59:59.999Z`)
if (isNaN(d.getTime())) return null
return d.toISOString()
}
// profiles.license_status masaüstü uygulamasının (laawos/Electron) TEK gerçek
// erişim kapısı (bkz. LicenseGate.tsx::fetchActiveStatus) ve laawos-backend'in
// API middleware'i de aynı alana bakıyor — bu yüzden bunu yazan HER yol (Kullanıcılar
// sayfası, Lisanslar sayfası) bu tek fonksiyondan geçmeli, yoksa iki ekran
// birbirinden habersiz farklı değerler yazıp "veriler eşleşmiyor" durumuna düşer.
export async function setProfileAccess(userId: string, status: string, expiresAt: string | null) {
await supabaseAdmin
.from('profiles')
.update({ license_status: status, license_expires_at: expiresAt })
.eq('id', userId)
}
export async function updateUserAction(formData: FormData) {
await requireAdmin()
const id = String(formData.get('id') || '')
if (!id) throw new Error('Kullanıcı id eksik')
const fullName = String(formData.get('full_name') || '').trim()
const barName = String(formData.get('bar_name') || '').trim()
const barNo = String(formData.get('bar_association_no') || '').trim()
const phone = String(formData.get('phone') || '').trim()
const licenseStatus = String(formData.get('license_status') || '').trim()
const licenseExpiresAt = toIsoOrNull(formData.get('license_expires_at'))
const trialEndDate = toIsoOrNull(formData.get('trial_end_date'))
const update: Record<string, unknown> = {
full_name: fullName || null,
bar_name: barName || null,
bar_association_no: barNo || null,
phone: phone || null,
trial_end_date: trialEndDate,
}
if (licenseStatus) {
// license_status + license_expires_at ikilisi setProfileAccess üzerinden yazılıyor,
// ki Lisanslar sayfasıyla aynı tek yoldan geçsin.
await setProfileAccess(id, licenseStatus, licenseExpiresAt)
} else {
update.license_expires_at = licenseExpiresAt
}
const { error } = await supabaseAdmin.from('profiles').update(update).eq('id', id)
if (error) {
redirect(`/admin/users/${id}?error=${encodeURIComponent(error.message)}`)
}
revalidatePath('/admin/users')
revalidatePath(`/admin/users/${id}`)
redirect(`/admin/users/${id}?success=1`)
}
export async function deleteUserAction(formData: FormData) {
await requireAdmin()
const id = String(formData.get('id') || '')
if (!id) throw new Error('Kullanıcı id eksik')
const { error: authError } = await deleteAuthUser(id)
if (authError) {
redirect(`/admin/users/${id}?error=${encodeURIComponent(authError)}`)
}
await supabaseAdmin.from('profiles').delete().eq('id', id)
revalidatePath('/admin/users')
redirect('/admin/users?deleted=1')
}
+104
View File
@@ -0,0 +1,104 @@
import Link from 'next/link'
import { supabaseAdmin, listAuthUsers } from '@/lib/supabaseAdmin'
interface ProfileRow {
id: string
full_name: string | null
bar_association_no: string | null
bar_name: string | null
license_status: string | null
license_expires_at: string | null
trial_end_date: string | null
org_id: string | null
created_at: string
}
function statusBadge(status: string | null) {
const map: Record<string, string> = {
active: 'bg-green-50 text-green-700 border-green-200 dark:bg-green-950/40 dark:text-green-400 dark:border-green-900',
trial: 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950/40 dark:text-amber-400 dark:border-amber-900',
expired: 'bg-red-50 text-red-700 border-red-200 dark:bg-red-950/40 dark:text-red-400 dark:border-red-900',
}
const cls = map[status || ''] || 'bg-gray-50 text-gray-600 border-gray-200 dark:bg-gray-900 dark:text-gray-400 dark:border-gray-800'
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border ${cls}`}>
{status || 'bilinmiyor'}
</span>
)
}
function formatDate(iso: string | null) {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('tr-TR', { day: 'numeric', month: 'short', year: 'numeric' })
}
export default async function UsersPage({ searchParams }: { searchParams: Promise<{ deleted?: string }> }) {
const { deleted } = await searchParams
const [{ data: profiles }, authUsers] = await Promise.all([
supabaseAdmin
.from('profiles')
.select('id, full_name, bar_association_no, bar_name, license_status, license_expires_at, trial_end_date, org_id, created_at')
.order('created_at', { ascending: false }),
listAuthUsers(),
])
const rows = (profiles as ProfileRow[] | null) || []
return (
<div className="space-y-6">
{deleted && (
<div className="rounded-md bg-green-50 dark:bg-green-950/40 border border-green-200 dark:border-green-900 px-4 py-3 text-sm text-green-700 dark:text-green-400">
Kullanıcı silindi.
</div>
)}
<div>
<h2 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-white">Kullanıcılar</h2>
<p className="text-gray-500 dark:text-gray-400 mt-2">Toplam {rows.length} kullanıcı.</p>
</div>
<div className="rounded-lg bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
{['Kullanıcı', 'Baro', 'Durum', 'Bitiş Tarihi', 'Kayıt', ''].map((h) => (
<th key={h} className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{rows.map((p) => {
const authUser = authUsers.get(p.id)
const expiryDate = p.license_status === 'trial' ? p.trial_end_date : p.license_expires_at
return (
<tr key={p.id} className="hover:bg-gray-50 dark:hover:bg-gray-900/50">
<td className="px-4 py-3 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900 dark:text-white">{p.full_name || '(isimsiz)'}</div>
<div className="text-xs text-gray-500">{authUser?.email || p.id}</div>
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
{p.bar_name || '—'}{p.bar_association_no ? ` · ${p.bar_association_no}` : ''}
</td>
<td className="px-4 py-3 whitespace-nowrap">{statusBadge(p.license_status)}</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">{formatDate(expiryDate)}</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">{formatDate(p.created_at)}</td>
<td className="px-4 py-3 whitespace-nowrap text-right text-sm">
<Link href={`/admin/users/${p.id}`} className="text-indigo-600 dark:text-indigo-400 hover:underline">
Düzenle
</Link>
</td>
</tr>
)
})}
{rows.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-sm text-gray-500">Henüz kullanıcı yok.</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
-4
View File
@@ -83,10 +83,6 @@ export default function LoginPage() {
{loading ? 'Giriş yapılıyor...' : 'Giriş Yap'}
</button>
</form>
<div className="mt-6 text-center text-xs text-gray-400">
Demo credentials: admin@ayris.tech / admin
</div>
</div>
</div>
</div>
+11 -6
View File
@@ -10,15 +10,20 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
// Boilerplate mock logic
// TODO: In production, lookup user in Prisma and verify password using bcrypt
// const user = await db.user.findUnique({ where: { email: credentials.email } })
// Gerçek AyrisLegal kullanıcı sistemine (Supabase Auth) bağlı DEĞİL —
// admin paneli girişi bilerek ayrı, basit bir env-tabanlı kontrol.
// ADMIN_EMAIL / ADMIN_PASSWORD .env dosyasında tanımlı, sadece
// server-side (bu fonksiyon) okunuyor, hiçbir zaman client'a gitmiyor.
const adminEmail = process.env.ADMIN_EMAIL
const adminPassword = process.env.ADMIN_PASSWORD
if (credentials?.email === "admin@ayris.tech" && credentials?.password === "admin") {
if (!adminEmail || !adminPassword) return null
if (credentials?.email === adminEmail && credentials?.password === adminPassword) {
return {
id: "1",
name: "Admin User",
email: "admin@ayris.tech",
name: "Admin",
email: adminEmail,
role: "ADMIN"
}
}
-9
View File
@@ -1,9 +0,0 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const db = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db
+79
View File
@@ -0,0 +1,79 @@
import 'server-only';
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.SUPABASE_URL;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !serviceRoleKey) {
throw new Error('SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY tanımlı değil (.env dosyasına bakın).');
}
// service_role ile — RLS bypass edilir, admin panelinin ihtiyacı budur (licenses,
// crash_reports, access_logs gibi tablolarda bilerek hiç RLS policy yok, sadece
// service_role erişebiliyor). SADECE server component / route handler içinde
// import edilmeli — 'server-only' paketi bunu build-time'da garanti ediyor.
export const supabaseAdmin = createClient(supabaseUrl, serviceRoleKey, {
auth: { autoRefreshToken: false, persistSession: false },
});
export interface AuthUser {
id: string;
email?: string;
created_at: string;
last_sign_in_at?: string | null;
}
// profiles tablosunda e-posta yok (auth.users'a referans veriyor) — e-postayı
// Supabase Auth Admin API'den çekmemiz gerekiyor.
export async function listAuthUsers(): Promise<Map<string, AuthUser>> {
const res = await fetch(`${supabaseUrl}/auth/v1/admin/users?per_page=1000`, {
headers: { apikey: serviceRoleKey!, Authorization: `Bearer ${serviceRoleKey}` },
cache: 'no-store',
});
if (!res.ok) return new Map();
const data = await res.json();
const users: AuthUser[] = data.users || [];
return new Map(users.map((u) => [u.id, u]));
}
// Kullanıcı silme: Supabase Auth Admin API üzerinden. profiles satırını silmek
// admin'in sorumluluğunda ayrıca çağrılmalı — auth.users -> profiles cascade'i
// bu self-hosted kurulumda garanti değil.
export async function deleteAuthUser(id: string): Promise<{ error?: string }> {
const res = await fetch(`${supabaseUrl}/auth/v1/admin/users/${id}`, {
method: 'DELETE',
headers: { apikey: serviceRoleKey!, Authorization: `Bearer ${serviceRoleKey}` },
});
if (!res.ok) {
const text = await res.text().catch(() => '');
return { error: `Auth kullanıcı silinemedi (${res.status}): ${text}` };
}
return {};
}
export interface CustomerOption {
id: string;
name: string | null;
email: string;
}
// Lisans düzenleme/oluşturma formlarındaki "Müşteri" seçimi Kullanıcılar sayfasıyla
// AYNI kaynaktan (profiles + Supabase Auth e-postası) besleniyor — serbest metin
// yerine, gerçekten var olan bir kullanıcı seçtirmek için.
export async function listCustomerOptions(): Promise<CustomerOption[]> {
const [{ data: profiles }, authUsers] = await Promise.all([
supabaseAdmin.from('profiles').select('id, full_name'),
listAuthUsers(),
]);
const options: CustomerOption[] = (profiles || [])
.map((p: { id: string; full_name: string | null }) => ({
id: p.id,
name: p.full_name,
email: authUsers.get(p.id)?.email || '',
}))
.filter((c) => c.email);
options.sort((a, b) => (a.name || a.email).localeCompare(b.name || b.email, 'tr'));
return options;
}
+111 -929
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,5 +1,5 @@
{
"name": "fethiye-holiday",
"name": "ayrislegal-admin",
"version": "0.1.0",
"private": true,
"scripts": {
@@ -10,7 +10,7 @@
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@prisma/client": "^7.8.0",
"@supabase/supabase-js": "^2.112.3",
"class-variance-authority": "^0.7.1",
"cloudinary": "^2.10.0",
"clsx": "^2.1.1",
@@ -22,6 +22,7 @@
"next-intl": "^4.13.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"server-only": "^0.0.1",
"shadcn": "^4.11.0",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
@@ -33,7 +34,6 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.9",
"prisma": "^7.8.0",
"tailwindcss": "^4",
"typescript": "^5"
}
-61
View File
@@ -1,61 +0,0 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
ADMIN
USER
}
model User {
id String @id @default(cuid())
name String?
email String @unique
password String?
role Role @default(USER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
accounts Account[]
sessions Session[]
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}