fix: make Google login and session recovery resilient
This commit is contained in:
@@ -937,3 +937,10 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö
|
|||||||
- Scrollbar sonrası içerik genişlikleri 384/762/1434 piksel olarak ölçüldü. Her üçünde document scrollWidth=clientWidth; dört çalışma alanı düğmesi görünümün yatay sınırları içinde. Dar görünümde Yönetim ikinci satıra geçiyor. Kanıt qa-responsive-navigation.json.
|
- Scrollbar sonrası içerik genişlikleri 384/762/1434 piksel olarak ölçüldü. Her üçünde document scrollWidth=clientWidth; dört çalışma alanı düğmesi görünümün yatay sınırları içinde. Dar görünümde Yönetim ikinci satıra geçiyor. Kanıt qa-responsive-navigation.json.
|
||||||
- Yalnız ana liste ve menü geometrisi doğrulandı. Kök locator iç iframe düğmesine erişemediğinden dar ekran etkileşim kabulü iddia edilmez. Portföy formu, hazırlık özeti, odak sırası ve dokunmatik kullanım açık.
|
- Yalnız ana liste ve menü geometrisi doğrulandı. Kök locator iç iframe düğmesine erişemediğinden dar ekran etkileşim kabulü iddia edilmez. Portföy formu, hazırlık özeti, odak sırası ve dokunmatik kullanım açık.
|
||||||
- Üretim kodu değişmedi; otomatik test/derleme tekrarlanmadı. Eklenen QA sunucusu bu ölçümden sonra kapatıldı ve geçici deposu temizlendi.
|
- Üretim kodu değişmedi; otomatik test/derleme tekrarlanmadı. Eklenen QA sunucusu bu ölçümden sonra kapatıldı ve geçici deposu temizlendi.
|
||||||
|
|
||||||
|
### Google giriş ve oturum geri yükleme — 6 Eylül 2026
|
||||||
|
- Yapılandırma ağ/hizmet hatası artık Google girişi kurulmamış gibi sunulmaz. Giriş ekranında hata, tekrar deneme ve doğrulanıyor durumu vardır. Google betiği için 15 saniye, yapılandırma/giriş/oturum HTTP isteği ve yanıt gövdesi için 30 saniye sınırı eklendi. SDK başlatma hataları yakalanır.
|
||||||
|
- Eski/kapatılmış giriş denemesinin yanıtı oturum oluşturamaz. Yinelenen Google callback aynı anda ikinci giriş başlatmaz. Geçersiz token/kullanıcı yanıtı reddedilir; depolama yarıda başarısız olduğunda yarım oturum temizlenir.
|
||||||
|
- Oturum geri yükleme sırasında 5xx, bozuk yanıt ve zaman aşımı saklı oturumu silmez. Açık 401/403 yetki reddi oturumu temizler. Yanıt beklerken yeni hesaba geçilmişse eski yanıt yeni hesabı değiştiremez. Sunucu tarafındaki yetkilendirme geçerliliğini korur.
|
||||||
|
- 215 otomatik test geçti; yeni testler yapılandırma ayrımı, iptal, geçersiz giriş, yarım depolama, 401/403, 5xx/bozuk yanıt, hesap değişimi ve takılmış yanıt gövdesinin zaman aşımını kapsar. Üretim derlemesi geçti; büyük paket uyarısı sürüyor.
|
||||||
|
- Gerçek Google OAuth hesabıyla giriş, SDK yükleme hatasının tarayıcı kabulü ve tüm mobil giriş etkileşimleri henüz doğrulanmadı. Uygulama Google hesabı kullanıyor; bağımsız parola sıfırlama akışı bulunmuyor. Canlıya yayın yapılmadı.
|
||||||
|
|||||||
@@ -16,46 +16,46 @@ export const LoginPage = ({ onLogin }) => {
|
|||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [noConfig, setNoConfig] = useState(false);
|
const [noConfig, setNoConfig] = useState(false);
|
||||||
|
|
||||||
|
const [attempt,setAttempt]=useState(0),[signingIn,setSigningIn]=useState(false);
|
||||||
|
const loginCallback=useRef(onLogin);loginCallback.current=onLogin;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled=false,script=null,scriptTimer=null,loginPending=false;
|
||||||
(async () => {
|
const controller=new AbortController();
|
||||||
const { clientId } = await getGoogleConfig();
|
setErr(null);setReady(false);setNoConfig(false);setSigningIn(false);
|
||||||
if (cancelled) return;
|
const failed=()=>{clearTimeout(scriptTimer);if(!cancelled){setErr('Google giriş ekranı yüklenemedi. Bağlantınızı kontrol edip yeniden deneyin.');setReady(false);}script?.remove();};
|
||||||
if (!clientId) { setNoConfig(true); return; }
|
const init=()=>{
|
||||||
|
if(cancelled)return;
|
||||||
const init = () => {
|
if(!window.google?.accounts?.id||!btnRef.current){failed();return;}
|
||||||
if (!window.google?.accounts?.id || !btnRef.current) return;
|
clearTimeout(scriptTimer);
|
||||||
window.google.accounts.id.initialize({
|
try {
|
||||||
client_id: clientId,
|
window.google.accounts.id.initialize({client_id:clientId,callback:async response=>{
|
||||||
callback: async (resp) => {
|
if(cancelled||loginPending)return;
|
||||||
try {
|
loginPending=true;setSigningIn(true);setErr(null);
|
||||||
const user = await loginWithGoogle(resp.credential);
|
try{const user=await loginWithGoogle(response.credential,{signal:controller.signal});if(!cancelled)loginCallback.current(user);}
|
||||||
onLogin(user);
|
catch(error){if(!cancelled)setErr(error.message || 'Giriş başarısız');}
|
||||||
} catch (e) {
|
finally{loginPending=false;if(!cancelled)setSigningIn(false);}
|
||||||
setErr(e.message || 'Giriş başarısız');
|
}});
|
||||||
}
|
btnRef.current.replaceChildren();
|
||||||
},
|
window.google.accounts.id.renderButton(btnRef.current,{theme:'outline',size:'large',text:'continue_with',locale:'tr',width:Math.min(320,btnRef.current.parentElement.clientWidth)});
|
||||||
});
|
setReady(true);
|
||||||
window.google.accounts.id.renderButton(btnRef.current, {
|
} catch { failed(); }
|
||||||
theme: 'outline', size: 'large', text: 'continue_with', locale: 'tr', width: 320,
|
};
|
||||||
});
|
let clientId;
|
||||||
setReady(true);
|
void (async()=>{
|
||||||
};
|
try{
|
||||||
|
const config=await getGoogleConfig({signal:controller.signal});
|
||||||
if (window.google?.accounts?.id) { init(); return; }
|
if(cancelled)return;clientId=config.clientId;
|
||||||
let s = document.getElementById('gsi-script');
|
if(!clientId){setNoConfig(true);return;}
|
||||||
if (!s) {
|
if(window.google?.accounts?.id){init();return;}
|
||||||
s = document.createElement('script');
|
script=document.getElementById('gsi-script');
|
||||||
s.id = 'gsi-script';
|
if(!script){script=document.createElement('script');script.id='gsi-script';script.src='https://accounts.google.com/gsi/client';script.async=true;script.defer=true;}
|
||||||
s.src = 'https://accounts.google.com/gsi/client';
|
script.addEventListener('load',init);script.addEventListener('error',failed);
|
||||||
s.async = true;
|
scriptTimer=setTimeout(failed,15000);
|
||||||
s.defer = true;
|
if(!script.isConnected)document.head.appendChild(script);
|
||||||
document.head.appendChild(s);
|
}catch(error){if(!cancelled)setErr(error.message || 'Giriş hizmetine ulaşılamadı.');}
|
||||||
}
|
|
||||||
s.addEventListener('load', init);
|
|
||||||
})();
|
})();
|
||||||
return () => { cancelled = true; };
|
return()=>{cancelled=true;controller.abort();clearTimeout(scriptTimer);script?.removeEventListener('load',init);script?.removeEventListener('error',failed);};
|
||||||
}, [onLogin]);
|
},[attempt]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0f172a] text-white flex flex-col lg:flex-row">
|
<div className="min-h-screen bg-[#0f172a] text-white flex flex-col lg:flex-row">
|
||||||
@@ -89,16 +89,18 @@ export const LoginPage = ({ onLogin }) => {
|
|||||||
|
|
||||||
{noConfig ? (
|
{noConfig ? (
|
||||||
<div className="text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-xl p-4">
|
<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).
|
Google girişi henüz etkinleştirilmedi. Lütfen yöneticinizle görüşün.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
<div ref={btnRef} className="min-h-[44px]" />
|
<div ref={btnRef} className="min-h-[44px]" />
|
||||||
{!ready && <p className="text-xs text-slate-400">Google giriş yükleniyor…</p>}
|
{!ready && !err && <p role="status" className="text-xs text-slate-400">Google giriş yükleniyor…</p>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{err && <p className="mt-4 text-sm text-red-600">{err}</p>}
|
{signingIn&&<p role="status" className="mt-4 text-sm">Giriş doğrulanıyor…</p>}
|
||||||
|
{err && <p role="alert" className="mt-4 text-sm text-red-600">{err}</p>}
|
||||||
|
{(err||noConfig)&&<button disabled={signingIn} onClick={()=>setAttempt(n=>n+1)} className="border rounded px-4 py-2 mt-3 text-sm">Girişi yeniden dene</button>}
|
||||||
|
|
||||||
<p className="mt-8 text-[11px] text-slate-400 flex items-center justify-center gap-1">
|
<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.
|
<ShieldCheck className="w-3.5 h-3.5" /> Girişin Google tarafından güvenle doğrulanır.
|
||||||
|
|||||||
+37
-27
@@ -1,3 +1,4 @@
|
|||||||
|
import {awaitWithAbort} from './abortable.js';
|
||||||
/**
|
/**
|
||||||
* Google ile Giriş — frontend istemcisi (GIS ID-token akışı).
|
* Google ile Giriş — frontend istemcisi (GIS ID-token akışı).
|
||||||
* Token localStorage'da 'ihaleist_token', kullanıcı 'ihaleist_user' altında saklanır.
|
* Token localStorage'da 'ihaleist_token', kullanıcı 'ihaleist_user' altında saklanır.
|
||||||
@@ -25,43 +26,52 @@ export function clearSession() {
|
|||||||
} catch { /* yoksay */ }
|
} catch { /* yoksay */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getGoogleConfig() {
|
async function authRequest(url,options={}) {
|
||||||
try {
|
const {signal,...requestOptions}=options,controller=new AbortController();
|
||||||
const res = await fetch('/api/auth/google/config');
|
const cancel=()=>controller.abort(signal.reason);
|
||||||
if (!res.ok) return { clientId: null };
|
if(signal?.aborted)cancel();else signal?.addEventListener('abort',cancel,{once:true});
|
||||||
return await res.json();
|
let timeout=false;
|
||||||
} catch {
|
const timer=setTimeout(()=>{timeout=true;controller.abort();},30000);
|
||||||
return { clientId: null };
|
try{
|
||||||
}
|
controller.signal.throwIfAborted();
|
||||||
|
const res=await awaitWithAbort(fetch(url,{...requestOptions,signal:controller.signal}),controller.signal);
|
||||||
|
const data=await awaitWithAbort(res.json().catch(error=>{if(res.ok)throw error;return null;}),controller.signal);
|
||||||
|
controller.signal.throwIfAborted();
|
||||||
|
if(!res.ok){const error=new Error(data?.error || 'Giriş hizmetine ulaşılamadı. Yeniden deneyin.');error.status=res.status;throw error;}
|
||||||
|
return data;
|
||||||
|
}catch(error){if(timeout)throw new Error('Giriş hizmeti zamanında yanıt vermedi. Yeniden deneyin.');throw error;}
|
||||||
|
finally{clearTimeout(timer);signal?.removeEventListener('abort',cancel);}
|
||||||
}
|
}
|
||||||
|
export async function getGoogleConfig({signal}={}) {
|
||||||
export async function loginWithGoogle(credential) {
|
const data=await authRequest('/api/auth/google/config',{signal});
|
||||||
const res = await fetch('/api/auth/google', {
|
if(!data || !Object.hasOwn(data,'clientId') || (data.clientId!==null&&typeof data.clientId!=='string'))throw new Error('Giriş yapılandırması doğrulanamadı. Yeniden deneyin.');
|
||||||
method: 'POST',
|
return data;
|
||||||
headers: { 'Content-Type': 'application/json' },
|
}
|
||||||
body: JSON.stringify({ credential }),
|
export async function loginWithGoogle(credential,{signal}={}) {
|
||||||
});
|
const data=await authRequest('/api/auth/google',{method:'POST',signal,headers:{'Content-Type':'application/json'},body:JSON.stringify({credential})});
|
||||||
const data = await res.json();
|
if(!data?.success)throw new Error(data?.error || 'Giriş başarısız');
|
||||||
if (!res.ok || !data.success) throw new Error(data.error || 'Giriş başarısız');
|
if(typeof data.token!=='string'||!data.token||typeof data.user?.email!=='string'||!data.user.email)throw new Error('Giriş yanıtı doğrulanamadı.');
|
||||||
setSession(data.token, data.user);
|
signal?.throwIfAborted();
|
||||||
|
setSession(data.token,data.user);
|
||||||
|
if(getToken()!==data.token||getStoredUser()?.email!==data.user.email){clearSession();throw new Error('Tarayıcı oturumu saklayamadı. Site depolama iznini kontrol edip tekrar deneyin.');}
|
||||||
return data.user;
|
return data.user;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sayfa açılışında saklı token'ı doğrula (opsiyonel; başarısızsa oturum düşer)
|
// Geçici hizmet hatası oturumu silmez; yalnız açık yetki reddi sona erdirir.
|
||||||
export async function restoreSession() {
|
export async function restoreSession() {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token) return null;
|
if (!token) return null;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/auth/me', { headers: { Authorization: `Bearer ${token}` } });
|
const data = await authRequest('/api/auth/me', { headers: { Authorization: `Bearer ${token}` } });
|
||||||
if (getToken() !== token) return getStoredUser();
|
if (getToken() !== token) return getStoredUser();
|
||||||
if (!res.ok) { clearSession(); return null; }
|
if (data?.success && typeof data.user?.email === 'string' && data.user.email) {
|
||||||
const data = await res.json();
|
setSession(token, data.user);
|
||||||
|
return data.user;
|
||||||
|
}
|
||||||
|
return getStoredUser();
|
||||||
|
} catch (error) {
|
||||||
if (getToken() !== token) return getStoredUser();
|
if (getToken() !== token) return getStoredUser();
|
||||||
if (data.success) { setSession(token, data.user); return data.user; }
|
if (error.status === 401 || error.status === 403) { clearSession(); return null; }
|
||||||
clearSession();
|
|
||||||
return null;
|
|
||||||
} catch {
|
|
||||||
// Ağ hatası: saklı kullanıcıyı koru (offline)
|
|
||||||
return getStoredUser();
|
return getStoredUser();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {getGoogleConfig,loginWithGoogle,restoreSession,setSession,getToken,getStoredUser} from '../src/services/auth.js';
|
||||||
|
const memory=new Map();globalThis.localStorage={getItem:key=>memory.get(key)||null,setItem:(key,value)=>memory.set(key,value),removeItem:key=>memory.delete(key)};
|
||||||
|
test('Google config distinguishes unavailable endpoint from explicitly unconfigured service',async()=>{
|
||||||
|
const original=globalThis.fetch;
|
||||||
|
try{
|
||||||
|
globalThis.fetch=async()=>({ok:false,json:async()=>({error:'Service unavailable'})});await assert.rejects(getGoogleConfig(),/Service unavailable/);
|
||||||
|
globalThis.fetch=async()=>({ok:true,json:async()=>({clientId:null})});assert.deepEqual(await getGoogleConfig(),{clientId:null});
|
||||||
|
globalThis.fetch=async()=>({ok:true,json:async()=>({})});await assert.rejects(getGoogleConfig(),/doğrulanamadı/);
|
||||||
|
}finally{globalThis.fetch=original;}
|
||||||
|
});
|
||||||
|
test('invalid or cancelled Google sign-in cannot create a browser session',async()=>{
|
||||||
|
const original=globalThis.fetch;memory.clear();
|
||||||
|
try{
|
||||||
|
globalThis.fetch=async()=>({ok:true,json:async()=>({success:true,token:'',user:{email:'qa@example.test'}})});
|
||||||
|
await assert.rejects(loginWithGoogle('synthetic'),/doğrulanamadı/);assert.equal(memory.size,0);
|
||||||
|
const controller=new AbortController();
|
||||||
|
globalThis.fetch=async()=>({ok:true,json:async()=>{controller.abort();return {success:true,token:'synthetic',user:{email:'qa@example.test'}};}});
|
||||||
|
await assert.rejects(loginWithGoogle('synthetic',{signal:controller.signal}));assert.equal(memory.size,0);
|
||||||
|
}finally{globalThis.fetch=original;}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restore preserves credentials on transient and malformed responses but expires rejected credentials',async()=>{
|
||||||
|
const original=globalThis.fetch;
|
||||||
|
try{
|
||||||
|
for(const response of [
|
||||||
|
{ok:false,status:503,json:async()=>({error:'Unavailable'})},
|
||||||
|
{ok:false,status:502,json:async()=>{throw new Error('HTML response');}},
|
||||||
|
{ok:true,status:200,json:async()=>({success:true,user:{}})},
|
||||||
|
]){
|
||||||
|
setSession('old-token',{email:'old@example.test'});globalThis.fetch=async()=>response;
|
||||||
|
assert.equal((await restoreSession()).email,'old@example.test');assert.equal(getToken(),'old-token');
|
||||||
|
}
|
||||||
|
for(const status of [401,403]){
|
||||||
|
setSession('old-token',{email:'old@example.test'});
|
||||||
|
globalThis.fetch=async()=>({ok:false,status,json:async()=>{throw new Error('Invalid JSON');}});
|
||||||
|
assert.equal(await restoreSession(),null);assert.equal(getToken(),null);
|
||||||
|
}
|
||||||
|
}finally{globalThis.fetch=original;memory.clear();}
|
||||||
|
});
|
||||||
|
test('late restoration cannot replace or clear a newer login',async()=>{
|
||||||
|
const original=globalThis.fetch;
|
||||||
|
try{
|
||||||
|
for(const ok of [true,false]){
|
||||||
|
setSession('old-token',{email:'old@example.test'});
|
||||||
|
globalThis.fetch=async()=>({ok,status:ok?200:401,json:async()=>{
|
||||||
|
setSession('new-token',{email:'new@example.test'});
|
||||||
|
return {success:ok,user:{email:'old@example.test'}};
|
||||||
|
}});
|
||||||
|
assert.equal((await restoreSession()).email,'new@example.test');
|
||||||
|
assert.equal(getToken(),'new-token');assert.equal(getStoredUser().email,'new@example.test');
|
||||||
|
}
|
||||||
|
}finally{globalThis.fetch=original;memory.clear();}
|
||||||
|
});
|
||||||
|
test('partial browser storage failure does not leave a usable half-session',async()=>{
|
||||||
|
const original=globalThis.fetch,storage=globalThis.localStorage;memory.clear();
|
||||||
|
try{
|
||||||
|
globalThis.localStorage={...storage,setItem:(key,value)=>{if(key==='ihaleist_user')throw new Error('Quota');storage.setItem(key,value);}};
|
||||||
|
globalThis.fetch=async()=>({ok:true,json:async()=>({success:true,token:'synthetic',user:{email:'qa@example.test'}})});
|
||||||
|
await assert.rejects(loginWithGoogle('synthetic'),/saklayamadı/);assert.equal(getToken(),null);
|
||||||
|
}finally{globalThis.fetch=original;globalThis.localStorage=storage;memory.clear();}
|
||||||
|
});
|
||||||
|
test('restoration timeout releases a stalled body without erasing the session',async t=>{
|
||||||
|
const original=globalThis.fetch;
|
||||||
|
t.mock.timers.enable({apis:['setTimeout']});
|
||||||
|
try{
|
||||||
|
setSession('old-token',{email:'old@example.test'});
|
||||||
|
let bodyStarted;
|
||||||
|
const started=new Promise(resolve=>{bodyStarted=resolve;});
|
||||||
|
globalThis.fetch=async()=>({ok:true,json:()=>{bodyStarted();return new Promise(()=>{});}});
|
||||||
|
const restoring=restoreSession();await started;t.mock.timers.tick(30000);
|
||||||
|
assert.equal((await restoring).email,'old@example.test');assert.equal(getToken(),'old-token');
|
||||||
|
}finally{globalThis.fetch=original;memory.clear();t.mock.timers.reset();}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user