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.
|
* döner; uygulama ücretsiz katmanda çalışmaya devam eder.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
export const PLANS = [
|
export const PLANS = [
|
||||||
{
|
{
|
||||||
id: 'ucretsiz',
|
id: 'ucretsiz',
|
||||||
@@ -71,7 +73,53 @@ export const PLANS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function billingConfigured() {
|
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() {
|
export function planCatalog() {
|
||||||
@@ -110,6 +158,7 @@ export async function createCheckoutSession({ planId, email }) {
|
|||||||
cancel_url: `${appUrl}/?odeme=iptal`,
|
cancel_url: `${appUrl}/?odeme=iptal`,
|
||||||
customer_email: email,
|
customer_email: email,
|
||||||
allow_promotion_codes: 'true',
|
allow_promotion_codes: 'true',
|
||||||
|
client_reference_id: email || '',
|
||||||
'metadata[planId]': planId,
|
'metadata[planId]': planId,
|
||||||
'metadata[email]': email || '',
|
'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 https from 'node:https';
|
||||||
import tls from 'node:tls';
|
import tls from 'node:tls';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { readDtTenders } from './dt-store.mjs';
|
import { readDtTenders } from './dt-store.mjs';
|
||||||
import { loginWithGoogle, userFromAuthHeader } from './auth.mjs';
|
import { loginWithGoogle, userFromAuthHeader } from './auth.mjs';
|
||||||
import { emailConfigured, sendEmail, tenderDigestHtml, emailFrom } from './email.mjs';
|
import { emailConfigured, sendEmail, tenderDigestHtml, emailFrom } from './email.mjs';
|
||||||
import { aiConfigured, availableProviders, analyzeSpec } from './ai.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 { searchTed } from './ted.mjs';
|
||||||
import { readHistory, historyStats } from './history-store.mjs';
|
import { readHistory, historyStats } from './history-store.mjs';
|
||||||
import { startScheduler, schedulerStatus } from './scheduler.mjs';
|
import { startScheduler, schedulerStatus } from './scheduler.mjs';
|
||||||
@@ -380,6 +383,18 @@ function json(res, code, body) {
|
|||||||
res.end(JSON.stringify(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) {
|
function readBody(req, limit = 100 * 1024) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let data = '';
|
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 ---
|
// --- Abonelik / ödeme ---
|
||||||
if (url.pathname === '/api/billing/plans') {
|
if (url.pathname === '/api/billing/plans') {
|
||||||
return json(res, 200, { configured: billingConfigured(), plans: planCatalog() });
|
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 React from 'react';
|
||||||
import { AppProvider, useApp } from './context/AppContext';
|
import { AppProvider, useApp } from './context/AppContext';
|
||||||
|
import { KotaUyarisi } from './components/KotaUyarisi';
|
||||||
import { Header } from './components/Header';
|
import { Header } from './components/Header';
|
||||||
import { LiveStatsBar } from './components/LiveStatsBar';
|
import { LiveStatsBar } from './components/LiveStatsBar';
|
||||||
import { TopNavigation } from './components/TopNavigation';
|
import { TopNavigation } from './components/TopNavigation';
|
||||||
@@ -39,6 +40,8 @@ const MainContent = () => {
|
|||||||
return (
|
return (
|
||||||
<main className="max-w-[1600px] mx-auto px-4 pb-12">
|
<main className="max-w-[1600px] mx-auto px-4 pb-12">
|
||||||
{/* 3-Column Main View */}
|
{/* 3-Column Main View */}
|
||||||
|
<KotaUyarisi />
|
||||||
|
|
||||||
{activeTab === 'list' && (
|
{activeTab === 'list' && (
|
||||||
<>
|
<>
|
||||||
<SummaryCards />
|
<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 { useApp } from '../../context/AppContext';
|
||||||
import { Code, Key, Webhook, Send, CheckCircle2, Copy, Plus, Lock, Globe, MessageSquare, Terminal, RefreshCw } from 'lucide-react';
|
import { listApiKeys, createApiKey, revokeApiKey } from '../../services/account';
|
||||||
import confetti from 'canvas-confetti';
|
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 = () => {
|
export const ApiWebhooks = () => {
|
||||||
const { apiKeys, setApiKeys, webhooksConfig, setWebhooksConfig } = useApp();
|
const { authUser } = useApp();
|
||||||
const [copiedKey, setCopiedKey] = useState(false);
|
const [durum, setDurum] = useState(null);
|
||||||
const [testSuccess, setTestSuccess] = useState(false);
|
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 yukle = () => {
|
||||||
const newKey = {
|
if (!authUser) return;
|
||||||
id: 'key-' + Date.now(),
|
listApiKeys().then(setDurum).catch((e) => setHata(e.message));
|
||||||
name: 'Yeni Uygulama Anahtarı ' + (apiKeys.length + 1),
|
};
|
||||||
key: 'ih_live_' + Math.random().toString(36).substring(2, 18),
|
useEffect(yukle, [authUser]);
|
||||||
createdAt: new Date().toISOString().split('T')[0],
|
|
||||||
lastUsed: 'Henüz Kullanılmadı'
|
const olustur = async () => {
|
||||||
};
|
setBusy(true); setHata(null); setYeniAnahtar(null);
|
||||||
setApiKeys(prev => [newKey, ...prev]);
|
try {
|
||||||
confetti({ particleCount: 50 });
|
const d = await createApiKey(yeniAd || 'API Anahtarı');
|
||||||
|
setYeniAnahtar(d.anahtar);
|
||||||
|
setYeniAd('');
|
||||||
|
yukle();
|
||||||
|
} catch (e) { setHata(e.message); }
|
||||||
|
finally { setBusy(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSendTestWebhook = () => {
|
const iptal = async (id) => {
|
||||||
setTestSuccess(true);
|
setBusy(true); setHata(null);
|
||||||
confetti({ particleCount: 60 });
|
try { setDurum(await revokeApiKey(id)); }
|
||||||
setTimeout(() => setTestSuccess(false), 4000);
|
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 (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
<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">
|
||||||
{/* 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="flex items-center gap-3">
|
<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" />
|
<Webhook className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-extrabold text-xl">API & Webhook Entegrasyon Yönetimi</h2>
|
<h2 className="font-extrabold text-xl">API Erişimi</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>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* REST API Keys Section */}
|
{kilitli && (
|
||||||
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-xs space-y-4">
|
<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 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">
|
<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>
|
</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>
|
{durum && (
|
||||||
</div>
|
<span className="text-[11px] text-slate-500">
|
||||||
|
{durum.anahtarlar.length}{limit != null ? ` / ${limit}` : ''} anahtar
|
||||||
<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>
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,147 +1,156 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { useApp } from '../../context/AppContext';
|
import { useApp } from '../../context/AppContext';
|
||||||
import { FolderKanban, FileText, Upload, Plus, ShieldCheck, AlertCircle, CheckCircle2, Download, Trash2, Tag, Calendar } from 'lucide-react';
|
import { listDocuments, uploadDocument, deleteDocument, downloadDocument, formatBayt } from '../../services/account';
|
||||||
import confetti from 'canvas-confetti';
|
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 = () => {
|
export const DocumentVault = () => {
|
||||||
const { vaultDocuments, setVaultDocuments, userProfile } = useApp();
|
const { authUser } = useApp();
|
||||||
const [docName, setDocName] = useState('');
|
const [durum, setDurum] = useState(null);
|
||||||
const [category, setCategory] = useState('Kalite Belgeleri');
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [hata, setHata] = useState(null);
|
||||||
|
const [kategori, setKategori] = useState(KATEGORILER[0]);
|
||||||
|
const dosyaRef = useRef(null);
|
||||||
|
|
||||||
const handleAddDocument = (e) => {
|
const yukle = () => {
|
||||||
e.preventDefault();
|
if (!authUser) return;
|
||||||
if (!docName.trim()) return;
|
listDocuments().then(setDurum).catch((e) => setHata(e.message));
|
||||||
|
};
|
||||||
|
useEffect(yukle, [authUser]);
|
||||||
|
|
||||||
const newDoc = {
|
const dosyaSecildi = async (e) => {
|
||||||
id: 'doc-' + Date.now(),
|
const f = e.target.files?.[0];
|
||||||
name: docName.endsWith('.pdf') ? docName : docName + '.pdf',
|
if (!f) return;
|
||||||
category,
|
setBusy(true); setHata(null);
|
||||||
size: (Math.random() * 2 + 0.5).toFixed(1) + ' MB',
|
try {
|
||||||
updatedAt: new Date().toISOString().split('T')[0],
|
setDurum(await uploadDocument(f, kategori));
|
||||||
expiresAt: '2027-12-31',
|
} catch (err) {
|
||||||
status: 'Geçerli'
|
setHata(err.message);
|
||||||
};
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
setVaultDocuments(prev => [newDoc, ...prev]);
|
if (dosyaRef.current) dosyaRef.current.value = '';
|
||||||
setDocName('');
|
}
|
||||||
confetti({ particleCount: 60 });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteDoc = (id) => {
|
const sil = async (id) => {
|
||||||
setVaultDocuments(prev => prev.filter(d => d.id !== 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 (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
<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">
|
||||||
{/* 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="flex items-center gap-3">
|
<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" />
|
<FolderKanban className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<h2 className="font-extrabold text-xl">Belge Kasası</h2>
|
||||||
<h2 className="font-extrabold text-xl">Doküman ve Teklif Deposu</h2>
|
<p className="text-xs text-teal-200 mt-0.5">
|
||||||
<span className="bg-emerald-500 text-slate-950 text-[10px] font-extrabold px-2.5 py-0.5 rounded-full uppercase tracking-wider">
|
İhale dosyalarınıza her cihazdan erişin — belgeler sunucuda saklanır, tarayıcınızda değil.
|
||||||
Resmi Evrak Kütüphanesi
|
</p>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add Document Form */}
|
{/* Kullanım */}
|
||||||
<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">
|
{durum && (
|
||||||
<div className="flex-1 space-y-1.5 w-full">
|
<div className="bg-white rounded-2xl border border-slate-200 shadow-xs p-5 space-y-2">
|
||||||
<label className="block text-xs font-bold text-slate-800 uppercase tracking-wider">Yeni Doküman Adı</label>
|
<div className="flex items-center justify-between text-xs">
|
||||||
<input
|
<span className="font-bold text-slate-800 flex items-center gap-1.5">
|
||||||
type="text"
|
<HardDrive className="w-3.5 h-3.5 text-teal-600" /> Kullanım
|
||||||
required
|
</span>
|
||||||
value={docName}
|
<span className="text-slate-600 font-semibold">
|
||||||
onChange={(e) => setDocName(e.target.value)}
|
{k.adet}{h?.adet != null ? ` / ${h.adet}` : ''} belge · {formatBayt(k.bayt)}
|
||||||
placeholder="Örn: 2026 ISO 9001 Kalite Belgesi.pdf"
|
{h?.bayt != null && ` / ${formatBayt(h.bayt)}`}
|
||||||
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"
|
</span>
|
||||||
/>
|
</div>
|
||||||
</div>
|
{h?.bayt != null && (
|
||||||
|
<div className="w-full bg-slate-100 rounded-full h-2 overflow-hidden">
|
||||||
<div className="w-full md:w-64 space-y-1.5">
|
<div className={`h-2 rounded-full transition-all ${doluluk > 90 ? 'bg-red-500' : doluluk > 70 ? 'bg-amber-500' : 'bg-teal-600'}`}
|
||||||
<label className="block text-xs font-bold text-slate-800 uppercase tracking-wider">Kategori</label>
|
style={{ width: `${doluluk}%` }} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="border-t border-slate-100 pt-3 flex items-center justify-between text-[11px] text-slate-500 font-medium">
|
{/* Yükleme */}
|
||||||
<span className="flex items-center gap-1 text-slate-600">
|
<div className="bg-white rounded-2xl border-2 border-dashed border-teal-300 p-6 text-center space-y-3">
|
||||||
<Calendar className="w-3.5 h-3.5 text-slate-400" /> Son Geçerlilik: <strong>{doc.expiresAt}</strong>
|
<div className="w-12 h-12 rounded-2xl bg-teal-50 text-teal-600 flex items-center justify-center mx-auto">
|
||||||
</span>
|
<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">
|
{hata && (
|
||||||
<button
|
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex gap-3">
|
||||||
onClick={() => alert(`${doc.name} başarıyla indirildi.`)}
|
<AlertTriangle className="w-4 h-4 text-red-600 shrink-0 mt-0.5" />
|
||||||
className="p-1.5 text-slate-600 hover:text-blue-600 rounded-lg hover:bg-slate-100 transition-colors"
|
<p className="text-xs text-red-800 leading-relaxed">{hata}</p>
|
||||||
title="İndir"
|
</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" />
|
<Download className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onClick={() => sil(d.id)} disabled={busy} className="text-slate-400 hover:text-red-500 p-1" title="Sil">
|
||||||
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"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { createContext, useContext, useState, useEffect } from 'react';
|
|||||||
import { parseWebsiteCapabilitiesDynamically } from '../services/webScraperService';
|
import { parseWebsiteCapabilitiesDynamically } from '../services/webScraperService';
|
||||||
import { fetchLiveTenders, fetchTenderDetail, mergeUserState } from '../services/tenderApi';
|
import { fetchLiveTenders, fetchTenderDetail, mergeUserState } from '../services/tenderApi';
|
||||||
import { restoreSession, clearSession, getStoredUser } from '../services/auth';
|
import { restoreSession, clearSession, getStoredUser } from '../services/auth';
|
||||||
|
import { getAllUserData, setUserData, getPlan } from '../services/account';
|
||||||
|
|
||||||
const AppContext = createContext();
|
const AppContext = createContext();
|
||||||
|
|
||||||
@@ -43,6 +44,39 @@ export const AppProvider = ({ children }) => {
|
|||||||
.finally(() => setAuthReady(true));
|
.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 handleLogin = (user) => setAuthUser(user);
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
clearSession();
|
clearSession();
|
||||||
@@ -109,7 +143,8 @@ export const AppProvider = ({ children }) => {
|
|||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('ihaleist_rakip_izleme', JSON.stringify(competitorWatchlist));
|
localStorage.setItem('ihaleist_rakip_izleme', JSON.stringify(competitorWatchlist));
|
||||||
}, [competitorWatchlist]);
|
if (veriSenkron) sunucuyaYaz('rakip_izleme', competitorWatchlist);
|
||||||
|
}, [competitorWatchlist, veriSenkron]);
|
||||||
|
|
||||||
// --- Favori kurumlar (localStorage, gerçek) ---
|
// --- Favori kurumlar (localStorage, gerçek) ---
|
||||||
const [favoriteKurumlar, setFavoriteKurumlar] = useState(() => {
|
const [favoriteKurumlar, setFavoriteKurumlar] = useState(() => {
|
||||||
@@ -117,7 +152,8 @@ export const AppProvider = ({ children }) => {
|
|||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('ihaleist_favori_kurumlar', JSON.stringify(favoriteKurumlar));
|
localStorage.setItem('ihaleist_favori_kurumlar', JSON.stringify(favoriteKurumlar));
|
||||||
}, [favoriteKurumlar]);
|
if (veriSenkron) sunucuyaYaz('favori_kurumlar', favoriteKurumlar);
|
||||||
|
}, [favoriteKurumlar, veriSenkron]);
|
||||||
const toggleFavoriteKurum = (kurum) => {
|
const toggleFavoriteKurum = (kurum) => {
|
||||||
const ad = (kurum || '').trim();
|
const ad = (kurum || '').trim();
|
||||||
if (!ad) return;
|
if (!ad) return;
|
||||||
@@ -136,7 +172,8 @@ export const AppProvider = ({ children }) => {
|
|||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('ihaleist_ekip', JSON.stringify(teamMembers));
|
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 addTeamMember = (m) => setTeamMembers((prev) => [...prev, { id: 'tm-' + Date.now(), ...m }]);
|
||||||
const removeTeamMember = (id) => setTeamMembers((prev) => prev.filter((m) => m.id !== id));
|
const removeTeamMember = (id) => setTeamMembers((prev) => prev.filter((m) => m.id !== id));
|
||||||
|
|
||||||
@@ -162,7 +199,8 @@ export const AppProvider = ({ children }) => {
|
|||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('ihaleist_reminder', JSON.stringify(reminderSettings));
|
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)
|
// Şu an sessiz saat aralığında mıyız (gece yarısını geçen aralıkları da destekler)
|
||||||
const isQuietNow = () => {
|
const isQuietNow = () => {
|
||||||
@@ -321,7 +359,8 @@ export const AppProvider = ({ children }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('ihaleist_saved_filters', JSON.stringify(savedFilters));
|
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
|
// (Sahte "online kullanıcı" rastgele sayacı kaldırıldı — canlı istatistikler
|
||||||
// gerçek /api/stats verisinden gelir.)
|
// gerçek /api/stats verisinden gelir.)
|
||||||
@@ -447,7 +486,12 @@ export const AppProvider = ({ children }) => {
|
|||||||
removeTeamMember,
|
removeTeamMember,
|
||||||
toggleTrackTender,
|
toggleTrackTender,
|
||||||
updateKanbanStatus,
|
updateKanbanStatus,
|
||||||
clearAllFilters
|
clearAllFilters,
|
||||||
|
plan,
|
||||||
|
planYenile,
|
||||||
|
kotaUyarisi,
|
||||||
|
setKotaUyarisi,
|
||||||
|
veriSenkron
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</AppContext.Provider>
|
</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