Fix API key error states and verify revocation lifecycle
This commit is contained in:
@@ -595,3 +595,13 @@ Ortak kalem araması başarısız olduğunda istemci boş dizi, AppContext ise s
|
||||
Liste kalem araması sürerken sonuçların genişleyebileceğini, hata olduğunda yalnız başlık/kurum/yüklü kart aramasıyla devam edildiğini ve sonuçların eksik olabileceğini açıklar. Kalem aramasını yeniden deneme düğmesi eklendi. Gerçek boş kalem sonucu hata sayılmaz.
|
||||
|
||||
Doğrulama: boş başarılı sonuç, depo 503 hatası ve iptal edilmiş arama için servis testi. Mevcut sorgu eşleşmesi testleriyle birlikte tüm 129 test ve üretim derlemesi geçti. Yeni hata durumunun tarayıcı kabulü açık. Büyük paket uyarıları sürüyor; canlı yayın yapılmadı.
|
||||
|
||||
### D09 — API anahtarı hata, oturum ve iptal akışı
|
||||
|
||||
İlk listeleme hatasında boş `durum.anahtarlar` erişimi kaldırıldı; yükleme/hata/boş liste ayrı gösterilir. Hesap ve token bazında anahtar ekranı yeniden kurulur; eski oturum yanıtları uygulanmaz. Oluşturma/iptal çift tıklaması engellenir; liste alınmadan oluşturma açılmaz. İptal yanıtı plan limitini korur ve iptal edilen tek seferlik anahtar kutusu temizlenir. Pano başarısı ancak gerçek kopyalama tamamlanınca gösterilir; hata görünürdür.
|
||||
|
||||
API anahtarı istemcisinde 30 saniye zaman aşımı, yanıt biçimi kontrolü ve yanıt sonrası oturum kontrolü eklendi. Sonucu alınamayan mutasyon otomatik tekrarlanmaz; kullanıcı listeyi kontrol etmeye yönlendirilir. Liste yenileme hatası yeni oluşturulmuş anahtarı silmez.
|
||||
|
||||
Doğrulama: boş başarılı liste, 503, geçersiz JSON/veri, geç gelen farklı oturum yanıtı ve zaman aşımında tek oluşturma isteği. Gerçek izole yerel HTTP sunucusunda oluşturma → gizli alanları içermeyen liste → MCP initialize erişimi → son kullanım zamanı → iptal → 401 zinciri geçti. Başka hesap iptal edemedi; iptal yanıtında limit korundu. Test anahtarları geçici veritabanında tutuldu ve çıktıya yazılmadı.
|
||||
|
||||
Sonuç: 132 test başarılı; üretim derlemesi geçti. Tarayıcıda hata/hesap değişimi/kopyalama kabulü henüz yapılmadı. Büyük paket uyarıları ve D01–D11 kalan işleri açık. Canlı yayın yapılmadı.
|
||||
|
||||
+1
-1
@@ -1186,7 +1186,7 @@ const server = http.createServer(async (req, res) => {
|
||||
}
|
||||
if (req.method === 'DELETE') {
|
||||
const ok = store.revokeApiKey(user.email, url.searchParams.get('id'));
|
||||
return json(res, ok ? 200 : 404, { success: ok, anahtarlar: store.listApiKeys(user.email) });
|
||||
return json(res, ok ? 200 : 404, { success: ok, error: ok ? undefined : 'Anahtar bulunamadı veya bu hesaba ait değil.', anahtarlar: store.listApiKeys(user.email), limit: haklar(user.email, { isAdmin: user.isAdmin }).apiAnahtari });
|
||||
}
|
||||
return json(res, 200, { success: true, anahtarlar: store.listApiKeys(user.email), limit: haklar(user.email, { isAdmin: user.isAdmin }).apiAnahtari });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { listApiKeys, createApiKey, revokeApiKey } from '../../services/account';
|
||||
import { Webhook, Key, Plus, Trash2, Loader2, Copy, Check, AlertTriangle, ShieldCheck, Lock } from 'lucide-react';
|
||||
import { getToken } from '../../services/auth.js';
|
||||
import { McpRssKarti } from './McpRssKarti';
|
||||
|
||||
/**
|
||||
@@ -11,6 +12,15 @@ import { McpRssKarti } from './McpRssKarti';
|
||||
*/
|
||||
export const ApiWebhooks = () => {
|
||||
const { authUser } = useApp();
|
||||
return authUser ? <ApiKeySession key={`${authUser.email}:${getToken()}`} /> : <p>API anahtarlarınızı yönetmek için giriş yapın.</p>;
|
||||
};
|
||||
|
||||
const ApiKeySession = () => {
|
||||
const token = useRef(getToken());
|
||||
const alive = useRef(true);
|
||||
const operation = useRef(false);
|
||||
const copyTimer = useRef(null);
|
||||
const current = () => alive.current && getToken() === token.current;
|
||||
const [durum, setDurum] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [hata, setHata] = useState(null);
|
||||
@@ -18,35 +28,60 @@ export const ApiWebhooks = () => {
|
||||
const [yeniAnahtar, setYeniAnahtar] = useState(null); // yalnızca bir kez gösterilir
|
||||
const [kopyalandi, setKopyalandi] = useState(false);
|
||||
|
||||
const yukle = () => {
|
||||
if (!authUser) return;
|
||||
setHata(null);
|
||||
listApiKeys().then(setDurum).catch((e) => setHata(e.message));
|
||||
const [loading, setLoading] = useState(true);
|
||||
const yukle = async () => {
|
||||
if (!current()) return;
|
||||
setLoading(true); setHata(null);
|
||||
try {
|
||||
const data = await listApiKeys();
|
||||
if (current()) setDurum(data);
|
||||
} catch (e) { if (current()) setHata(e.message); }
|
||||
finally { if (current()) setLoading(false); }
|
||||
};
|
||||
useEffect(yukle, [authUser]);
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
void yukle();
|
||||
return () => { alive.current = false; clearTimeout(copyTimer.current); };
|
||||
}, []);
|
||||
|
||||
const olustur = async () => {
|
||||
setBusy(true); setHata(null); setYeniAnahtar(null);
|
||||
if (operation.current || !current() || !durum || loading) return;
|
||||
operation.current = true;
|
||||
setBusy(true); setHata(null);
|
||||
try {
|
||||
const d = await createApiKey(yeniAd || 'API Anahtarı');
|
||||
setYeniAnahtar(d.anahtar);
|
||||
const d = await createApiKey(yeniAd.trim() || 'API Anahtarı');
|
||||
if (!current()) return;
|
||||
setYeniAnahtar(d.anahtar); setKopyalandi(false);
|
||||
setYeniAd('');
|
||||
yukle();
|
||||
} catch (e) { setHata(e.message); }
|
||||
finally { setBusy(false); }
|
||||
await yukle();
|
||||
} catch (e) { if (current()) setHata(e.message); }
|
||||
finally { operation.current = false; if (current()) setBusy(false); }
|
||||
};
|
||||
|
||||
const iptal = async (id) => {
|
||||
if (operation.current || !current() || loading) return;
|
||||
operation.current = true;
|
||||
setBusy(true); setHata(null);
|
||||
try { setDurum(await revokeApiKey(id)); }
|
||||
catch (e) { setHata(e.message); }
|
||||
finally { setBusy(false); }
|
||||
try {
|
||||
const data = await revokeApiKey(id);
|
||||
if (!current()) return;
|
||||
setDurum(data);
|
||||
setYeniAnahtar((key) => key?.id === id ? null : key);
|
||||
} catch (e) { if (current()) setHata(e.message); }
|
||||
finally { operation.current = false; if (current()) setBusy(false); }
|
||||
};
|
||||
|
||||
const kopyala = (t) => {
|
||||
navigator.clipboard?.writeText(t);
|
||||
const kopyala = async (t) => {
|
||||
try {
|
||||
if (!navigator.clipboard) throw new Error('clipboard unavailable');
|
||||
await navigator.clipboard.writeText(t);
|
||||
if (!current()) return;
|
||||
setKopyalandi(true);
|
||||
setTimeout(() => setKopyalandi(false), 2000);
|
||||
clearTimeout(copyTimer.current);
|
||||
copyTimer.current = setTimeout(() => { if (current()) setKopyalandi(false); }, 2000);
|
||||
} catch {
|
||||
if (current()) { setKopyalandi(false); setHata('Anahtar kopyalanamadı. Anahtarı seçerek elle kopyalayabilirsiniz.'); }
|
||||
}
|
||||
};
|
||||
|
||||
const limit = durum?.limit;
|
||||
@@ -54,7 +89,7 @@ export const ApiWebhooks = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{hata && <button onClick={yukle} className="px-4 py-2 border rounded-lg">Yeniden dene</button>}
|
||||
{hata && <button disabled={busy || loading} onClick={yukle} className="px-4 py-2 border rounded-lg">Yeniden dene</button>}
|
||||
<div className="bg-linear-to-r from-slate-900 via-violet-950 to-indigo-900 text-white p-6 rounded-2xl shadow-lg border border-violet-800/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-2xl bg-violet-600 flex items-center justify-center shadow-md">
|
||||
@@ -113,8 +148,10 @@ export const ApiWebhooks = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!durum && !hata && !hata ? (
|
||||
{loading && !durum ? (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400"><Loader2 className="w-3.5 h-3.5 animate-spin" /> yükleniyor…</div>
|
||||
) : !durum ? (
|
||||
<p className="text-xs text-slate-500">Anahtar listesi alınamadı. Yeniden deneyebilirsiniz.</p>
|
||||
) : durum.anahtarlar.length === 0 ? (
|
||||
<p className="text-xs text-slate-400">Henüz anahtar yok.</p>
|
||||
) : (
|
||||
@@ -130,7 +167,7 @@ export const ApiWebhooks = () => {
|
||||
{k.son_kullanim ? `son kullanım: ${new Date(k.son_kullanim).toLocaleString('tr-TR')}` : 'hiç kullanılmadı'}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => iptal(k.id)} disabled={busy}
|
||||
<button onClick={() => iptal(k.id)} disabled={busy || loading}
|
||||
className="text-slate-400 hover:text-red-500 shrink-0" title="Anahtarı iptal et">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -141,16 +178,16 @@ export const ApiWebhooks = () => {
|
||||
|
||||
<div className="flex gap-2 pt-2 border-t border-slate-100">
|
||||
<input value={yeniAd} onChange={(e) => setYeniAd(e.target.value)}
|
||||
placeholder="Anahtar adı (ör. ERP Entegrasyonu)" disabled={kilitli}
|
||||
className="flex-1 bg-slate-50 border border-slate-200 rounded-lg px-2.5 py-2 text-xs disabled:opacity-50" />
|
||||
<button onClick={olustur} disabled={busy || kilitli}
|
||||
aria-label="Anahtar adı" placeholder="Anahtar adı (ör. ERP Entegrasyonu)" disabled={kilitli || busy || loading || !durum}
|
||||
className="min-w-0 flex-1 bg-slate-50 border border-slate-200 rounded-lg px-2.5 py-2 text-xs disabled:opacity-50" />
|
||||
<button onClick={olustur} disabled={busy || loading || !durum || kilitli || (limit != null && durum.anahtarlar.length >= limit)}
|
||||
className="bg-violet-600 hover:bg-violet-500 disabled:opacity-40 text-white font-bold text-xs px-4 rounded-lg flex items-center gap-1.5">
|
||||
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Plus className="w-3.5 h-3.5" />} Oluştur
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hata && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-2.5 flex gap-2">
|
||||
<div role="alert" className="bg-red-50 border border-red-200 rounded-lg p-2.5 flex gap-2">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-red-600 shrink-0 mt-0.5" />
|
||||
<p className="text-[11px] text-red-800">{hata}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { apiKeyRequest } from './apiKeys.js';
|
||||
import { sessionGuard } from './sessionGuard.js';
|
||||
/**
|
||||
* Sunucu tarafı hesap servisleri: plan/kota, kullanıcı verisi, API anahtarları, belgeler.
|
||||
@@ -37,9 +38,9 @@ export const setUserData = (anahtar, deger) =>
|
||||
iste('/api/me/data', { method: 'PUT', body: JSON.stringify({ anahtar, deger }) });
|
||||
|
||||
// --- API anahtarları ---
|
||||
export const listApiKeys = () => iste('/api/me/apikeys');
|
||||
export const createApiKey = (ad) => iste('/api/me/apikeys', { method: 'POST', body: JSON.stringify({ ad }) });
|
||||
export const revokeApiKey = (id) => iste(`/api/me/apikeys?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
export const listApiKeys = () => apiKeyRequest();
|
||||
export const createApiKey = (ad) => apiKeyRequest('POST', { ad });
|
||||
export const revokeApiKey = (id) => apiKeyRequest('DELETE', { id });
|
||||
|
||||
// --- Belgeler ---
|
||||
export const listDocuments = () => iste('/api/me/documents');
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { getToken } from './auth.js';
|
||||
|
||||
// A lost mutation response is uncertain: never automatically repeat key creation.
|
||||
export async function apiKeyRequest(method = 'GET', { id, ad, timeoutMs = 30000 } = {}) {
|
||||
const token = getToken();
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(`/api/me/apikeys${id ? `?id=${encodeURIComponent(id)}` : ''}`, {
|
||||
method, signal: controller.signal,
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
...(method === 'POST' ? { body: JSON.stringify({ ad }) } : {}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (getToken() !== token) throw new Error('Oturum değişti; anahtar ekranını yeniden açın.');
|
||||
if (!response.ok || data?.success !== true) throw new Error(data?.error || 'API anahtarı işlemi tamamlanamadı.');
|
||||
const valid = method === 'POST'
|
||||
? typeof data.anahtar?.id === 'string' && typeof data.anahtar?.anahtar === 'string' && data.anahtar.anahtar.startsWith('ih_live_')
|
||||
: Array.isArray(data.anahtarlar) && data.anahtarlar.every(k => k && typeof k.id === 'string' && typeof k.ad === 'string') && (data.limit === null || (Number.isFinite(data.limit) && data.limit >= 0));
|
||||
if (!valid) throw new Error('Anahtar bilgileri eksik veya geçersiz. Listeyi yeniden yükleyin.');
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) throw new Error(method === 'GET'
|
||||
? 'Anahtar listesi zamanında alınamadı. Yeniden deneyin.'
|
||||
: 'İşlem sonucu alınamadı. Tekrar işlem yapmadan önce listeyi yenileyin; işlem sunucuda gerçekleşmiş olabilir.');
|
||||
if (error instanceof SyntaxError) throw new Error('Sunucu geçerli anahtar bilgisi döndürmedi. Listeyi yeniden yükleyin.');
|
||||
throw error;
|
||||
} finally { clearTimeout(timer); }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { apiKeyRequest } from '../src/services/apiKeys.js';
|
||||
|
||||
test('API key client distinguishes failure, malformed data, and a genuinely empty list', async () => {
|
||||
const original = globalThis.fetch;
|
||||
try {
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({success:true,anahtarlar:[],limit:0}));
|
||||
assert.deepEqual((await apiKeyRequest()).anahtarlar, []);
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({success:false,error:'Depo erişilemiyor'}),{status:503});
|
||||
await assert.rejects(apiKeyRequest(), /Depo erişilemiyor/);
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({success:true,anahtarlar:null}));
|
||||
await assert.rejects(apiKeyRequest(), /eksik veya geçersiz/);
|
||||
globalThis.fetch = async () => new Response('<html>502</html>');
|
||||
await assert.rejects(apiKeyRequest(), /geçerli anahtar bilgisi/);
|
||||
} finally { globalThis.fetch = original; }
|
||||
});
|
||||
|
||||
test('API key client rejects late private responses after session change and does not retry uncertain creation', async () => {
|
||||
const original = globalThis.fetch, storage = globalThis.localStorage;
|
||||
let token = 'account-a', calls = 0;
|
||||
globalThis.localStorage = {getItem:()=>token};
|
||||
try {
|
||||
globalThis.fetch = async () => {
|
||||
token = 'account-b';
|
||||
return new Response(JSON.stringify({success:true,anahtar:{id:'qa',anahtar:'ih_live_qa'}}));
|
||||
};
|
||||
await assert.rejects(apiKeyRequest('POST',{ad:'QA'}), /Oturum değişti/);
|
||||
globalThis.fetch = async (_url, options) => {
|
||||
calls++;
|
||||
return new Promise((_resolve,reject)=>options.signal.addEventListener('abort',()=>reject(options.signal.reason),{once:true}));
|
||||
};
|
||||
await assert.rejects(apiKeyRequest('POST',{ad:'QA',timeoutMs:5}), /sunucuda gerçekleşmiş olabilir/);
|
||||
assert.equal(calls,1);
|
||||
} finally { globalThis.fetch=original; globalThis.localStorage=storage; }
|
||||
});
|
||||
@@ -134,3 +134,24 @@ test('shared item extraction requires an owned tender document and matching tend
|
||||
await request('/api/me/documents?id='+company.data.id,'DELETE');
|
||||
await request('/api/me/documents?id='+tender.data.id,'DELETE');
|
||||
});
|
||||
|
||||
test('HTTP API key creation, private listing, MCP use and revocation form a complete lifecycle', async () => {
|
||||
const admin=signToken({email:'keys@example.test',name:'Key QA',isAdmin:true});
|
||||
const created=await request('/api/me/apikeys','POST',{ad:'QA integration'},admin);
|
||||
assert.equal(created.status,200);
|
||||
const {id,anahtar}=created.data.anahtar;
|
||||
const listed=await request('/api/me/apikeys','GET',null,admin);
|
||||
assert.equal(listed.data.anahtarlar.length,1);
|
||||
assert.equal(listed.data.anahtarlar[0].anahtar,undefined);
|
||||
assert.equal(listed.data.anahtarlar[0].hash,undefined);
|
||||
assert.equal((await request('/api/me/apikeys?id='+id,'DELETE',null,other)).status,404);
|
||||
const rpc={jsonrpc:'2.0',id:1,method:'initialize',params:{}};
|
||||
assert.equal((await request('/mcp','POST',rpc,anahtar)).status,200);
|
||||
const used=await request('/api/me/apikeys','GET',null,admin);
|
||||
assert.ok(used.data.anahtarlar[0].son_kullanim);
|
||||
const revoked=await request('/api/me/apikeys?id='+id,'DELETE',null,admin);
|
||||
assert.equal(revoked.status,200);
|
||||
assert.equal(revoked.data.limit,listed.data.limit);
|
||||
assert.deepEqual(revoked.data.anahtarlar,[]);
|
||||
assert.equal((await request('/mcp','POST',rpc,anahtar)).status,401);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user