feat(tutorials): add video training academy management with YouTube parser and live preview
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+61
-14
@@ -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'ı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.
|
||||
</div>
|
||||
{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>
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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ı Emsal arama filtreleri 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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
Generated
+111
-929
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
Reference in New Issue
Block a user