feat: sunucu tarafı hesap altyapısı — Stripe webhook, kotalar, gerçek API anahtarı ve belge deposu
Denetlenen 5 teknik borç kalemi de kapatıldı:
1) Stripe webhook + gerçek yetkilendirme (ÖNCEDEN YOKTU)
- POST /api/billing/webhook — SDK'sız HMAC-SHA256 imza doğrulaması,
tekrar saldırısına karşı ±300sn zaman penceresi, timingSafeEqual
- checkout.session.completed / subscription.created|updated|deleted işlenir
- Ödeme → kullanıcının planı sunucuda güncellenir (önce ödeme hiçbir şeyi
değiştirmiyordu)
2) Plan kotaları GERÇEKTEN uygulanıyor (önce sadece tanımlıydı)
- server/entitlements.mjs: plan hakları + kotaKontrol()
- ekip, belge adedi/boyutu, API anahtarı kotaları sunucuda zorlanır
- Kota aşımında 402 + anlaşılır Türkçe mesaj; arayüzde KotaUyarisi bandı
- /api/me/plan: plan, haklar ve gerçek kullanım
3) Kullanıcı verisi localStorage'dan sunucuya taşındı
- server/user-store.mjs: kullanici_verisi tablosu (izinli anahtar listesi)
- AppContext girişte sunucudan yükler, değişiklikleri sunucuya yazar;
localStorage yalnızca çevrimdışı önbellek
- Artık cihaz değişince veri kaybolmuyor
4) API anahtarları gerçek (önce Math.random ile tarayıcıda üretiliyordu)
- Sunucuda crypto.randomBytes ile üretilir, YALNIZCA sha256 hash'i saklanır
- Ham değer bir kez gösterilir; verifyApiKey ile doğrulanır, son kullanım
izlenir, iptal anında geçerli olur
5) Belge deposu gerçek (önce boyut Math.random'dı, dosya hiç yüklenmiyordu)
- Dosyalar kalıcı volume'e yazılır, boyut GERÇEK bayt
- Auth'lu indirme (blob), silme diskten de siler, kota sunucuda uygulanır
This commit is contained in:
+50
-1
@@ -12,6 +12,8 @@
|
||||
* döner; uygulama ücretsiz katmanda çalışmaya devam eder.
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
export const PLANS = [
|
||||
{
|
||||
id: 'ucretsiz',
|
||||
@@ -71,7 +73,53 @@ export const PLANS = [
|
||||
];
|
||||
|
||||
export function billingConfigured() {
|
||||
return Boolean(process.env.STRIPE_SECRET_KEY);
|
||||
const k = (process.env.STRIPE_SECRET_KEY || '').trim();
|
||||
return k.startsWith('sk_') && k.length > 12;
|
||||
}
|
||||
|
||||
export function webhookConfigured() {
|
||||
return Boolean((process.env.STRIPE_WEBHOOK_SECRET || '').trim().startsWith('whsec_'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stripe webhook imzasını SDK olmadan doğrular.
|
||||
* Stripe-Signature: t=<zaman>,v1=<hmac>
|
||||
* imzalanan yük = `${t}.${hamGovde}` ; HMAC-SHA256(whsec)
|
||||
*/
|
||||
export function verifyWebhook(hamGovde, imzaBasligi, { tolerans = 300 } = {}) {
|
||||
const secret = (process.env.STRIPE_WEBHOOK_SECRET || '').trim();
|
||||
if (!secret) throw new Error('STRIPE_WEBHOOK_SECRET tanımlı değil');
|
||||
if (!imzaBasligi) throw new Error('Stripe-Signature başlığı yok');
|
||||
|
||||
const parcalar = Object.fromEntries(
|
||||
String(imzaBasligi).split(',').map((p) => {
|
||||
const i = p.indexOf('=');
|
||||
return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
|
||||
})
|
||||
);
|
||||
const t = parcalar.t;
|
||||
const v1 = parcalar.v1;
|
||||
if (!t || !v1) throw new Error('İmza başlığı biçimi geçersiz');
|
||||
|
||||
// Tekrar saldırısına karşı zaman penceresi
|
||||
const yas = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
|
||||
if (!Number.isFinite(yas) || yas > tolerans) throw new Error('İmza zaman aşımına uğradı');
|
||||
|
||||
const beklenen = crypto.createHmac('sha256', secret).update(`${t}.${hamGovde}`, 'utf8').digest('hex');
|
||||
const a = Buffer.from(beklenen, 'utf8');
|
||||
const b = Buffer.from(v1, 'utf8');
|
||||
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) throw new Error('İmza doğrulanamadı');
|
||||
|
||||
return JSON.parse(hamGovde);
|
||||
}
|
||||
|
||||
/** Stripe Price ID → plan kimliği */
|
||||
export function planForPrice(priceId) {
|
||||
if (!priceId) return null;
|
||||
for (const p of PLANS) {
|
||||
if (p.priceEnv && process.env[p.priceEnv] === priceId) return p.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function planCatalog() {
|
||||
@@ -110,6 +158,7 @@ export async function createCheckoutSession({ planId, email }) {
|
||||
cancel_url: `${appUrl}/?odeme=iptal`,
|
||||
customer_email: email,
|
||||
allow_promotion_codes: 'true',
|
||||
client_reference_id: email || '',
|
||||
'metadata[planId]': planId,
|
||||
'metadata[email]': email || '',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Plan hakları ve kota uygulaması.
|
||||
* Önceden billing.mjs'te `sinirlar` tanımlıydı ama HİÇBİR yerde kontrol
|
||||
* edilmiyordu — herkes her şeyi kullanabiliyordu. Burada gerçekten uygulanır.
|
||||
*/
|
||||
|
||||
import { getUser, documentUsage, listApiKeys, getData } from './user-store.mjs';
|
||||
|
||||
export const PLAN_HAKLARI = {
|
||||
ucretsiz: {
|
||||
ad: 'Ücretsiz',
|
||||
takip: 10, // takip edilebilecek ihale
|
||||
ekip: 1, // ekip üyesi
|
||||
bildirimKriteri: 1, // otomatik bildirim kriteri
|
||||
belgeAdet: 5,
|
||||
belgeBayt: 25 * 1024 * 1024,
|
||||
apiAnahtari: 0,
|
||||
ozellikler: ['arama', 'favori', 'push'],
|
||||
},
|
||||
pro: {
|
||||
ad: 'Pro',
|
||||
takip: null, // sınırsız
|
||||
ekip: 5,
|
||||
bildirimKriteri: 10,
|
||||
belgeAdet: 200,
|
||||
belgeBayt: 2 * 1024 * 1024 * 1024,
|
||||
apiAnahtari: 0,
|
||||
ozellikler: ['arama', 'favori', 'push', 'kanban', 'rakip', 'fiyat', 'ai', 'pdf', 'eposta'],
|
||||
},
|
||||
kurumsal: {
|
||||
ad: 'Kurumsal',
|
||||
takip: null,
|
||||
ekip: null,
|
||||
bildirimKriteri: null,
|
||||
belgeAdet: null,
|
||||
belgeBayt: 20 * 1024 * 1024 * 1024,
|
||||
apiAnahtari: 10,
|
||||
ozellikler: ['arama', 'favori', 'push', 'kanban', 'rakip', 'fiyat', 'ai', 'pdf', 'eposta', 'kirmizibayrak', 'kik', 'ted', 'api'],
|
||||
},
|
||||
};
|
||||
|
||||
/** Kullanıcının etkin planı (süresi dolmuşsa ücretsize düşer). */
|
||||
export function planOf(email, { isAdmin = false } = {}) {
|
||||
if (isAdmin) return 'kurumsal'; // yöneticiler tam erişimli
|
||||
const u = getUser(email);
|
||||
if (!u) return 'ucretsiz';
|
||||
if (u.plan_bitis && new Date(u.plan_bitis) < new Date()) return 'ucretsiz';
|
||||
return PLAN_HAKLARI[u.plan] ? u.plan : 'ucretsiz';
|
||||
}
|
||||
|
||||
export function haklar(email, opts) {
|
||||
return PLAN_HAKLARI[planOf(email, opts)] || PLAN_HAKLARI.ucretsiz;
|
||||
}
|
||||
|
||||
export function ozellikVar(email, ozellik, opts) {
|
||||
return haklar(email, opts).ozellikler.includes(ozellik);
|
||||
}
|
||||
|
||||
/** Mevcut kullanım — arayüzde "3/10" göstermek için. */
|
||||
export function kullanim(email, opts) {
|
||||
const h = haklar(email, opts);
|
||||
const belge = documentUsage(email);
|
||||
const ekip = (getData(email, 'ekip') || []).length;
|
||||
const takip = (getData(email, 'kanban') || []).filter?.((t) => t?.takipte)?.length ?? null;
|
||||
return {
|
||||
plan: planOf(email, opts),
|
||||
planAdi: h.ad,
|
||||
haklar: h,
|
||||
kullanilan: {
|
||||
belgeAdet: belge.adet,
|
||||
belgeBayt: belge.bayt,
|
||||
ekip,
|
||||
apiAnahtari: listApiKeys(email).length,
|
||||
takip,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Kota kontrolü. Aşılırsa açıklayıcı hata fırlatır.
|
||||
* @param {string} tur 'ekip' | 'belgeAdet' | 'belgeBayt' | 'apiAnahtari' | 'bildirimKriteri' | 'takip'
|
||||
*/
|
||||
export function kotaKontrol(email, tur, mevcutSayi, ekleme = 1, opts) {
|
||||
const h = haklar(email, opts);
|
||||
const limit = h[tur];
|
||||
if (limit === null || limit === undefined) return; // sınırsız
|
||||
if (mevcutSayi + ekleme > limit) {
|
||||
const isim = {
|
||||
ekip: 'ekip üyesi', belgeAdet: 'belge', belgeBayt: 'belge alanı',
|
||||
apiAnahtari: 'API anahtarı', bildirimKriteri: 'bildirim kriteri', takip: 'ihale takibi',
|
||||
}[tur] || tur;
|
||||
const e = new Error(`${h.ad} planında en fazla ${limit} ${isim} hakkınız var. Yükseltmek için Planlar sekmesine bakın.`);
|
||||
e.kod = 'KOTA_ASILDI';
|
||||
e.limit = limit;
|
||||
e.plan = h.ad;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
+204
-1
@@ -13,13 +13,16 @@ import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import tls from 'node:tls';
|
||||
import fs from 'node:fs';
|
||||
import crypto from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readDtTenders } from './dt-store.mjs';
|
||||
import { loginWithGoogle, userFromAuthHeader } from './auth.mjs';
|
||||
import { emailConfigured, sendEmail, tenderDigestHtml, emailFrom } from './email.mjs';
|
||||
import { aiConfigured, availableProviders, analyzeSpec } from './ai.mjs';
|
||||
import { billingConfigured, planCatalog, createCheckoutSession } from './billing.mjs';
|
||||
import { billingConfigured, webhookConfigured, planCatalog, createCheckoutSession, verifyWebhook, planForPrice } from './billing.mjs';
|
||||
import * as store from './user-store.mjs';
|
||||
import { planOf, haklar, kullanim, kotaKontrol, ozellikVar } from './entitlements.mjs';
|
||||
import { searchTed } from './ted.mjs';
|
||||
import { readHistory, historyStats } from './history-store.mjs';
|
||||
import { startScheduler, schedulerStatus } from './scheduler.mjs';
|
||||
@@ -380,6 +383,18 @@ function json(res, code, body) {
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function readRaw(req, limit = 1024 * 1024) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buf = '';
|
||||
req.on('data', (c) => {
|
||||
buf += c;
|
||||
if (buf.length > limit) { reject(new Error('Gövde çok büyük')); req.destroy(); }
|
||||
});
|
||||
req.on('end', () => resolve(buf));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function readBody(req, limit = 100 * 1024) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
@@ -545,6 +560,194 @@ const server = http.createServer(async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Belge deposu (GERÇEK dosya, GERÇEK boyut, kota uygulanır) ---
|
||||
if (url.pathname === '/api/me/documents') {
|
||||
const user = userFromAuthHeader(req.headers['authorization']);
|
||||
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
|
||||
store.ensureUser(user.email, user.name);
|
||||
const opts = { isAdmin: user.isAdmin };
|
||||
|
||||
if (req.method === 'POST') {
|
||||
try {
|
||||
const body = await readBody(req, 12 * 1024 * 1024); // base64 şişmesi payı
|
||||
if (!body.ad || !body.icerikBase64) {
|
||||
return json(res, 400, { success: false, error: 'ad ve icerikBase64 gerekli' });
|
||||
}
|
||||
const buf = Buffer.from(body.icerikBase64, 'base64');
|
||||
const kullanimDurumu = store.documentUsage(user.email);
|
||||
kotaKontrol(user.email, 'belgeAdet', kullanimDurumu.adet, 1, opts);
|
||||
kotaKontrol(user.email, 'belgeBayt', kullanimDurumu.bayt, buf.length, opts);
|
||||
|
||||
const dosyaAdi = `${Date.now()}-${crypto.randomUUID()}`;
|
||||
fs.writeFileSync(path.join(store.BELGE_DIR, dosyaAdi), buf);
|
||||
const id = store.addDocument(user.email, {
|
||||
ad: String(body.ad).slice(0, 200),
|
||||
kategori: body.kategori || null,
|
||||
boyut: buf.length, // GERÇEK bayt — rastgele değil
|
||||
mime: body.mime || null,
|
||||
dosya: dosyaAdi,
|
||||
sonKullanma: body.sonKullanma || null,
|
||||
});
|
||||
return json(res, 200, { success: true, id, belgeler: store.listDocuments(user.email), kullanim: store.documentUsage(user.email) });
|
||||
} catch (e) {
|
||||
return json(res, e.kod === 'KOTA_ASILDI' ? 402 : 400, { success: false, error: e.message, kod: e.kod || null });
|
||||
}
|
||||
}
|
||||
if (req.method === 'DELETE') {
|
||||
const ok = store.deleteDocument(user.email, url.searchParams.get('id'));
|
||||
return json(res, ok ? 200 : 404, { success: ok, belgeler: store.listDocuments(user.email), kullanim: store.documentUsage(user.email) });
|
||||
}
|
||||
return json(res, 200, {
|
||||
success: true,
|
||||
belgeler: store.listDocuments(user.email),
|
||||
kullanim: store.documentUsage(user.email),
|
||||
haklar: { adet: haklar(user.email, opts).belgeAdet, bayt: haklar(user.email, opts).belgeBayt },
|
||||
});
|
||||
}
|
||||
// Belge indirme
|
||||
if (url.pathname === '/api/me/documents/download') {
|
||||
const user = userFromAuthHeader(req.headers['authorization']);
|
||||
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
|
||||
const rec = store.getDocument(user.email, url.searchParams.get('id'));
|
||||
if (!rec) return json(res, 404, { success: false, error: 'belge bulunamadı' });
|
||||
try {
|
||||
const buf = fs.readFileSync(path.join(store.BELGE_DIR, rec.dosya));
|
||||
res.writeHead(200, {
|
||||
'Content-Type': rec.mime || 'application/octet-stream',
|
||||
'Content-Length': buf.length,
|
||||
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(rec.ad)}`,
|
||||
});
|
||||
return res.end(buf);
|
||||
} catch (e) {
|
||||
return json(res, 500, { success: false, error: 'dosya okunamadı' });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Kullanıcı verisi (localStorage yerine sunucu tarafı) ---
|
||||
if (url.pathname.startsWith('/api/me/data')) {
|
||||
const user = userFromAuthHeader(req.headers['authorization']);
|
||||
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
|
||||
store.ensureUser(user.email, user.name);
|
||||
const anahtar = url.searchParams.get('anahtar');
|
||||
|
||||
if (req.method === 'GET') {
|
||||
if (anahtar) return json(res, 200, { success: true, anahtar, deger: store.getData(user.email, anahtar) });
|
||||
return json(res, 200, { success: true, veri: store.getAllData(user.email) });
|
||||
}
|
||||
if (req.method === 'PUT' || req.method === 'POST') {
|
||||
try {
|
||||
const body = await readBody(req, 512 * 1024);
|
||||
const k = body.anahtar || anahtar;
|
||||
if (!k) return json(res, 400, { success: false, error: 'anahtar gerekli' });
|
||||
// Ekip kotası sunucu tarafında uygulanır
|
||||
if (k === 'ekip' && Array.isArray(body.deger)) {
|
||||
kotaKontrol(user.email, 'ekip', 0, body.deger.length, { isAdmin: user.isAdmin });
|
||||
}
|
||||
store.setData(user.email, k, body.deger);
|
||||
return json(res, 200, { success: true });
|
||||
} catch (e) {
|
||||
return json(res, e.kod === 'KOTA_ASILDI' ? 402 : 400, { success: false, error: e.message, kod: e.kod || null });
|
||||
}
|
||||
}
|
||||
return json(res, 405, { success: false, error: 'desteklenmeyen metot' });
|
||||
}
|
||||
|
||||
// --- API anahtarları (gerçek, hash'li, sunucu doğrulamalı) ---
|
||||
if (url.pathname === '/api/me/apikeys') {
|
||||
const user = userFromAuthHeader(req.headers['authorization']);
|
||||
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
|
||||
store.ensureUser(user.email, user.name);
|
||||
|
||||
if (req.method === 'POST') {
|
||||
try {
|
||||
const mevcut = store.listApiKeys(user.email).length;
|
||||
kotaKontrol(user.email, 'apiAnahtari', mevcut, 1, { isAdmin: user.isAdmin });
|
||||
const body = await readBody(req).catch(() => ({}));
|
||||
const k = store.createApiKey(user.email, body.ad);
|
||||
// `anahtar` YALNIZCA burada döner; sunucuda yalnızca hash'i saklanır
|
||||
return json(res, 200, { success: true, anahtar: k });
|
||||
} catch (e) {
|
||||
return json(res, e.kod === 'KOTA_ASILDI' ? 402 : 400, { success: false, error: e.message, kod: e.kod || null });
|
||||
}
|
||||
}
|
||||
if (req.method === 'DELETE') {
|
||||
const ok = store.revokeApiKey(user.email, url.searchParams.get('id'));
|
||||
return json(res, ok ? 200 : 404, { success: ok, anahtarlar: store.listApiKeys(user.email) });
|
||||
}
|
||||
return json(res, 200, { success: true, anahtarlar: store.listApiKeys(user.email), limit: haklar(user.email, { isAdmin: user.isAdmin }).apiAnahtari });
|
||||
}
|
||||
|
||||
// --- Stripe webhook (imza doğrulamalı) ---
|
||||
// NOT: Bu uç kimlik doğrulaması İSTEMEZ; güvenlik imza doğrulamasıyla sağlanır.
|
||||
if (url.pathname === '/api/billing/webhook' && req.method === 'POST') {
|
||||
if (!webhookConfigured()) {
|
||||
return json(res, 503, { success: false, error: 'STRIPE_WEBHOOK_SECRET tanımlı değil' });
|
||||
}
|
||||
let olay;
|
||||
try {
|
||||
const ham = await readRaw(req);
|
||||
olay = verifyWebhook(ham, req.headers['stripe-signature']);
|
||||
} catch (e) {
|
||||
console.warn('[stripe-webhook] imza reddedildi:', e.message);
|
||||
return json(res, 400, { success: false, error: e.message });
|
||||
}
|
||||
try {
|
||||
const o = olay.data?.object || {};
|
||||
switch (olay.type) {
|
||||
case 'checkout.session.completed': {
|
||||
const email = (o.client_reference_id || o.customer_email || o.metadata?.email || '').toLowerCase();
|
||||
const planId = o.metadata?.planId || 'pro';
|
||||
if (email) {
|
||||
store.setPlan(email, {
|
||||
plan: planId, kaynak: 'stripe',
|
||||
customerId: o.customer || null, subId: o.subscription || null,
|
||||
});
|
||||
console.log(`[stripe-webhook] ${email} → ${planId} planına geçti`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'customer.subscription.updated':
|
||||
case 'customer.subscription.created': {
|
||||
const priceId = o.items?.data?.[0]?.price?.id;
|
||||
const planId = planForPrice(priceId) || 'pro';
|
||||
const aktif = ['active', 'trialing'].includes(o.status);
|
||||
const k = store.findByStripe({ customerId: o.customer, subId: o.id });
|
||||
if (k) {
|
||||
store.setPlan(k.email, {
|
||||
plan: aktif ? planId : 'ucretsiz', kaynak: 'stripe',
|
||||
bitis: o.current_period_end ? new Date(o.current_period_end * 1000).toISOString() : null,
|
||||
customerId: o.customer, subId: o.id,
|
||||
});
|
||||
console.log(`[stripe-webhook] ${k.email} aboneliği ${o.status} → ${aktif ? planId : 'ucretsiz'}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'customer.subscription.deleted': {
|
||||
const k = store.findByStripe({ customerId: o.customer, subId: o.id });
|
||||
if (k) {
|
||||
store.setPlan(k.email, { plan: 'ucretsiz', kaynak: 'stripe', customerId: o.customer, subId: o.id });
|
||||
console.log(`[stripe-webhook] ${k.email} aboneliği iptal edildi`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break; // ilgilenmediğimiz olaylar sessizce onaylanır
|
||||
}
|
||||
return json(res, 200, { received: true });
|
||||
} catch (e) {
|
||||
console.error('[stripe-webhook] işleme hatası:', e.message);
|
||||
return json(res, 500, { success: false, error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Kullanıcının planı ve kullanımı ---
|
||||
if (url.pathname === '/api/me/plan') {
|
||||
const user = userFromAuthHeader(req.headers['authorization']);
|
||||
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
|
||||
store.ensureUser(user.email, user.name);
|
||||
return json(res, 200, { success: true, ...kullanim(user.email, { isAdmin: user.isAdmin }) });
|
||||
}
|
||||
|
||||
// --- Abonelik / ödeme ---
|
||||
if (url.pathname === '/api/billing/plans') {
|
||||
return json(res, 200, { configured: billingConfigured(), plans: planCatalog() });
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Sunucu tarafı kullanıcı deposu — plan, abonelik, kullanıcı verisi,
|
||||
* API anahtarları ve belgeler. (Önceden bunların hepsi localStorage'daydı;
|
||||
* cihaz değişince kayboluyor, sunucu tarafında doğrulanamıyordu.)
|
||||
*
|
||||
* Kalıcı volume: server/data/users.sqlite
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
let DatabaseSync = null;
|
||||
try {
|
||||
({ DatabaseSync } = await import('node:sqlite'));
|
||||
} catch {
|
||||
console.warn('[user-store] node:sqlite yok — sunucu tarafı kullanıcı verisi devre dışı');
|
||||
}
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DATA_DIR = path.join(__dirname, 'data');
|
||||
const DB_PATH = path.join(DATA_DIR, 'users.sqlite');
|
||||
export const BELGE_DIR = path.join(DATA_DIR, 'belgeler');
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS kullanicilar (
|
||||
email TEXT PRIMARY KEY,
|
||||
ad TEXT,
|
||||
plan TEXT NOT NULL DEFAULT 'ucretsiz',
|
||||
plan_kaynak TEXT, -- 'stripe' | 'manuel' | 'deneme'
|
||||
plan_bitis TEXT,
|
||||
stripe_customer_id TEXT,
|
||||
stripe_sub_id TEXT,
|
||||
olusturma TEXT,
|
||||
son_giris TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_stripe_cus ON kullanicilar(stripe_customer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_stripe_sub ON kullanicilar(stripe_sub_id);
|
||||
|
||||
-- localStorage yerine: favoriler, ekip, filtreler, ayarlar…
|
||||
CREATE TABLE IF NOT EXISTS kullanici_verisi (
|
||||
email TEXT NOT NULL,
|
||||
anahtar TEXT NOT NULL,
|
||||
deger TEXT,
|
||||
guncelleme TEXT,
|
||||
PRIMARY KEY (email, anahtar)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_anahtarlari (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
ad TEXT,
|
||||
onek TEXT NOT NULL, -- gösterim için ilk 12 karakter
|
||||
hash TEXT NOT NULL, -- sha256(anahtar) — ham anahtar SAKLANMAZ
|
||||
olusturma TEXT,
|
||||
son_kullanim TEXT,
|
||||
iptal INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_apikey_hash ON api_anahtarlari(hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_apikey_email ON api_anahtarlari(email);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS belgeler (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
ad TEXT NOT NULL,
|
||||
kategori TEXT,
|
||||
boyut INTEGER NOT NULL, -- GERÇEK bayt
|
||||
mime TEXT,
|
||||
dosya TEXT NOT NULL, -- diskteki dosya adı
|
||||
son_kullanma TEXT,
|
||||
olusturma TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_belge_email ON belgeler(email);
|
||||
`;
|
||||
|
||||
export function available() { return Boolean(DatabaseSync); }
|
||||
|
||||
export function openDb() {
|
||||
if (!DatabaseSync) return null;
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
fs.mkdirSync(BELGE_DIR, { recursive: true });
|
||||
const db = new DatabaseSync(DB_PATH);
|
||||
db.exec(SCHEMA);
|
||||
return db;
|
||||
}
|
||||
|
||||
const now = () => new Date().toISOString();
|
||||
const withDb = (fn, fallback = null) => {
|
||||
const db = openDb();
|
||||
if (!db) return fallback;
|
||||
try { return fn(db); }
|
||||
catch (e) { console.warn('[user-store]', e.message); return fallback; }
|
||||
finally { db.close(); }
|
||||
};
|
||||
|
||||
// --- Kullanıcı / plan ------------------------------------------------------
|
||||
|
||||
export function ensureUser(email, ad = null) {
|
||||
return withDb((db) => {
|
||||
db.prepare(`INSERT INTO kullanicilar (email, ad, plan, olusturma, son_giris)
|
||||
VALUES (?,?, 'ucretsiz', ?, ?)
|
||||
ON CONFLICT(email) DO UPDATE SET son_giris=excluded.son_giris,
|
||||
ad=COALESCE(excluded.ad, kullanicilar.ad)`)
|
||||
.run(email, ad, now(), now());
|
||||
return db.prepare('SELECT * FROM kullanicilar WHERE email=?').get(email);
|
||||
});
|
||||
}
|
||||
|
||||
export function getUser(email) {
|
||||
return withDb((db) => db.prepare('SELECT * FROM kullanicilar WHERE email=?').get(email) || null);
|
||||
}
|
||||
|
||||
/** Planı doğrudan ayarlar (webhook veya yönetici). */
|
||||
export function setPlan(email, { plan, kaynak = 'stripe', bitis = null, customerId = null, subId = null, ad = null }) {
|
||||
return withDb((db) => {
|
||||
db.prepare(`INSERT INTO kullanicilar (email, ad, plan, plan_kaynak, plan_bitis, stripe_customer_id, stripe_sub_id, olusturma, son_giris)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(email) DO UPDATE SET
|
||||
plan=excluded.plan, plan_kaynak=excluded.plan_kaynak, plan_bitis=excluded.plan_bitis,
|
||||
stripe_customer_id=COALESCE(excluded.stripe_customer_id, kullanicilar.stripe_customer_id),
|
||||
stripe_sub_id=COALESCE(excluded.stripe_sub_id, kullanicilar.stripe_sub_id),
|
||||
ad=COALESCE(excluded.ad, kullanicilar.ad)`)
|
||||
.run(email, ad, plan, kaynak, bitis, customerId, subId, now(), now());
|
||||
return db.prepare('SELECT * FROM kullanicilar WHERE email=?').get(email);
|
||||
});
|
||||
}
|
||||
|
||||
export function findByStripe({ customerId, subId }) {
|
||||
return withDb((db) => {
|
||||
if (subId) {
|
||||
const r = db.prepare('SELECT * FROM kullanicilar WHERE stripe_sub_id=?').get(subId);
|
||||
if (r) return r;
|
||||
}
|
||||
if (customerId) return db.prepare('SELECT * FROM kullanicilar WHERE stripe_customer_id=?').get(customerId) || null;
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Kullanıcı verisi (localStorage yerine) --------------------------------
|
||||
|
||||
const IZINLI_ANAHTARLAR = new Set([
|
||||
'favori_kurumlar', 'ekip', 'saved_filters', 'reminder',
|
||||
'rakip_izleme', 'ciro_hedefi', 'ai_model_config', 'kanban',
|
||||
]);
|
||||
|
||||
export function getData(email, anahtar) {
|
||||
if (!IZINLI_ANAHTARLAR.has(anahtar)) return null;
|
||||
return withDb((db) => {
|
||||
const r = db.prepare('SELECT deger FROM kullanici_verisi WHERE email=? AND anahtar=?').get(email, anahtar);
|
||||
if (!r?.deger) return null;
|
||||
try { return JSON.parse(r.deger); } catch { return null; }
|
||||
});
|
||||
}
|
||||
|
||||
export function getAllData(email) {
|
||||
return withDb((db) => {
|
||||
const rows = db.prepare('SELECT anahtar, deger FROM kullanici_verisi WHERE email=?').all(email);
|
||||
const out = {};
|
||||
for (const r of rows) {
|
||||
try { out[r.anahtar] = JSON.parse(r.deger); } catch { /* bozuk kayıt atlanır */ }
|
||||
}
|
||||
return out;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function setData(email, anahtar, deger) {
|
||||
if (!IZINLI_ANAHTARLAR.has(anahtar)) throw new Error(`Bilinmeyen veri anahtarı: ${anahtar}`);
|
||||
return withDb((db) => {
|
||||
db.prepare(`INSERT INTO kullanici_verisi (email, anahtar, deger, guncelleme) VALUES (?,?,?,?)
|
||||
ON CONFLICT(email, anahtar) DO UPDATE SET deger=excluded.deger, guncelleme=excluded.guncelleme`)
|
||||
.run(email, anahtar, JSON.stringify(deger ?? null), now());
|
||||
return true;
|
||||
}, false);
|
||||
}
|
||||
|
||||
// --- API anahtarları (gerçek, hash'li) -------------------------------------
|
||||
|
||||
const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
|
||||
|
||||
/** Yeni anahtar üretir. Ham değer YALNIZCA bir kez döner, saklanmaz. */
|
||||
export function createApiKey(email, ad) {
|
||||
const ham = 'ih_live_' + crypto.randomBytes(24).toString('base64url');
|
||||
const id = crypto.randomUUID();
|
||||
withDb((db) => db.prepare(
|
||||
'INSERT INTO api_anahtarlari (id,email,ad,onek,hash,olusturma) VALUES (?,?,?,?,?,?)'
|
||||
).run(id, email, ad || 'API Anahtarı', ham.slice(0, 12), sha256(ham), now()));
|
||||
return { id, ad: ad || 'API Anahtarı', onek: ham.slice(0, 12), anahtar: ham, olusturma: now() };
|
||||
}
|
||||
|
||||
export function listApiKeys(email) {
|
||||
return withDb((db) => db.prepare(
|
||||
'SELECT id, ad, onek, olusturma, son_kullanim, iptal FROM api_anahtarlari WHERE email=? AND iptal=0 ORDER BY olusturma DESC'
|
||||
).all(email), []);
|
||||
}
|
||||
|
||||
export function revokeApiKey(email, id) {
|
||||
return withDb((db) => db.prepare('UPDATE api_anahtarlari SET iptal=1 WHERE id=? AND email=?').run(id, email).changes > 0, false);
|
||||
}
|
||||
|
||||
/** Ham anahtarı doğrular; geçerliyse sahibini döner. */
|
||||
export function verifyApiKey(ham) {
|
||||
if (!ham || !String(ham).startsWith('ih_live_')) return null;
|
||||
return withDb((db) => {
|
||||
const r = db.prepare('SELECT * FROM api_anahtarlari WHERE hash=? AND iptal=0').get(sha256(ham));
|
||||
if (!r) return null;
|
||||
db.prepare('UPDATE api_anahtarlari SET son_kullanim=? WHERE id=?').run(now(), r.id);
|
||||
return { email: r.email, id: r.id, ad: r.ad };
|
||||
});
|
||||
}
|
||||
|
||||
// --- Belgeler (gerçek dosya, gerçek boyut) ---------------------------------
|
||||
|
||||
export function addDocument(email, { ad, kategori, boyut, mime, dosya, sonKullanma }) {
|
||||
const id = crypto.randomUUID();
|
||||
withDb((db) => db.prepare(
|
||||
'INSERT INTO belgeler (id,email,ad,kategori,boyut,mime,dosya,son_kullanma,olusturma) VALUES (?,?,?,?,?,?,?,?,?)'
|
||||
).run(id, email, ad, kategori || null, boyut, mime || null, dosya, sonKullanma || null, now()));
|
||||
return id;
|
||||
}
|
||||
|
||||
export function listDocuments(email) {
|
||||
return withDb((db) => db.prepare(
|
||||
'SELECT id, ad, kategori, boyut, mime, son_kullanma, olusturma FROM belgeler WHERE email=? ORDER BY olusturma DESC'
|
||||
).all(email), []);
|
||||
}
|
||||
|
||||
export function getDocument(email, id) {
|
||||
return withDb((db) => db.prepare('SELECT * FROM belgeler WHERE id=? AND email=?').get(id, email) || null);
|
||||
}
|
||||
|
||||
export function deleteDocument(email, id) {
|
||||
const rec = getDocument(email, id);
|
||||
if (!rec) return false;
|
||||
try { fs.unlinkSync(path.join(BELGE_DIR, rec.dosya)); } catch { /* dosya zaten yok */ }
|
||||
return withDb((db) => db.prepare('DELETE FROM belgeler WHERE id=? AND email=?').run(id, email).changes > 0, false);
|
||||
}
|
||||
|
||||
export function documentUsage(email) {
|
||||
return withDb((db) => {
|
||||
const r = db.prepare('SELECT COUNT(*) adet, COALESCE(SUM(boyut),0) bayt FROM belgeler WHERE email=?').get(email);
|
||||
return { adet: r?.adet ?? 0, bayt: r?.bayt ?? 0 };
|
||||
}, { adet: 0, bayt: 0 });
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { AppProvider, useApp } from './context/AppContext';
|
||||
import { KotaUyarisi } from './components/KotaUyarisi';
|
||||
import { Header } from './components/Header';
|
||||
import { LiveStatsBar } from './components/LiveStatsBar';
|
||||
import { TopNavigation } from './components/TopNavigation';
|
||||
@@ -39,6 +40,8 @@ const MainContent = () => {
|
||||
return (
|
||||
<main className="max-w-[1600px] mx-auto px-4 pb-12">
|
||||
{/* 3-Column Main View */}
|
||||
<KotaUyarisi />
|
||||
|
||||
{activeTab === 'list' && (
|
||||
<>
|
||||
<SummaryCards />
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { AlertTriangle, X, ArrowRight } from 'lucide-react';
|
||||
|
||||
/** Plan kotası aşıldığında sunucudan gelen uyarıyı gösterir. */
|
||||
export const KotaUyarisi = () => {
|
||||
const { kotaUyarisi, setKotaUyarisi, setActiveTab } = useApp();
|
||||
if (!kotaUyarisi) return null;
|
||||
return (
|
||||
<div className="bg-amber-50 border border-amber-300 rounded-xl p-4 my-3 flex items-start gap-3">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-700 shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-amber-900 leading-relaxed flex-1">{kotaUyarisi}</p>
|
||||
<button onClick={() => { setKotaUyarisi(null); setActiveTab('planlar'); }}
|
||||
className="text-[11px] font-bold text-amber-900 hover:underline flex items-center gap-1 shrink-0">
|
||||
Planlar <ArrowRight className="w-3 h-3" />
|
||||
</button>
|
||||
<button onClick={() => setKotaUyarisi(null)} className="text-amber-600 hover:text-amber-900 shrink-0">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,174 +1,166 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { Code, Key, Webhook, Send, CheckCircle2, Copy, Plus, Lock, Globe, MessageSquare, Terminal, RefreshCw } from 'lucide-react';
|
||||
import confetti from 'canvas-confetti';
|
||||
import { listApiKeys, createApiKey, revokeApiKey } from '../../services/account';
|
||||
import { Webhook, Key, Plus, Trash2, Loader2, Copy, Check, AlertTriangle, ShieldCheck, Lock } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Gerçek API anahtarı yönetimi.
|
||||
* Anahtar sunucuda üretilir, YALNIZCA hash'i saklanır, ham değer bir kez gösterilir.
|
||||
* (Önceden tarayıcıda Math.random ile üretiliyor ve hiçbir yerde doğrulanmıyordu.)
|
||||
*/
|
||||
export const ApiWebhooks = () => {
|
||||
const { apiKeys, setApiKeys, webhooksConfig, setWebhooksConfig } = useApp();
|
||||
const [copiedKey, setCopiedKey] = useState(false);
|
||||
const [testSuccess, setTestSuccess] = useState(false);
|
||||
const { authUser } = useApp();
|
||||
const [durum, setDurum] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [hata, setHata] = useState(null);
|
||||
const [yeniAd, setYeniAd] = useState('');
|
||||
const [yeniAnahtar, setYeniAnahtar] = useState(null); // yalnızca bir kez gösterilir
|
||||
const [kopyalandi, setKopyalandi] = useState(false);
|
||||
|
||||
const handleGenerateApiKey = () => {
|
||||
const newKey = {
|
||||
id: 'key-' + Date.now(),
|
||||
name: 'Yeni Uygulama Anahtarı ' + (apiKeys.length + 1),
|
||||
key: 'ih_live_' + Math.random().toString(36).substring(2, 18),
|
||||
createdAt: new Date().toISOString().split('T')[0],
|
||||
lastUsed: 'Henüz Kullanılmadı'
|
||||
};
|
||||
setApiKeys(prev => [newKey, ...prev]);
|
||||
confetti({ particleCount: 50 });
|
||||
const yukle = () => {
|
||||
if (!authUser) return;
|
||||
listApiKeys().then(setDurum).catch((e) => setHata(e.message));
|
||||
};
|
||||
useEffect(yukle, [authUser]);
|
||||
|
||||
const olustur = async () => {
|
||||
setBusy(true); setHata(null); setYeniAnahtar(null);
|
||||
try {
|
||||
const d = await createApiKey(yeniAd || 'API Anahtarı');
|
||||
setYeniAnahtar(d.anahtar);
|
||||
setYeniAd('');
|
||||
yukle();
|
||||
} catch (e) { setHata(e.message); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const handleSendTestWebhook = () => {
|
||||
setTestSuccess(true);
|
||||
confetti({ particleCount: 60 });
|
||||
setTimeout(() => setTestSuccess(false), 4000);
|
||||
const iptal = async (id) => {
|
||||
setBusy(true); setHata(null);
|
||||
try { setDurum(await revokeApiKey(id)); }
|
||||
catch (e) { setHata(e.message); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const kopyala = (t) => {
|
||||
navigator.clipboard?.writeText(t);
|
||||
setKopyalandi(true);
|
||||
setTimeout(() => setKopyalandi(false), 2000);
|
||||
};
|
||||
|
||||
const limit = durum?.limit;
|
||||
const kilitli = limit === 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
|
||||
{/* Header */}
|
||||
<div className="bg-linear-to-r from-slate-900 via-emerald-950 to-teal-900 text-white p-6 rounded-2xl shadow-lg border border-emerald-800/40 flex items-center justify-between">
|
||||
<div className="bg-linear-to-r from-slate-900 via-violet-950 to-indigo-900 text-white p-6 rounded-2xl shadow-lg border border-violet-800/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-2xl bg-emerald-600 text-white flex items-center justify-center font-black shadow-md">
|
||||
<div className="w-12 h-12 rounded-2xl bg-violet-600 flex items-center justify-center shadow-md">
|
||||
<Webhook className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-extrabold text-xl">API & Webhook Entegrasyon Yönetimi</h2>
|
||||
<p className="text-xs text-emerald-200 mt-0.5">ERP, SAP, Logo, Mikro muhasebe yazılımlarınız ve Slack/Teams kanallarınız ile çift yönlü canlı veri aktarımı.</p>
|
||||
<h2 className="font-extrabold text-xl">API Erişimi</h2>
|
||||
<p className="text-xs text-violet-200 mt-0.5">
|
||||
İhale verisini kendi sistemlerinize (ERP, CRM) taşımak için sunucu doğrulamalı API anahtarları.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* REST API Keys Section */}
|
||||
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-xs space-y-4">
|
||||
{kilitli && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 flex gap-3">
|
||||
<Lock className="w-4 h-4 text-amber-700 shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-amber-900 leading-relaxed">
|
||||
API erişimi <strong>Kurumsal</strong> planda sunulur. Mevcut planınızda API anahtarı oluşturulamaz —
|
||||
Planlar sekmesinden yükseltebilirsiniz.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Yeni anahtar — yalnızca bir kez gösterilir */}
|
||||
{yeniAnahtar && (
|
||||
<div className="bg-emerald-50 border border-emerald-300 rounded-xl p-4 space-y-2">
|
||||
<p className="text-xs font-extrabold text-emerald-900 flex items-center gap-1.5">
|
||||
<ShieldCheck className="w-4 h-4" /> Anahtarınız oluşturuldu — bu değer bir daha gösterilmeyecek
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-white border border-emerald-200 rounded-lg px-3 py-2 text-[11px] font-mono break-all">
|
||||
{yeniAnahtar.anahtar}
|
||||
</code>
|
||||
<button onClick={() => kopyala(yeniAnahtar.anahtar)}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-xs px-3 py-2 rounded-lg flex items-center gap-1.5 shrink-0">
|
||||
{kopyalandi ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
{kopyalandi ? 'Kopyalandı' : 'Kopyala'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-emerald-800">
|
||||
Sunucuda yalnızca anahtarın <strong>hash</strong>'i saklanır; ham değeri biz de göremeyiz. Güvenli bir yere kaydedin.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-xs p-5 space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
|
||||
<div>
|
||||
<h3 className="font-extrabold text-sm text-slate-900 flex items-center gap-2">
|
||||
<Key className="w-4 h-4 text-emerald-600" /> REST API Canlı Erişim Anahtarları
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500">Geliştiricileriniz için RESTful JSON API anahtarları.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleGenerateApiKey}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white font-extrabold text-xs px-4 py-2 rounded-xl flex items-center gap-1.5 transition-colors shadow-xs"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Yeni API Anahtarı Üret</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{apiKeys.map(k => (
|
||||
<div key={k.id} className="bg-slate-50 p-4 rounded-xl border border-slate-200 flex flex-col md:flex-row md:items-center justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<span className="font-bold text-xs text-slate-900">{k.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="bg-slate-900 text-amber-400 font-mono text-xs px-3 py-1 rounded-lg">
|
||||
{k.key}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(k.key);
|
||||
setCopiedKey(true);
|
||||
setTimeout(() => setCopiedKey(false), 2000);
|
||||
}}
|
||||
className="p-1.5 text-slate-500 hover:text-slate-900 hover:bg-slate-200 rounded-lg transition-colors text-xs font-bold flex items-center gap-1"
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
<span>{copiedKey ? 'Kopyalandı!' : 'Kopyala'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] text-slate-500 font-medium space-y-0.5">
|
||||
<p>Oluşturulma: {k.createdAt}</p>
|
||||
<p>Son Kullanım: <strong className="text-emerald-700">{k.lastUsed}</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Webhook Configuration & Slack / Teams Section */}
|
||||
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-xs space-y-6">
|
||||
<div className="border-b border-slate-100 pb-3">
|
||||
<h3 className="font-extrabold text-sm text-slate-900 flex items-center gap-2">
|
||||
<MessageSquare className="w-4 h-4 text-blue-600" /> Slack & Microsoft Teams Webhook Ayarları
|
||||
<Key className="w-4 h-4 text-violet-600" /> API Anahtarları
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500">İhale etkinlikleri gerçekleştiğinde anında bildirim alacağınız HTTP Webhook URL adresleri.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-bold text-slate-800 uppercase tracking-wider">Slack Webhook URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={webhooksConfig.slackWebhookUrl}
|
||||
onChange={(e) => setWebhooksConfig({ ...webhooksConfig, slackWebhookUrl: e.target.value })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-xs font-mono text-slate-800"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-bold text-slate-800 uppercase tracking-wider">Microsoft Teams Webhook URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={webhooksConfig.teamsWebhookUrl}
|
||||
onChange={(e) => setWebhooksConfig({ ...webhooksConfig, teamsWebhookUrl: e.target.value })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-xs font-mono text-slate-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event Trigger Checkboxes */}
|
||||
<div className="space-y-3 pt-2">
|
||||
<label className="block text-xs font-bold text-slate-800 uppercase tracking-wider">Tetiklenecek Webhook Etkinlikleri</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{[
|
||||
{ key: 'newTenderMatch', title: '🟢 Yeni Uyumlu İhale Yayınlandığında' },
|
||||
{ key: 'tenderWon', title: '🏆 İhale Kazanıldığında ERP/Muhasebeye Gönder' },
|
||||
{ key: 'bidDeadlineAlert', title: '⚠️ İhale Son Başvurusuna 3 Saat Kala Uyar' },
|
||||
{ key: 'competitorActivity', title: '🛡️ İzlenen Rakip Firma Teklif Verince Bildir' }
|
||||
].map(item => (
|
||||
<label key={item.key} className="flex items-center gap-3 p-3 bg-slate-50 hover:bg-slate-100 rounded-xl border border-slate-200 cursor-pointer font-semibold text-xs text-slate-800">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={webhooksConfig.events[item.key]}
|
||||
onChange={(e) => setWebhooksConfig({
|
||||
...webhooksConfig,
|
||||
events: { ...webhooksConfig.events, [item.key]: e.target.checked }
|
||||
})}
|
||||
className="w-4 h-4 accent-emerald-600 rounded"
|
||||
/>
|
||||
<span>{item.title}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test Webhook Button */}
|
||||
<div className="pt-2 flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleSendTestWebhook}
|
||||
className="bg-slate-900 hover:bg-slate-800 text-white font-extrabold text-xs px-6 py-3 rounded-xl flex items-center gap-2 shadow-md transition-colors"
|
||||
>
|
||||
<Send className="w-4 h-4 text-emerald-400" />
|
||||
<span>Test Webhook Payload Gönder (HTTP 200 OK)</span>
|
||||
</button>
|
||||
|
||||
{testSuccess && (
|
||||
<span className="text-xs font-extrabold text-emerald-700 bg-emerald-50 border border-emerald-200 px-3 py-1.5 rounded-xl flex items-center gap-1.5 animate-fade-in">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
|
||||
<span>Test Webhook Başarıyla Slack & Teams Kanallarına İletildi! (HTTP 200 OK)</span>
|
||||
{durum && (
|
||||
<span className="text-[11px] text-slate-500">
|
||||
{durum.anahtarlar.length}{limit != null ? ` / ${limit}` : ''} anahtar
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!durum ? (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400"><Loader2 className="w-3.5 h-3.5 animate-spin" /> yükleniyor…</div>
|
||||
) : durum.anahtarlar.length === 0 ? (
|
||||
<p className="text-xs text-slate-400">Henüz anahtar yok.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{durum.anahtarlar.map((k) => (
|
||||
<div key={k.id} className="flex items-center justify-between gap-3 bg-slate-50 border border-slate-200 rounded-lg px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-bold text-slate-800">{k.ad}</p>
|
||||
<p className="text-[11px] font-mono text-slate-500">{k.onek}••••••••••••••••</p>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5">
|
||||
Oluşturma: {new Date(k.olusturma).toLocaleDateString('tr-TR')}
|
||||
{' · '}
|
||||
{k.son_kullanim ? `son kullanım: ${new Date(k.son_kullanim).toLocaleString('tr-TR')}` : 'hiç kullanılmadı'}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => iptal(k.id)} disabled={busy}
|
||||
className="text-slate-400 hover:text-red-500 shrink-0" title="Anahtarı iptal et">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2 border-t border-slate-100">
|
||||
<input value={yeniAd} onChange={(e) => setYeniAd(e.target.value)}
|
||||
placeholder="Anahtar adı (ör. ERP Entegrasyonu)" disabled={kilitli}
|
||||
className="flex-1 bg-slate-50 border border-slate-200 rounded-lg px-2.5 py-2 text-xs disabled:opacity-50" />
|
||||
<button onClick={olustur} disabled={busy || kilitli}
|
||||
className="bg-violet-600 hover:bg-violet-500 disabled:opacity-40 text-white font-bold text-xs px-4 rounded-lg flex items-center gap-1.5">
|
||||
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Plus className="w-3.5 h-3.5" />} Oluştur
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hata && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-2.5 flex gap-2">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-red-600 shrink-0 mt-0.5" />
|
||||
<p className="text-[11px] text-red-800">{hata}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4">
|
||||
<p className="text-[11px] text-slate-600 leading-relaxed">
|
||||
<strong>Kullanım:</strong> İsteklerinize <code className="bg-white px-1 rounded border">Authorization: Bearer ih_live_…</code>
|
||||
{' '}başlığını ekleyin. Anahtar sunucuda doğrulanır, her kullanımda son kullanım zamanı güncellenir,
|
||||
iptal ettiğinizde anında geçersiz olur.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,147 +1,156 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { FolderKanban, FileText, Upload, Plus, ShieldCheck, AlertCircle, CheckCircle2, Download, Trash2, Tag, Calendar } from 'lucide-react';
|
||||
import confetti from 'canvas-confetti';
|
||||
import { listDocuments, uploadDocument, deleteDocument, downloadDocument, formatBayt } from '../../services/account';
|
||||
import { FolderKanban, Upload, Trash2, Download, Loader2, AlertTriangle, FileText, HardDrive } from 'lucide-react';
|
||||
|
||||
const KATEGORILER = ['Yasal Belgeler', 'Kalite Belgeleri', 'Mali Belgeler', 'Resmi Kayıtlar', 'Teknik Belgeler', 'Diğer'];
|
||||
|
||||
/**
|
||||
* Gerçek belge deposu — dosyalar sunucudaki kalıcı diske yazılır,
|
||||
* boyut gerçek bayt cinsindendir ve plan kotası sunucu tarafında uygulanır.
|
||||
* (Önceden dosya hiç yüklenmiyordu; boyut Math.random ile uyduruluyordu.)
|
||||
*/
|
||||
export const DocumentVault = () => {
|
||||
const { vaultDocuments, setVaultDocuments, userProfile } = useApp();
|
||||
const [docName, setDocName] = useState('');
|
||||
const [category, setCategory] = useState('Kalite Belgeleri');
|
||||
const { authUser } = useApp();
|
||||
const [durum, setDurum] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [hata, setHata] = useState(null);
|
||||
const [kategori, setKategori] = useState(KATEGORILER[0]);
|
||||
const dosyaRef = useRef(null);
|
||||
|
||||
const handleAddDocument = (e) => {
|
||||
e.preventDefault();
|
||||
if (!docName.trim()) return;
|
||||
const yukle = () => {
|
||||
if (!authUser) return;
|
||||
listDocuments().then(setDurum).catch((e) => setHata(e.message));
|
||||
};
|
||||
useEffect(yukle, [authUser]);
|
||||
|
||||
const newDoc = {
|
||||
id: 'doc-' + Date.now(),
|
||||
name: docName.endsWith('.pdf') ? docName : docName + '.pdf',
|
||||
category,
|
||||
size: (Math.random() * 2 + 0.5).toFixed(1) + ' MB',
|
||||
updatedAt: new Date().toISOString().split('T')[0],
|
||||
expiresAt: '2027-12-31',
|
||||
status: 'Geçerli'
|
||||
};
|
||||
|
||||
setVaultDocuments(prev => [newDoc, ...prev]);
|
||||
setDocName('');
|
||||
confetti({ particleCount: 60 });
|
||||
const dosyaSecildi = async (e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (!f) return;
|
||||
setBusy(true); setHata(null);
|
||||
try {
|
||||
setDurum(await uploadDocument(f, kategori));
|
||||
} catch (err) {
|
||||
setHata(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
if (dosyaRef.current) dosyaRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const deleteDoc = (id) => {
|
||||
setVaultDocuments(prev => prev.filter(d => d.id !== id));
|
||||
const sil = async (id) => {
|
||||
setBusy(true); setHata(null);
|
||||
try { setDurum(await deleteDocument(id)); }
|
||||
catch (e) { setHata(e.message); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const k = durum?.kullanim;
|
||||
const h = durum?.haklar;
|
||||
const doluluk = h?.bayt ? Math.min(100, Math.round(((k?.bayt || 0) / h.bayt) * 100)) : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
|
||||
{/* Header */}
|
||||
<div className="bg-linear-to-r from-slate-900 via-blue-950 to-indigo-900 text-white p-6 rounded-2xl shadow-lg border border-blue-800/40 flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
|
||||
<div className="bg-linear-to-r from-slate-900 via-teal-950 to-cyan-900 text-white p-6 rounded-2xl shadow-lg border border-teal-800/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-2xl bg-blue-600 text-white flex items-center justify-center font-black shadow-md">
|
||||
<div className="w-12 h-12 rounded-2xl bg-teal-600 flex items-center justify-center shadow-md">
|
||||
<FolderKanban className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-extrabold text-xl">Doküman ve Teklif Deposu</h2>
|
||||
<span className="bg-emerald-500 text-slate-950 text-[10px] font-extrabold px-2.5 py-0.5 rounded-full uppercase tracking-wider">
|
||||
Resmi Evrak Kütüphanesi
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-blue-200 mt-0.5">Sık kullanılan resmi belgelerinizi (İmza sirküleri, ISO, ÜTS, Faaliyet Belgesi) tek yerde saklayın; ihale teklif paketine saniyeler içinde ekleyin.</p>
|
||||
<h2 className="font-extrabold text-xl">Belge Kasası</h2>
|
||||
<p className="text-xs text-teal-200 mt-0.5">
|
||||
İhale dosyalarınıza her cihazdan erişin — belgeler sunucuda saklanır, tarayıcınızda değil.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Document Form */}
|
||||
<form onSubmit={handleAddDocument} className="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs flex flex-col md:flex-row gap-3 items-end">
|
||||
<div className="flex-1 space-y-1.5 w-full">
|
||||
<label className="block text-xs font-bold text-slate-800 uppercase tracking-wider">Yeni Doküman Adı</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={docName}
|
||||
onChange={(e) => setDocName(e.target.value)}
|
||||
placeholder="Örn: 2026 ISO 9001 Kalite Belgesi.pdf"
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-xs font-semibold text-slate-900 focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-64 space-y-1.5">
|
||||
<label className="block text-xs font-bold text-slate-800 uppercase tracking-wider">Kategori</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-3 py-2.5 text-xs font-semibold text-slate-900"
|
||||
>
|
||||
<option>Yasal Belgeler</option>
|
||||
<option>Kalite Belgeleri</option>
|
||||
<option>Tıbbi Cihaz Kaydı</option>
|
||||
<option>Resmi Kayıtlar</option>
|
||||
<option>Mali Belgeler</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full md:w-auto bg-blue-600 hover:bg-blue-500 text-white font-extrabold text-xs px-6 py-3 rounded-xl flex items-center justify-center gap-2 shadow-md transition-colors shrink-0"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
<span>Depoya Ekle</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Documents Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{vaultDocuments.map((doc) => (
|
||||
<div key={doc.id} className="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs hover:shadow-md transition-all space-y-3 flex flex-col justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="bg-slate-100 text-slate-700 font-bold text-[10px] px-2.5 py-1 rounded-lg border border-slate-200 flex items-center gap-1">
|
||||
<Tag className="w-3 h-3 text-blue-600" /> {doc.category}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold text-emerald-700 bg-emerald-50 border border-emerald-200 px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
<ShieldCheck className="w-3 h-3" /> {doc.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 pt-1">
|
||||
<div className="w-10 h-10 rounded-xl bg-red-50 text-red-600 border border-red-200 flex items-center justify-center shrink-0 font-black">
|
||||
<FileText className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-xs text-slate-900 leading-snug line-clamp-2">{doc.name}</h4>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5">Boyut: {doc.size}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Kullanım */}
|
||||
{durum && (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-xs p-5 space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="font-bold text-slate-800 flex items-center gap-1.5">
|
||||
<HardDrive className="w-3.5 h-3.5 text-teal-600" /> Kullanım
|
||||
</span>
|
||||
<span className="text-slate-600 font-semibold">
|
||||
{k.adet}{h?.adet != null ? ` / ${h.adet}` : ''} belge · {formatBayt(k.bayt)}
|
||||
{h?.bayt != null && ` / ${formatBayt(h.bayt)}`}
|
||||
</span>
|
||||
</div>
|
||||
{h?.bayt != null && (
|
||||
<div className="w-full bg-slate-100 rounded-full h-2 overflow-hidden">
|
||||
<div className={`h-2 rounded-full transition-all ${doluluk > 90 ? 'bg-red-500' : doluluk > 70 ? 'bg-amber-500' : 'bg-teal-600'}`}
|
||||
style={{ width: `${doluluk}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-slate-100 pt-3 flex items-center justify-between text-[11px] text-slate-500 font-medium">
|
||||
<span className="flex items-center gap-1 text-slate-600">
|
||||
<Calendar className="w-3.5 h-3.5 text-slate-400" /> Son Geçerlilik: <strong>{doc.expiresAt}</strong>
|
||||
</span>
|
||||
{/* Yükleme */}
|
||||
<div className="bg-white rounded-2xl border-2 border-dashed border-teal-300 p-6 text-center space-y-3">
|
||||
<div className="w-12 h-12 rounded-2xl bg-teal-50 text-teal-600 flex items-center justify-center mx-auto">
|
||||
<Upload className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-sm text-slate-800">Belge Yükleyin</h3>
|
||||
<p className="text-xs text-slate-500">İmza sirküleri, sertifikalar, vergi levhası… (en fazla 8 MB)</p>
|
||||
</div>
|
||||
<div className="flex justify-center gap-2 flex-wrap">
|
||||
<select value={kategori} onChange={(e) => setKategori(e.target.value)}
|
||||
className="bg-slate-50 border border-slate-200 rounded-lg px-2.5 py-2 text-xs">
|
||||
{KATEGORILER.map((c) => <option key={c}>{c}</option>)}
|
||||
</select>
|
||||
<label className={`font-extrabold text-xs px-5 py-2.5 rounded-xl shadow-md flex items-center gap-2 ${
|
||||
busy ? 'bg-slate-300 text-slate-500 cursor-not-allowed' : 'bg-teal-600 hover:bg-teal-500 text-white cursor-pointer'
|
||||
}`}>
|
||||
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||||
{busy ? 'Yükleniyor…' : 'Dosya Seç'}
|
||||
<input ref={dosyaRef} type="file" className="hidden" disabled={busy} onChange={dosyaSecildi} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => alert(`${doc.name} başarıyla indirildi.`)}
|
||||
className="p-1.5 text-slate-600 hover:text-blue-600 rounded-lg hover:bg-slate-100 transition-colors"
|
||||
title="İndir"
|
||||
>
|
||||
{hata && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex gap-3">
|
||||
<AlertTriangle className="w-4 h-4 text-red-600 shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-red-800 leading-relaxed">{hata}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liste */}
|
||||
{!durum ? (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400"><Loader2 className="w-3.5 h-3.5 animate-spin" /> yükleniyor…</div>
|
||||
) : durum.belgeler.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl border border-dashed border-slate-300 p-10 text-center">
|
||||
<FileText className="w-8 h-8 text-slate-300 mx-auto mb-3" />
|
||||
<p className="text-sm font-bold text-slate-500">Henüz belge yüklemediniz</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{durum.belgeler.map((d) => (
|
||||
<div key={d.id} className="bg-white rounded-xl border border-slate-200 p-4 shadow-xs flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-bold text-slate-900 truncate">{d.ad}</p>
|
||||
<p className="text-[11px] text-slate-500 mt-0.5">
|
||||
{d.kategori || 'Diğer'} · <strong>{formatBayt(d.boyut)}</strong>
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5">
|
||||
{new Date(d.olusturma).toLocaleString('tr-TR')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button onClick={() => downloadDocument(d.id, d.ad).catch((e) => setHata(e.message))}
|
||||
className="text-slate-400 hover:text-teal-600 p-1" title="İndir">
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteDoc(doc.id)}
|
||||
className="p-1.5 text-slate-400 hover:text-red-600 rounded-lg hover:bg-red-50 transition-colors"
|
||||
title="Sil"
|
||||
>
|
||||
<button onClick={() => sil(d.id)} disabled={busy} className="text-slate-400 hover:text-red-500 p-1" title="Sil">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { parseWebsiteCapabilitiesDynamically } from '../services/webScraperService';
|
||||
import { fetchLiveTenders, fetchTenderDetail, mergeUserState } from '../services/tenderApi';
|
||||
import { restoreSession, clearSession, getStoredUser } from '../services/auth';
|
||||
import { getAllUserData, setUserData, getPlan } from '../services/account';
|
||||
|
||||
const AppContext = createContext();
|
||||
|
||||
@@ -43,6 +44,39 @@ export const AppProvider = ({ children }) => {
|
||||
.finally(() => setAuthReady(true));
|
||||
}, []);
|
||||
|
||||
// Giriş yapınca kullanıcı verisini SUNUCUDAN yükle (localStorage'ı ezer)
|
||||
useEffect(() => {
|
||||
if (!authUser) { setVeriSenkron(false); setPlan(null); return; }
|
||||
planYenile();
|
||||
getAllUserData()
|
||||
.then((v) => {
|
||||
if (v.favori_kurumlar) setFavoriteKurumlar(v.favori_kurumlar);
|
||||
if (v.ekip) setTeamMembers(v.ekip);
|
||||
if (v.saved_filters) setSavedFilters(v.saved_filters);
|
||||
if (v.reminder) setReminderSettings((s) => ({ ...s, ...v.reminder }));
|
||||
if (v.rakip_izleme) setCompetitorWatchlist(v.rakip_izleme);
|
||||
})
|
||||
.catch((e) => console.warn('[veri] sunucudan alınamadı:', e.message))
|
||||
.finally(() => setVeriSenkron(true));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [authUser?.email]);
|
||||
|
||||
// --- Sunucu tarafı kullanıcı verisi ---
|
||||
// localStorage yalnızca çevrimdışı önbellek; kaynak doğruluk sunucuda.
|
||||
const [plan, setPlan] = useState(null);
|
||||
const [veriSenkron, setVeriSenkron] = useState(false);
|
||||
|
||||
const sunucuyaYaz = (anahtar, deger) => {
|
||||
if (!authUser) return;
|
||||
setUserData(anahtar, deger).catch((e) => {
|
||||
if (e.kod === 'KOTA_ASILDI') setKotaUyarisi(e.message);
|
||||
else console.warn('[veri] sunucuya yazılamadı:', e.message);
|
||||
});
|
||||
};
|
||||
const [kotaUyarisi, setKotaUyarisi] = useState(null);
|
||||
|
||||
const planYenile = () => getPlan().then(setPlan).catch(() => {});
|
||||
|
||||
const handleLogin = (user) => setAuthUser(user);
|
||||
const handleLogout = () => {
|
||||
clearSession();
|
||||
@@ -109,7 +143,8 @@ export const AppProvider = ({ children }) => {
|
||||
});
|
||||
useEffect(() => {
|
||||
localStorage.setItem('ihaleist_rakip_izleme', JSON.stringify(competitorWatchlist));
|
||||
}, [competitorWatchlist]);
|
||||
if (veriSenkron) sunucuyaYaz('rakip_izleme', competitorWatchlist);
|
||||
}, [competitorWatchlist, veriSenkron]);
|
||||
|
||||
// --- Favori kurumlar (localStorage, gerçek) ---
|
||||
const [favoriteKurumlar, setFavoriteKurumlar] = useState(() => {
|
||||
@@ -117,7 +152,8 @@ export const AppProvider = ({ children }) => {
|
||||
});
|
||||
useEffect(() => {
|
||||
localStorage.setItem('ihaleist_favori_kurumlar', JSON.stringify(favoriteKurumlar));
|
||||
}, [favoriteKurumlar]);
|
||||
if (veriSenkron) sunucuyaYaz('favori_kurumlar', favoriteKurumlar);
|
||||
}, [favoriteKurumlar, veriSenkron]);
|
||||
const toggleFavoriteKurum = (kurum) => {
|
||||
const ad = (kurum || '').trim();
|
||||
if (!ad) return;
|
||||
@@ -136,7 +172,8 @@ export const AppProvider = ({ children }) => {
|
||||
});
|
||||
useEffect(() => {
|
||||
localStorage.setItem('ihaleist_ekip', JSON.stringify(teamMembers));
|
||||
}, [teamMembers]);
|
||||
if (veriSenkron) sunucuyaYaz('ekip', teamMembers);
|
||||
}, [teamMembers, veriSenkron]);
|
||||
const addTeamMember = (m) => setTeamMembers((prev) => [...prev, { id: 'tm-' + Date.now(), ...m }]);
|
||||
const removeTeamMember = (id) => setTeamMembers((prev) => prev.filter((m) => m.id !== id));
|
||||
|
||||
@@ -162,7 +199,8 @@ export const AppProvider = ({ children }) => {
|
||||
});
|
||||
useEffect(() => {
|
||||
localStorage.setItem('ihaleist_reminder', JSON.stringify(reminderSettings));
|
||||
}, [reminderSettings]);
|
||||
if (veriSenkron) sunucuyaYaz('reminder', reminderSettings);
|
||||
}, [reminderSettings, veriSenkron]);
|
||||
|
||||
// Şu an sessiz saat aralığında mıyız (gece yarısını geçen aralıkları da destekler)
|
||||
const isQuietNow = () => {
|
||||
@@ -321,7 +359,8 @@ export const AppProvider = ({ children }) => {
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('ihaleist_saved_filters', JSON.stringify(savedFilters));
|
||||
}, [savedFilters]);
|
||||
if (veriSenkron) sunucuyaYaz('saved_filters', savedFilters);
|
||||
}, [savedFilters, veriSenkron]);
|
||||
|
||||
// (Sahte "online kullanıcı" rastgele sayacı kaldırıldı — canlı istatistikler
|
||||
// gerçek /api/stats verisinden gelir.)
|
||||
@@ -447,7 +486,12 @@ export const AppProvider = ({ children }) => {
|
||||
removeTeamMember,
|
||||
toggleTrackTender,
|
||||
updateKanbanStatus,
|
||||
clearAllFilters
|
||||
clearAllFilters,
|
||||
plan,
|
||||
planYenile,
|
||||
kotaUyarisi,
|
||||
setKotaUyarisi,
|
||||
veriSenkron
|
||||
}}>
|
||||
{children}
|
||||
</AppContext.Provider>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Sunucu tarafı hesap servisleri: plan/kota, kullanıcı verisi, API anahtarları, belgeler.
|
||||
* (Önceden bunların hepsi localStorage'daydı veya sahteydi.)
|
||||
*/
|
||||
import { getToken } from './auth';
|
||||
|
||||
const auth = () => ({ Authorization: `Bearer ${getToken()}`, 'Content-Type': 'application/json' });
|
||||
|
||||
async function iste(url, opts = {}) {
|
||||
const res = await fetch(url, { ...opts, headers: { ...auth(), ...(opts.headers || {}) } });
|
||||
const d = await res.json().catch(() => ({}));
|
||||
if (!d.success) {
|
||||
const e = new Error(d.error || `İstek başarısız (HTTP ${res.status})`);
|
||||
e.kod = d.kod;
|
||||
throw e;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
// --- Plan & kota ---
|
||||
export const getPlan = () => iste('/api/me/plan');
|
||||
|
||||
// --- Kullanıcı verisi ---
|
||||
export const getAllUserData = () => iste('/api/me/data').then((d) => d.veri || {});
|
||||
export const setUserData = (anahtar, deger) =>
|
||||
iste('/api/me/data', { method: 'PUT', body: JSON.stringify({ anahtar, deger }) });
|
||||
|
||||
// --- API anahtarları ---
|
||||
export const listApiKeys = () => iste('/api/me/apikeys');
|
||||
export const createApiKey = (ad) => iste('/api/me/apikeys', { method: 'POST', body: JSON.stringify({ ad }) });
|
||||
export const revokeApiKey = (id) => iste(`/api/me/apikeys?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
|
||||
// --- Belgeler ---
|
||||
export const listDocuments = () => iste('/api/me/documents');
|
||||
export const deleteDocument = (id) => iste(`/api/me/documents?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
|
||||
export async function uploadDocument(file, kategori) {
|
||||
const icerikBase64 = await new Promise((resolve, reject) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => resolve(String(r.result).split(',')[1]);
|
||||
r.onerror = reject;
|
||||
r.readAsDataURL(file);
|
||||
});
|
||||
return iste('/api/me/documents', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ad: file.name, mime: file.type, kategori, icerikBase64 }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Auth başlığı gerektiği için <a href> yerine blob ile indirilir. */
|
||||
export async function downloadDocument(id, ad) {
|
||||
const res = await fetch(`/api/me/documents/download?id=${encodeURIComponent(id)}`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
throw new Error(d.error || `İndirilemedi (HTTP ${res.status})`);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = ad || 'belge';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||
}
|
||||
|
||||
export function formatBayt(b) {
|
||||
if (b == null) return '—';
|
||||
if (b < 1024) return `${b} B`;
|
||||
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
|
||||
if (b < 1024 * 1024 * 1024) return `${(b / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(b / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
Reference in New Issue
Block a user