Files
nextjs-boilerplate/app/[locale]/admin/licenses/actions.ts
T

187 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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`)
}