diff --git a/server/auth.mjs b/server/auth.mjs new file mode 100644 index 0000000..12c01a7 --- /dev/null +++ b/server/auth.mjs @@ -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'], + }; +} diff --git a/server/index.js b/server/index.js index 671def1..0333b45 100644 --- a/server/index.js +++ b/server/index.js @@ -16,6 +16,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; 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; // zinciri tamamlamak için PEM'i yerelden ekliyoruz. @@ -295,9 +296,46 @@ function json(res, code, 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 url = new URL(req.url, `http://localhost:${PORT}`); 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') { const force = url.searchParams.get('refresh') === '1'; const c = await getTenders(force); diff --git a/src/App.jsx b/src/App.jsx index e913411..089c2db 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -26,9 +26,10 @@ import { AnalyticsDashboard } from './components/tools/AnalyticsDashboard'; import { ApiWebhooks } from './components/tools/ApiWebhooks'; import { PwaSettings } from './components/tools/PwaSettings'; import { AiModelSettings } from './components/tools/AiModelSettings'; +import { LoginPage } from './components/LoginPage'; const MainContent = () => { - const { activeTab } = useApp(); + const { activeTab, isAdmin } = useApp(); return (
@@ -58,7 +59,7 @@ const MainContent = () => { {activeTab === 'analitik_dashboard' && } {activeTab === 'api_webhooks' && } {activeTab === 'pwa_settings' && } - {activeTab === 'ai_ayarlari' && } + {activeTab === 'ai_ayarlari' && isAdmin && } {/* Global Modals */} @@ -69,15 +70,33 @@ const MainContent = () => { ); }; +const Gate = () => { + const { authReady, isAuthenticated, handleLogin } = useApp(); + + if (!authReady) { + return ( +
+ Yükleniyor… +
+ ); + } + if (!isAuthenticated) { + return ; + } + return ( +
+
+ + + +
+ ); +}; + export default function App() { return ( -
-
- - - -
+
); } diff --git a/src/components/Header.jsx b/src/components/Header.jsx index a851a50..a349afe 100644 --- a/src/components/Header.jsx +++ b/src/components/Header.jsx @@ -14,7 +14,10 @@ export const Header = () => { setActiveTab, clearAllFilters, isRefreshingLive, - refreshLiveEKAPData + refreshLiveEKAPData, + authUser, + isAdmin, + handleLogout } = useApp(); const [showNotifications, setShowNotifications] = useState(false); @@ -160,18 +163,25 @@ export const Header = () => { 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" > -
- {userProfile.username.charAt(0).toUpperCase()} -
- {userProfile.username} + {authUser?.picture ? ( + + ) : ( +
+ {(authUser?.name || userProfile.username).charAt(0).toUpperCase()} +
+ )} + {(authUser?.name || userProfile.username).split(' ')[0]} {showUserMenu && (
-

{userProfile.name}

-

{userProfile.company}

+

+ {authUser?.name || userProfile.name} + {isAdmin && ADMIN} +

+

{authUser?.email || userProfile.company}

-
diff --git a/src/components/LoginPage.jsx b/src/components/LoginPage.jsx new file mode 100644 index 0000000..fd20fc3 --- /dev/null +++ b/src/components/LoginPage.jsx @@ -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 ( +
+ {/* Sol: marka + özellikler */} +
+
+
İ
+ İhaleist +
+

+ İhaleleri takip etmeyin,
yönetin ve kazanın. +

+

+ Canlı kamu ihale verisi, doğrudan temin takibi ve yapay zeka destekli analiz tek platformda. +

+
    + {FEATURES.map((f, i) => ( +
  • + + {f.text} +
  • + ))} +
+
+ + {/* Sağ: giriş kartı */} +
+
+

Giriş Yap

+

Google hesabınla saniyeler içinde başla.

+ + {noConfig ? ( +
+ Google girişi henüz yapılandırılmadı (sunucuda GOOGLE_CLIENT_ID eksik). +
+ ) : ( +
+
+ {!ready &&

Google giriş yükleniyor…

} +
+ )} + + {err &&

{err}

} + +

+ Girişin Google tarafından güvenle doğrulanır. +

+

+ Giriş yaparak Kullanım Şartları ve{' '} + Gizlilik Politikası'nı kabul edersin. +

+
+
+
+ ); +}; diff --git a/src/components/TopNavigation.jsx b/src/components/TopNavigation.jsx index 16e0482..f72966d 100644 --- a/src/components/TopNavigation.jsx +++ b/src/components/TopNavigation.jsx @@ -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'; export const TopNavigation = () => { - const { activeTab, setActiveTab } = useApp(); + const { activeTab, setActiveTab, isAdmin } = useApp(); const navItems = [ { 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: 'analitik_dashboard', label: 'Analitik Dashboard', icon: BarChart3 }, { 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 } ]; diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index 2e453ed..2902691 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -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 { parseWebsiteCapabilitiesDynamically } from '../services/webScraperService'; import { fetchLiveTenders, fetchTenderDetail, mergeUserState } from '../services/tenderApi'; +import { restoreSession, clearSession, getStoredUser } from '../services/auth'; const AppContext = createContext(); @@ -34,6 +35,24 @@ export const AppProvider = ({ children }) => { const [liveDataError, setLiveDataError] = 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. const refreshLiveEKAPData = async ({ refresh = true } = {}) => { setIsRefreshingLive(true); @@ -338,6 +357,12 @@ export const AppProvider = ({ children }) => { refreshLiveEKAPData, liveDataError, liveDataSource, + authUser, + authReady, + isAuthenticated, + isAdmin, + handleLogin, + handleLogout, liveStats, searchQuery, setSearchQuery, diff --git a/src/services/auth.js b/src/services/auth.js new file mode 100644 index 0000000..d5ba3a5 --- /dev/null +++ b/src/services/auth.js @@ -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(); + } +}