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
+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 }
];