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:
2026-08-28 00:43:08 +03:00
parent 1b28ebb603
commit 13f3e9d366
8 changed files with 409 additions and 18 deletions
+116
View File
@@ -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'],
};
}
+38
View File
@@ -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);
+27 -8
View File
@@ -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 (
<main className="max-w-[1600px] mx-auto px-4 pb-12">
@@ -58,7 +59,7 @@ const MainContent = () => {
{activeTab === 'analitik_dashboard' && <AnalyticsDashboard />}
{activeTab === 'api_webhooks' && <ApiWebhooks />}
{activeTab === 'pwa_settings' && <PwaSettings />}
{activeTab === 'ai_ayarlari' && <AiModelSettings />}
{activeTab === 'ai_ayarlari' && isAdmin && <AiModelSettings />}
{/* Global Modals */}
<DetailedSearchModal />
@@ -69,15 +70,33 @@ const MainContent = () => {
);
};
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 (
<div className="min-h-screen bg-[#f3f5f8] text-slate-800 flex flex-col font-['Inter',sans-serif]">
<Header />
<LiveStatsBar />
<TopNavigation />
<MainContent />
</div>
);
};
export default function App() {
return (
<AppProvider>
<div className="min-h-screen bg-[#f3f5f8] text-slate-800 flex flex-col font-['Inter',sans-serif]">
<Header />
<LiveStatsBar />
<TopNavigation />
<MainContent />
</div>
<Gate />
</AppProvider>
);
}
+21 -8
View File
@@ -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"
>
<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()}
</div>
<span>{userProfile.username}</span>
{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">
{(authUser?.name || userProfile.username).charAt(0).toUpperCase()}
</div>
)}
<span>{(authUser?.name || userProfile.username).split(' ')[0]}</span>
<ChevronDown className="w-3.5 h-3.5 text-slate-400" />
</button>
{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="px-4 py-2 border-b border-slate-100">
<p className="font-bold text-sm text-slate-900">{userProfile.name}</p>
<p className="text-xs text-slate-500 truncate">{userProfile.company}</p>
<p className="font-bold text-sm text-slate-900 flex items-center gap-1.5">
{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 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">
@@ -185,7 +195,10 @@ export const Header = () => {
</a>
</div>
<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
</button>
</div>
+114
View File
@@ -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 -2
View File
@@ -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 }
];
+25
View File
@@ -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,
+65
View File
@@ -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();
}
}