feat(auth): Google ile Giriş + landing/login + çalışan çıkış + admin rolü
- server/auth.mjs: ID token'ı oauth2.googleapis.com/tokeninfo ile doğrula (ek bağımlılık yok), HMAC imzalı app token, ADMIN_EMAILS ile admin - POST /api/auth/google, GET /api/auth/google/config, GET /api/auth/me - LoginPage: GIS butonlu landing/giriş; giriş yapılmadan uygulama açılmaz - Header çıkış butonu artık çalışıyor; kullanıcı adı/avatar Google'dan - AI Model Ayarları sekmesi sadece admin'e görünür - Env: GOOGLE_CLIENT_ID + ADMIN_EMAILS (Coolify'dan)
This commit is contained in:
+116
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* Google ile Giriş — backend doğrulama (ek bağımlılık yok).
|
||||||
|
* GIS ID-token akışı: frontend Google'dan ID token alır, buraya gönderir;
|
||||||
|
* biz oauth2.googleapis.com/tokeninfo ile doğrular, aud=GOOGLE_CLIENT_ID +
|
||||||
|
* email_verified kontrol eder, HMAC imzalı kısa bir uygulama token'ı veririz.
|
||||||
|
* Admin: ADMIN_EMAILS listesindeki e-postalar.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import https from 'node:https';
|
||||||
|
|
||||||
|
const CLIENT_ID = () => process.env.GOOGLE_CLIENT_ID || null;
|
||||||
|
const AUTH_SECRET = () =>
|
||||||
|
process.env.AUTH_SECRET || process.env.GOOGLE_CLIENT_ID || 'ihaleist-dev-secret';
|
||||||
|
const TOKEN_TTL_SEC = 30 * 24 * 60 * 60; // 30 gün
|
||||||
|
|
||||||
|
export function adminEmails() {
|
||||||
|
return (process.env.ADMIN_EMAILS || '')
|
||||||
|
.split(',')
|
||||||
|
.map((e) => e.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
export function isAdminEmail(email) {
|
||||||
|
return adminEmails().includes(String(email || '').toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Uygulama token'ı (HMAC imzalı, kompakt) ---
|
||||||
|
function b64url(buf) {
|
||||||
|
return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
function b64urlJson(obj) {
|
||||||
|
return b64url(JSON.stringify(obj));
|
||||||
|
}
|
||||||
|
export function signToken(payload) {
|
||||||
|
const body = { ...payload, exp: Math.floor(Date.now() / 1000) + TOKEN_TTL_SEC };
|
||||||
|
const data = b64urlJson(body);
|
||||||
|
const sig = b64url(crypto.createHmac('sha256', AUTH_SECRET()).update(data).digest());
|
||||||
|
return `${data}.${sig}`;
|
||||||
|
}
|
||||||
|
export function verifyToken(token) {
|
||||||
|
if (!token || typeof token !== 'string' || !token.includes('.')) return null;
|
||||||
|
const [data, sig] = token.split('.');
|
||||||
|
const expected = b64url(crypto.createHmac('sha256', AUTH_SECRET()).update(data).digest());
|
||||||
|
if (
|
||||||
|
sig.length !== expected.length ||
|
||||||
|
!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(Buffer.from(data.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString());
|
||||||
|
if (body.exp && body.exp < Math.floor(Date.now() / 1000)) return null;
|
||||||
|
return body;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Google ID token doğrulama (tokeninfo) ---
|
||||||
|
function fetchJson(url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
https
|
||||||
|
.get(url, { timeout: 15000 }, (res) => {
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', (c) => chunks.push(c));
|
||||||
|
res.on('end', () => {
|
||||||
|
const text = Buffer.concat(chunks).toString('utf8');
|
||||||
|
if (res.statusCode !== 200) return reject(new Error(`tokeninfo HTTP ${res.statusCode}`));
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(text));
|
||||||
|
} catch {
|
||||||
|
reject(new Error('tokeninfo geçersiz JSON'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.on('timeout', function () {
|
||||||
|
this.destroy(new Error('tokeninfo zaman aşımı'));
|
||||||
|
})
|
||||||
|
.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginWithGoogle(credential) {
|
||||||
|
const clientId = CLIENT_ID();
|
||||||
|
if (!clientId) throw new Error('Google girişi kapalı (GOOGLE_CLIENT_ID yok)');
|
||||||
|
if (!credential) throw new Error('credential yok');
|
||||||
|
const p = await fetchJson(
|
||||||
|
`https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(credential)}`,
|
||||||
|
);
|
||||||
|
if (p.aud !== clientId) throw new Error('aud eşleşmiyor');
|
||||||
|
if (p.email_verified !== 'true' && p.email_verified !== true) throw new Error('e-posta doğrulanmamış');
|
||||||
|
const email = String(p.email).toLowerCase();
|
||||||
|
const isAdmin = isAdminEmail(email);
|
||||||
|
const user = {
|
||||||
|
email,
|
||||||
|
name: p.name || p.given_name || email.split('@')[0],
|
||||||
|
picture: p.picture || null,
|
||||||
|
isAdmin,
|
||||||
|
roles: isAdmin ? ['USER', 'ADMIN'] : ['USER'],
|
||||||
|
};
|
||||||
|
const token = signToken({ email: user.email, name: user.name, isAdmin });
|
||||||
|
return { token, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function userFromAuthHeader(authHeader) {
|
||||||
|
const m = /^Bearer\s+(.+)$/i.exec(authHeader || '');
|
||||||
|
if (!m) return null;
|
||||||
|
const claims = verifyToken(m[1]);
|
||||||
|
if (!claims) return null;
|
||||||
|
return {
|
||||||
|
email: claims.email,
|
||||||
|
name: claims.name,
|
||||||
|
isAdmin: Boolean(claims.isAdmin),
|
||||||
|
roles: claims.isAdmin ? ['USER', 'ADMIN'] : ['USER'],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import fs from 'node:fs';
|
|||||||
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';
|
||||||
|
|
||||||
// ilan.gov.tr sunucusu ara sertifikayı (GeoTrust TLS RSA CA G1) göndermiyor;
|
// ilan.gov.tr sunucusu ara sertifikayı (GeoTrust TLS RSA CA G1) göndermiyor;
|
||||||
// zinciri tamamlamak için PEM'i yerelden ekliyoruz.
|
// zinciri tamamlamak için PEM'i yerelden ekliyoruz.
|
||||||
@@ -295,9 +296,46 @@ function json(res, code, body) {
|
|||||||
res.end(JSON.stringify(body));
|
res.end(JSON.stringify(body));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readBody(req, limit = 100 * 1024) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let data = '';
|
||||||
|
req.on('data', (c) => {
|
||||||
|
data += c;
|
||||||
|
if (data.length > limit) reject(new Error('gövde çok büyük'));
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
try {
|
||||||
|
resolve(data ? JSON.parse(data) : {});
|
||||||
|
} catch {
|
||||||
|
reject(new Error('geçersiz JSON gövdesi'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const server = http.createServer(async (req, res) => {
|
const server = http.createServer(async (req, res) => {
|
||||||
const url = new URL(req.url, `http://localhost:${PORT}`);
|
const url = new URL(req.url, `http://localhost:${PORT}`);
|
||||||
try {
|
try {
|
||||||
|
// --- Google ile Giriş ---
|
||||||
|
if (url.pathname === '/api/auth/google/config') {
|
||||||
|
return json(res, 200, { clientId: process.env.GOOGLE_CLIENT_ID || null });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/auth/google' && req.method === 'POST') {
|
||||||
|
try {
|
||||||
|
const body = await readBody(req);
|
||||||
|
const { token, user } = await loginWithGoogle(body.credential);
|
||||||
|
return json(res, 200, { success: true, token, user });
|
||||||
|
} catch (e) {
|
||||||
|
return json(res, 401, { success: false, error: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/auth/me') {
|
||||||
|
const user = userFromAuthHeader(req.headers['authorization']);
|
||||||
|
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
|
||||||
|
return json(res, 200, { success: true, user });
|
||||||
|
}
|
||||||
|
|
||||||
if (url.pathname === '/api/tenders') {
|
if (url.pathname === '/api/tenders') {
|
||||||
const force = url.searchParams.get('refresh') === '1';
|
const force = url.searchParams.get('refresh') === '1';
|
||||||
const c = await getTenders(force);
|
const c = await getTenders(force);
|
||||||
|
|||||||
+23
-4
@@ -26,9 +26,10 @@ import { AnalyticsDashboard } from './components/tools/AnalyticsDashboard';
|
|||||||
import { ApiWebhooks } from './components/tools/ApiWebhooks';
|
import { ApiWebhooks } from './components/tools/ApiWebhooks';
|
||||||
import { PwaSettings } from './components/tools/PwaSettings';
|
import { PwaSettings } from './components/tools/PwaSettings';
|
||||||
import { AiModelSettings } from './components/tools/AiModelSettings';
|
import { AiModelSettings } from './components/tools/AiModelSettings';
|
||||||
|
import { LoginPage } from './components/LoginPage';
|
||||||
|
|
||||||
const MainContent = () => {
|
const MainContent = () => {
|
||||||
const { activeTab } = useApp();
|
const { activeTab, isAdmin } = useApp();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="max-w-[1600px] mx-auto px-4 pb-12">
|
<main className="max-w-[1600px] mx-auto px-4 pb-12">
|
||||||
@@ -58,7 +59,7 @@ const MainContent = () => {
|
|||||||
{activeTab === 'analitik_dashboard' && <AnalyticsDashboard />}
|
{activeTab === 'analitik_dashboard' && <AnalyticsDashboard />}
|
||||||
{activeTab === 'api_webhooks' && <ApiWebhooks />}
|
{activeTab === 'api_webhooks' && <ApiWebhooks />}
|
||||||
{activeTab === 'pwa_settings' && <PwaSettings />}
|
{activeTab === 'pwa_settings' && <PwaSettings />}
|
||||||
{activeTab === 'ai_ayarlari' && <AiModelSettings />}
|
{activeTab === 'ai_ayarlari' && isAdmin && <AiModelSettings />}
|
||||||
|
|
||||||
{/* Global Modals */}
|
{/* Global Modals */}
|
||||||
<DetailedSearchModal />
|
<DetailedSearchModal />
|
||||||
@@ -69,15 +70,33 @@ const MainContent = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function App() {
|
const Gate = () => {
|
||||||
|
const { authReady, isAuthenticated, handleLogin } = useApp();
|
||||||
|
|
||||||
|
if (!authReady) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#0f172a] flex items-center justify-center text-white/70">
|
||||||
|
Yükleniyor…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <LoginPage onLogin={handleLogin} />;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<AppProvider>
|
|
||||||
<div className="min-h-screen bg-[#f3f5f8] text-slate-800 flex flex-col font-['Inter',sans-serif]">
|
<div className="min-h-screen bg-[#f3f5f8] text-slate-800 flex flex-col font-['Inter',sans-serif]">
|
||||||
<Header />
|
<Header />
|
||||||
<LiveStatsBar />
|
<LiveStatsBar />
|
||||||
<TopNavigation />
|
<TopNavigation />
|
||||||
<MainContent />
|
<MainContent />
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<AppProvider>
|
||||||
|
<Gate />
|
||||||
</AppProvider>
|
</AppProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ export const Header = () => {
|
|||||||
setActiveTab,
|
setActiveTab,
|
||||||
clearAllFilters,
|
clearAllFilters,
|
||||||
isRefreshingLive,
|
isRefreshingLive,
|
||||||
refreshLiveEKAPData
|
refreshLiveEKAPData,
|
||||||
|
authUser,
|
||||||
|
isAdmin,
|
||||||
|
handleLogout
|
||||||
} = useApp();
|
} = useApp();
|
||||||
|
|
||||||
const [showNotifications, setShowNotifications] = useState(false);
|
const [showNotifications, setShowNotifications] = useState(false);
|
||||||
@@ -160,18 +163,25 @@ export const Header = () => {
|
|||||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||||
className="bg-slate-700 hover:bg-slate-600 text-white px-3 py-1.5 rounded-lg text-xs font-semibold flex items-center gap-2 transition-colors border border-slate-600"
|
className="bg-slate-700 hover:bg-slate-600 text-white px-3 py-1.5 rounded-lg text-xs font-semibold flex items-center gap-2 transition-colors border border-slate-600"
|
||||||
>
|
>
|
||||||
|
{authUser?.picture ? (
|
||||||
|
<img src={authUser.picture} alt="" className="w-6 h-6 rounded-full object-cover" referrerPolicy="no-referrer" />
|
||||||
|
) : (
|
||||||
<div className="w-6 h-6 rounded-full bg-blue-600 flex items-center justify-center text-white font-bold text-xs">
|
<div className="w-6 h-6 rounded-full bg-blue-600 flex items-center justify-center text-white font-bold text-xs">
|
||||||
{userProfile.username.charAt(0).toUpperCase()}
|
{(authUser?.name || userProfile.username).charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<span>{userProfile.username}</span>
|
)}
|
||||||
|
<span>{(authUser?.name || userProfile.username).split(' ')[0]}</span>
|
||||||
<ChevronDown className="w-3.5 h-3.5 text-slate-400" />
|
<ChevronDown className="w-3.5 h-3.5 text-slate-400" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showUserMenu && (
|
{showUserMenu && (
|
||||||
<div className="absolute right-0 mt-2 w-60 bg-white text-slate-800 rounded-xl shadow-xl border border-slate-200 py-2 z-50 animate-fade-in">
|
<div className="absolute right-0 mt-2 w-60 bg-white text-slate-800 rounded-xl shadow-xl border border-slate-200 py-2 z-50 animate-fade-in">
|
||||||
<div className="px-4 py-2 border-b border-slate-100">
|
<div className="px-4 py-2 border-b border-slate-100">
|
||||||
<p className="font-bold text-sm text-slate-900">{userProfile.name}</p>
|
<p className="font-bold text-sm text-slate-900 flex items-center gap-1.5">
|
||||||
<p className="text-xs text-slate-500 truncate">{userProfile.company}</p>
|
{authUser?.name || userProfile.name}
|
||||||
|
{isAdmin && <span className="text-[9px] font-black px-1.5 py-0.5 rounded bg-indigo-600 text-white">ADMIN</span>}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 truncate">{authUser?.email || userProfile.company}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="py-1 text-xs">
|
<div className="py-1 text-xs">
|
||||||
<a href="#profile" className="px-4 py-2 hover:bg-slate-50 flex items-center gap-2 text-slate-700">
|
<a href="#profile" className="px-4 py-2 hover:bg-slate-50 flex items-center gap-2 text-slate-700">
|
||||||
@@ -185,7 +195,10 @@ export const Header = () => {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-slate-100 pt-1 text-xs">
|
<div className="border-t border-slate-100 pt-1 text-xs">
|
||||||
<button className="w-full text-left px-4 py-2 text-red-600 hover:bg-red-50 flex items-center gap-2">
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="w-full text-left px-4 py-2 text-red-600 hover:bg-red-50 flex items-center gap-2"
|
||||||
|
>
|
||||||
<LogOut className="w-4 h-4" /> Çıkış Yap
|
<LogOut className="w-4 h-4" /> Çıkış Yap
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { getGoogleConfig, loginWithGoogle } from '../services/auth';
|
||||||
|
import { ShieldCheck, TrendingUp, Kanban, Radar, FileText, Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
|
const FEATURES = [
|
||||||
|
{ icon: Sparkles, text: 'Canlı ilan.gov.tr ihale akışı' },
|
||||||
|
{ icon: FileText, text: 'Doğrudan temin (22/d) günlük takip' },
|
||||||
|
{ icon: Kanban, text: 'Kanban ile ihale süreç yönetimi' },
|
||||||
|
{ icon: TrendingUp, text: 'Fiyat & piyasa analizi' },
|
||||||
|
{ icon: Radar, text: 'Kazanan firma & rakip analizi' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const LoginPage = ({ onLogin }) => {
|
||||||
|
const btnRef = useRef(null);
|
||||||
|
const [err, setErr] = useState(null);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const [noConfig, setNoConfig] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const { clientId } = await getGoogleConfig();
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!clientId) { setNoConfig(true); return; }
|
||||||
|
|
||||||
|
const init = () => {
|
||||||
|
if (!window.google?.accounts?.id || !btnRef.current) return;
|
||||||
|
window.google.accounts.id.initialize({
|
||||||
|
client_id: clientId,
|
||||||
|
callback: async (resp) => {
|
||||||
|
try {
|
||||||
|
const user = await loginWithGoogle(resp.credential);
|
||||||
|
onLogin(user);
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message || 'Giriş başarısız');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
window.google.accounts.id.renderButton(btnRef.current, {
|
||||||
|
theme: 'outline', size: 'large', text: 'continue_with', locale: 'tr', width: 320,
|
||||||
|
});
|
||||||
|
setReady(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (window.google?.accounts?.id) { init(); return; }
|
||||||
|
let s = document.getElementById('gsi-script');
|
||||||
|
if (!s) {
|
||||||
|
s = document.createElement('script');
|
||||||
|
s.id = 'gsi-script';
|
||||||
|
s.src = 'https://accounts.google.com/gsi/client';
|
||||||
|
s.async = true;
|
||||||
|
s.defer = true;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
s.addEventListener('load', init);
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [onLogin]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#0f172a] text-white flex flex-col lg:flex-row">
|
||||||
|
{/* Sol: marka + özellikler */}
|
||||||
|
<div className="flex-1 flex flex-col justify-center px-8 lg:px-16 py-12 bg-linear-to-br from-slate-900 via-blue-950 to-indigo-950">
|
||||||
|
<div className="flex items-center gap-2 mb-8">
|
||||||
|
<div className="w-11 h-11 rounded-2xl bg-blue-600 flex items-center justify-center font-black text-xl">İ</div>
|
||||||
|
<span className="text-2xl font-black">İhale<span className="text-blue-400">ist</span></span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl lg:text-4xl font-black leading-tight mb-4">
|
||||||
|
İhaleleri takip etmeyin,<br /><span className="text-blue-400">yönetin ve kazanın.</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-300 max-w-md mb-8">
|
||||||
|
Canlı kamu ihale verisi, doğrudan temin takibi ve yapay zeka destekli analiz tek platformda.
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-3 max-w-md">
|
||||||
|
{FEATURES.map((f, i) => (
|
||||||
|
<li key={i} className="flex items-center gap-3 text-slate-200">
|
||||||
|
<f.icon className="w-5 h-5 text-blue-400 shrink-0" />
|
||||||
|
<span>{f.text}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sağ: giriş kartı */}
|
||||||
|
<div className="flex-1 flex items-center justify-center px-8 py-12 bg-white text-slate-900">
|
||||||
|
<div className="w-full max-w-sm text-center">
|
||||||
|
<h2 className="text-2xl font-black mb-1">Giriş Yap</h2>
|
||||||
|
<p className="text-sm text-slate-500 mb-8">Google hesabınla saniyeler içinde başla.</p>
|
||||||
|
|
||||||
|
{noConfig ? (
|
||||||
|
<div className="text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||||
|
Google girişi henüz yapılandırılmadı (sunucuda GOOGLE_CLIENT_ID eksik).
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div ref={btnRef} className="min-h-[44px]" />
|
||||||
|
{!ready && <p className="text-xs text-slate-400">Google giriş yükleniyor…</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{err && <p className="mt-4 text-sm text-red-600">{err}</p>}
|
||||||
|
|
||||||
|
<p className="mt-8 text-[11px] text-slate-400 flex items-center justify-center gap-1">
|
||||||
|
<ShieldCheck className="w-3.5 h-3.5" /> Girişin Google tarafından güvenle doğrulanır.
|
||||||
|
</p>
|
||||||
|
<p className="mt-4 text-[11px] text-slate-400">
|
||||||
|
Giriş yaparak <a href="/kullanim-kosullari" className="underline">Kullanım Şartları</a> ve{' '}
|
||||||
|
<a href="/gizlilik" className="underline">Gizlilik Politikası</a>'nı kabul edersin.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,7 +3,7 @@ import { useApp } from '../context/AppContext';
|
|||||||
import { Newspaper, Trophy, CalendarDays, FileCheck2, Calculator, TrendingUp, LayoutGrid, Kanban, Radar, FolderKanban, BarChart3, Webhook, Smartphone, Cpu } from 'lucide-react';
|
import { Newspaper, Trophy, CalendarDays, FileCheck2, Calculator, TrendingUp, LayoutGrid, Kanban, Radar, FolderKanban, BarChart3, Webhook, Smartphone, Cpu } from 'lucide-react';
|
||||||
|
|
||||||
export const TopNavigation = () => {
|
export const TopNavigation = () => {
|
||||||
const { activeTab, setActiveTab } = useApp();
|
const { activeTab, setActiveTab, isAdmin } = useApp();
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ id: 'list', label: 'Ana İhale Listesi', icon: LayoutGrid },
|
{ id: 'list', label: 'Ana İhale Listesi', icon: LayoutGrid },
|
||||||
@@ -18,7 +18,8 @@ export const TopNavigation = () => {
|
|||||||
{ id: 'dokuman_deposu', label: 'Doküman Deposu', icon: FolderKanban },
|
{ id: 'dokuman_deposu', label: 'Doküman Deposu', icon: FolderKanban },
|
||||||
{ id: 'analitik_dashboard', label: 'Analitik Dashboard', icon: BarChart3 },
|
{ id: 'analitik_dashboard', label: 'Analitik Dashboard', icon: BarChart3 },
|
||||||
{ id: 'api_webhooks', label: 'API & Webhooks', icon: Webhook },
|
{ id: 'api_webhooks', label: 'API & Webhooks', icon: Webhook },
|
||||||
{ id: 'ai_ayarlari', label: 'AI Model Ayarları', icon: Cpu, badge: 'Admin' },
|
// Sadece admin: AI Model Ayarları
|
||||||
|
...(isAdmin ? [{ id: 'ai_ayarlari', label: 'AI Model Ayarları', icon: Cpu, badge: 'Admin' }] : []),
|
||||||
{ id: 'pwa_settings', label: 'Mobil PWA', icon: Smartphone }
|
{ id: 'pwa_settings', label: 'Mobil PWA', icon: Smartphone }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { createContext, useContext, useState, useEffect } from 'react';
|
|||||||
import { MOCK_TENDERS, MOCK_LIVE_STATS, MOCK_COMPANY_STATS } from '../data/mockTenders';
|
import { MOCK_TENDERS, MOCK_LIVE_STATS, MOCK_COMPANY_STATS } from '../data/mockTenders';
|
||||||
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';
|
||||||
|
|
||||||
const AppContext = createContext();
|
const AppContext = createContext();
|
||||||
|
|
||||||
@@ -34,6 +35,24 @@ export const AppProvider = ({ children }) => {
|
|||||||
const [liveDataError, setLiveDataError] = useState(null);
|
const [liveDataError, setLiveDataError] = useState(null);
|
||||||
const [liveDataSource, setLiveDataSource] = useState(null);
|
const [liveDataSource, setLiveDataSource] = useState(null);
|
||||||
|
|
||||||
|
// --- Kimlik doğrulama (Google ile Giriş) ---
|
||||||
|
const [authUser, setAuthUser] = useState(() => getStoredUser());
|
||||||
|
const [authReady, setAuthReady] = useState(false);
|
||||||
|
const isAuthenticated = Boolean(authUser);
|
||||||
|
const isAdmin = Boolean(authUser?.isAdmin);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
restoreSession()
|
||||||
|
.then((u) => setAuthUser(u))
|
||||||
|
.finally(() => setAuthReady(true));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLogin = (user) => setAuthUser(user);
|
||||||
|
const handleLogout = () => {
|
||||||
|
clearSession();
|
||||||
|
setAuthUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
// Canlı ihale verisini (ilan.gov.tr) çeker; kullanıcının kanban/takip durumunu korur.
|
// Canlı ihale verisini (ilan.gov.tr) çeker; kullanıcının kanban/takip durumunu korur.
|
||||||
const refreshLiveEKAPData = async ({ refresh = true } = {}) => {
|
const refreshLiveEKAPData = async ({ refresh = true } = {}) => {
|
||||||
setIsRefreshingLive(true);
|
setIsRefreshingLive(true);
|
||||||
@@ -338,6 +357,12 @@ export const AppProvider = ({ children }) => {
|
|||||||
refreshLiveEKAPData,
|
refreshLiveEKAPData,
|
||||||
liveDataError,
|
liveDataError,
|
||||||
liveDataSource,
|
liveDataSource,
|
||||||
|
authUser,
|
||||||
|
authReady,
|
||||||
|
isAuthenticated,
|
||||||
|
isAdmin,
|
||||||
|
handleLogin,
|
||||||
|
handleLogout,
|
||||||
liveStats,
|
liveStats,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Google ile Giriş — frontend istemcisi (GIS ID-token akışı).
|
||||||
|
* Token localStorage'da 'ihaleist_token', kullanıcı 'ihaleist_user' altında saklanır.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'ihaleist_token';
|
||||||
|
const USER_KEY = 'ihaleist_user';
|
||||||
|
|
||||||
|
export function getToken() {
|
||||||
|
try { return localStorage.getItem(TOKEN_KEY); } catch { return null; }
|
||||||
|
}
|
||||||
|
export function getStoredUser() {
|
||||||
|
try { return JSON.parse(localStorage.getItem(USER_KEY) || 'null'); } catch { return null; }
|
||||||
|
}
|
||||||
|
export function setSession(token, user) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||||
|
} catch { /* yoksay */ }
|
||||||
|
}
|
||||||
|
export function clearSession() {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem(USER_KEY);
|
||||||
|
} catch { /* yoksay */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGoogleConfig() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/google/config');
|
||||||
|
if (!res.ok) return { clientId: null };
|
||||||
|
return await res.json();
|
||||||
|
} catch {
|
||||||
|
return { clientId: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginWithGoogle(credential) {
|
||||||
|
const res = await fetch('/api/auth/google', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ credential }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || !data.success) throw new Error(data.error || 'Giriş başarısız');
|
||||||
|
setSession(data.token, data.user);
|
||||||
|
return data.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sayfa açılışında saklı token'ı doğrula (opsiyonel; başarısızsa oturum düşer)
|
||||||
|
export async function restoreSession() {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) return null;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/me', { headers: { Authorization: `Bearer ${token}` } });
|
||||||
|
if (!res.ok) { clearSession(); return null; }
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) { setSession(token, data.user); return data.user; }
|
||||||
|
clearSession();
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
// Ağ hatası: saklı kullanıcıyı koru (offline)
|
||||||
|
return getStoredUser();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user