diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index 378c47c..18e15fb 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -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. - 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. + +### 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ı. diff --git a/src/components/LoginPage.jsx b/src/components/LoginPage.jsx index 6c18296..66d9ffd 100644 --- a/src/components/LoginPage.jsx +++ b/src/components/LoginPage.jsx @@ -16,46 +16,46 @@ export const LoginPage = ({ onLogin }) => { const [ready, setReady] = useState(false); const [noConfig, setNoConfig] = useState(false); + const [attempt,setAttempt]=useState(0),[signingIn,setSigningIn]=useState(false); + const loginCallback=useRef(onLogin);loginCallback.current=onLogin; 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); + let cancelled=false,script=null,scriptTimer=null,loginPending=false; + const controller=new AbortController(); + setErr(null);setReady(false);setNoConfig(false);setSigningIn(false); + const failed=()=>{clearTimeout(scriptTimer);if(!cancelled){setErr('Google giriş ekranı yüklenemedi. Bağlantınızı kontrol edip yeniden deneyin.');setReady(false);}script?.remove();}; + const init=()=>{ + if(cancelled)return; + if(!window.google?.accounts?.id||!btnRef.current){failed();return;} + clearTimeout(scriptTimer); + try { + window.google.accounts.id.initialize({client_id:clientId,callback:async response=>{ + if(cancelled||loginPending)return; + loginPending=true;setSigningIn(true);setErr(null); + try{const user=await loginWithGoogle(response.credential,{signal:controller.signal});if(!cancelled)loginCallback.current(user);} + catch(error){if(!cancelled)setErr(error.message || 'Giriş başarısız');} + finally{loginPending=false;if(!cancelled)setSigningIn(false);} + }}); + 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); + } catch { failed(); } + }; + let clientId; + void (async()=>{ + try{ + const config=await getGoogleConfig({signal:controller.signal}); + if(cancelled)return;clientId=config.clientId; + if(!clientId){setNoConfig(true);return;} + if(window.google?.accounts?.id){init();return;} + script=document.getElementById('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;} + script.addEventListener('load',init);script.addEventListener('error',failed); + scriptTimer=setTimeout(failed,15000); + if(!script.isConnected)document.head.appendChild(script); + }catch(error){if(!cancelled)setErr(error.message || 'Giriş hizmetine ulaşılamadı.');} })(); - return () => { cancelled = true; }; - }, [onLogin]); + return()=>{cancelled=true;controller.abort();clearTimeout(scriptTimer);script?.removeEventListener('load',init);script?.removeEventListener('error',failed);}; + },[attempt]); return (
@@ -89,16 +89,18 @@ export const LoginPage = ({ onLogin }) => { {noConfig ? (
- 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.
) : (
- {!ready &&

Google giriş yükleniyor…

} + {!ready && !err &&

Google giriş yükleniyor…

}
)} - {err &&

{err}

} + {signingIn&&

Giriş doğrulanıyor…

} + {err &&

{err}

} + {(err||noConfig)&&}

Girişin Google tarafından güvenle doğrulanır. diff --git a/src/services/auth.js b/src/services/auth.js index 54c3ef3..5801c59 100644 --- a/src/services/auth.js +++ b/src/services/auth.js @@ -1,3 +1,4 @@ +import {awaitWithAbort} from './abortable.js'; /** * Google ile Giriş — frontend istemcisi (GIS ID-token akışı). * Token localStorage'da 'ihaleist_token', kullanıcı 'ihaleist_user' altında saklanır. @@ -25,43 +26,52 @@ export function clearSession() { } 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 }; - } +async function authRequest(url,options={}) { + const {signal,...requestOptions}=options,controller=new AbortController(); + const cancel=()=>controller.abort(signal.reason); + if(signal?.aborted)cancel();else signal?.addEventListener('abort',cancel,{once:true}); + let timeout=false; + const timer=setTimeout(()=>{timeout=true;controller.abort();},30000); + 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 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); +export async function getGoogleConfig({signal}={}) { + const data=await authRequest('/api/auth/google/config',{signal}); + if(!data || !Object.hasOwn(data,'clientId') || (data.clientId!==null&&typeof data.clientId!=='string'))throw new Error('Giriş yapılandırması doğrulanamadı. Yeniden deneyin.'); + return data; +} +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})}); + if(!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ı.'); + 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; } -// 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() { const token = getToken(); if (!token) return null; 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 (!res.ok) { clearSession(); return null; } - const data = await res.json(); + if (data?.success && typeof data.user?.email === 'string' && data.user.email) { + setSession(token, data.user); + return data.user; + } + return getStoredUser(); + } catch (error) { if (getToken() !== token) return getStoredUser(); - if (data.success) { setSession(token, data.user); return data.user; } - clearSession(); - return null; - } catch { - // Ağ hatası: saklı kullanıcıyı koru (offline) + if (error.status === 401 || error.status === 403) { clearSession(); return null; } return getStoredUser(); } } diff --git a/tests/auth-client.test.mjs b/tests/auth-client.test.mjs new file mode 100644 index 0000000..50c0a40 --- /dev/null +++ b/tests/auth-client.test.mjs @@ -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();} +});