187 lines
6.8 KiB
TypeScript
187 lines
6.8 KiB
TypeScript
'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`)
|
||
}
|