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:
2026-08-29 10:40:05 +03:00
parent f73c7bd6cb
commit f04401031c
10 changed files with 1008 additions and 268 deletions
+50 -1
View File
@@ -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 || '',
}); });
+98
View File
@@ -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
View File
@@ -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() });
+244
View File
@@ -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 });
}
+3
View File
@@ -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 />
+22
View File
@@ -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>
);
};
+128 -136
View File
@@ -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),
createdAt: new Date().toISOString().split('T')[0],
lastUsed: 'Henüz Kullanılmadı'
}; };
setApiKeys(prev => [newKey, ...prev]); useEffect(yukle, [authUser]);
confetti({ particleCount: 50 });
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 = () => { 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">
<div className="flex items-center justify-between border-b border-slate-100 pb-3"> <Lock className="w-4 h-4 text-amber-700 shrink-0 mt-0.5" />
<div> <p className="text-xs text-amber-900 leading-relaxed">
<h3 className="font-extrabold text-sm text-slate-900 flex items-center gap-2"> API erişimi <strong>Kurumsal</strong> planda sunulur. Mevcut planınızda API anahtarı oluşturulamaz —
<Key className="w-4 h-4 text-emerald-600" /> REST API Canlı Erişim Anahtarları Planlar sekmesinden yükseltebilirsiniz.
</h3> </p>
<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>
)}
<div className="space-y-3"> {/* Yeni anahtar — yalnızca bir kez gösterilir */}
{apiKeys.map(k => ( {yeniAnahtar && (
<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="bg-emerald-50 border border-emerald-300 rounded-xl p-4 space-y-2">
<div className="space-y-1"> <p className="text-xs font-extrabold text-emerald-900 flex items-center gap-1.5">
<span className="font-bold text-xs text-slate-900">{k.name}</span> <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"> <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"> <code className="flex-1 bg-white border border-emerald-200 rounded-lg px-3 py-2 text-[11px] font-mono break-all">
{k.key} {yeniAnahtar.anahtar}
</code> </code>
<button <button onClick={() => kopyala(yeniAnahtar.anahtar)}
onClick={() => { 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">
navigator.clipboard.writeText(k.key); {kopyalandi ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
setCopiedKey(true); {kopyalandi ? 'Kopyalandı' : 'Kopyala'}
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> </button>
</div> </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>
)}
<div className="text-[11px] text-slate-500 font-medium space-y-0.5"> <div className="bg-white rounded-2xl border border-slate-200 shadow-xs p-5 space-y-4">
<p>Oluşturulma: {k.createdAt}</p> <div className="flex items-center justify-between border-b border-slate-100 pb-3">
<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> </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> </div>
); );
}; };
+117 -108
View File
@@ -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);
if (dosyaRef.current) dosyaRef.current.value = '';
}
}; };
setVaultDocuments(prev => [newDoc, ...prev]); const sil = async (id) => {
setDocName(''); setBusy(true); setHata(null);
confetti({ particleCount: 60 }); try { setDurum(await deleteDocument(id)); }
catch (e) { setHata(e.message); }
finally { setBusy(false); }
}; };
const deleteDoc = (id) => { const k = durum?.kullanim;
setVaultDocuments(prev => prev.filter(d => d.id !== id)); 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>
</div>
</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> </span>
</div> </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> {h?.bayt != null && (
</div> <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> </div>
)}
{/* Add Document Form */} {/* Yükleme */}
<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="bg-white rounded-2xl border-2 border-dashed border-teal-300 p-6 text-center space-y-3">
<div className="flex-1 space-y-1.5 w-full"> <div className="w-12 h-12 rounded-2xl bg-teal-50 text-teal-600 flex items-center justify-center mx-auto">
<label className="block text-xs font-bold text-slate-800 uppercase tracking-wider">Yeni Doküman Adı</label> <Upload className="w-6 h-6" />
<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>
<div> <div>
<h4 className="font-bold text-xs text-slate-900 leading-snug line-clamp-2">{doc.name}</h4> <h3 className="font-bold text-sm text-slate-800">Belge Yükleyin</h3>
<p className="text-[10px] text-slate-400 mt-0.5">Boyut: {doc.size}</p> <p className="text-xs text-slate-500">İmza sirküleri, sertifikalar, vergi levhası… (en fazla 8 MB)</p>
</div> </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> </div>
<div className="border-t border-slate-100 pt-3 flex items-center justify-between text-[11px] text-slate-500 font-medium"> {hata && (
<span className="flex items-center gap-1 text-slate-600"> <div className="bg-red-50 border border-red-200 rounded-xl p-4 flex gap-3">
<Calendar className="w-3.5 h-3.5 text-slate-400" /> Son Geçerlilik: <strong>{doc.expiresAt}</strong> <AlertTriangle className="w-4 h-4 text-red-600 shrink-0 mt-0.5" />
</span> <p className="text-xs text-red-800 leading-relaxed">{hata}</p>
</div>
)}
<div className="flex items-center gap-1"> {/* Liste */}
<button {!durum ? (
onClick={() => alert(`${doc.name} başarıyla indirildi.`)} <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>
className="p-1.5 text-slate-600 hover:text-blue-600 rounded-lg hover:bg-slate-100 transition-colors" ) : durum.belgeler.length === 0 ? (
title="İndir" <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>
); );
}; };
+50 -6
View File
@@ -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>
+76
View File
@@ -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`;
}