diff --git a/public/app-runtime.js b/public/app-runtime.js
index 8b16f70..d91a86c 100644
--- a/public/app-runtime.js
+++ b/public/app-runtime.js
@@ -5475,47 +5475,108 @@ window.SUN_LEGACY_CATALOG_V175=[];
function modal(title,html){$('sunDevModalV22')?.remove();const m=document.createElement('div');m.className='sun-dev-modal';m.id='sunDevModalV22';m.innerHTML=`
`;document.body.appendChild(m);const close=()=>m.remove();m.addEventListener('click',e=>{if(e.target===m||e.target.closest('[data-close]'))close()});return {m,body:$('#sunDevModalBody'),close}}
function confirmDanger(text){return confirm(text)}
- async function ensureDeveloperMfa(){
- const c=client();if(!c?.auth?.mfa)throw new Error('Supabase MFA недоступна.');
- const readAal=async()=>{const a=await c.auth.mfa.getAuthenticatorAssuranceLevel();if(a.error)throw a.error;return a.data||{}};
- const waitAal2=async()=>{for(let i=0;i<12;i++){const a=await readAal();if(a.currentLevel==='aal2')return true;await new Promise(r=>setTimeout(r,180))}return false};
- const initial=await readAal();if(initial.currentLevel==='aal2')return true;
- const listed=await c.auth.mfa.listFactors();if(listed.error)throw listed.error;
- let factor=(listed.data?.totp||[]).find(f=>f.status==='verified');let enrollment=null;
- if(!factor){
- for(const f of (listed.data?.totp||[]).filter(x=>x.status!=='verified')){
- try{const u=await c.auth.mfa.unenroll({factorId:f.id});if(u.error&&!/not found/i.test(String(u.error.message||'')))throw u.error}catch(e){console.warn('[Developer MFA] reset factor',e)}
+ let developerMfaPending=null,developerMfaEnrollment=null;
+ function ensureDeveloperMfa(){
+ const c=client(),uid=session()?.user?.id;
+ if(!c?.auth?.mfa||!uid)return Promise.reject(new Error('Войдите в аккаунт разработчика.'));
+ if(developerMfaPending?.client===c&&developerMfaPending.uid===uid)return developerMfaPending.promise;
+ const task={client:c,uid,promise:null};developerMfaPending=task;
+ const run=()=>runDeveloperMfa(c,uid);
+ task.promise=(navigator.locks?.request?navigator.locks.request(`caterium-developer-mfa:${uid}`,{ifAvailable:true},lock=>{
+ if(!lock){toast('Подтверждение разработчика уже открыто в другой вкладке. Завершите его там или закройте то окно.','info');return false;}
+ return run();
+ }):run()).finally(()=>{if(developerMfaPending===task)developerMfaPending=null;});
+ return task.promise;
+ }
+ async function runDeveloperMfa(c,uid){
+ const current=()=>client()===c&&session()?.user?.id===uid;
+ const check=()=>{if(!current())throw new Error('Аккаунт изменился. Откройте кабинет разработчика заново.');};
+ const readAal=async()=>{check();const a=await c.auth.mfa.getAuthenticatorAssuranceLevel();check();if(a.error)throw a.error;return a.data||{};};
+ const list=async()=>{
+ check();const r=await c.auth.mfa.listFactors();check();if(r.error)throw r.error;
+ // Supabase .totp contains VERIFIED factors only. Abandoned enrollments
+ // are in .all, and must not be confused with another authenticator.
+ return [...new Map([...(r.data?.all||[]),...(r.data?.totp||[])].map(f=>[f.id,f])).values()];
+ };
+ const totp=f=>f.factor_type==='totp'||f.type==='totp';
+ const verified=rows=>rows.find(f=>totp(f)&&f.status==='verified');
+ const abandoned=f=>totp(f)&&f.status==='unverified'&&f.friendly_name==='Caterium Developer';
+ const waitAal2=async()=>{for(let i=0;i<12;i++){if((await readAal()).currentLevel==='aal2')return true;await new Promise(r=>setTimeout(r,180));}return false;};
+ if((await readAal()).currentLevel==='aal2')return true;
+ let rows=await list(),factor=verified(rows),enrollment=null;
+ const remembered=developerMfaEnrollment;
+ if(factor)developerMfaEnrollment=null;
+ else if(remembered?.client===c&&remembered.uid===uid&&rows.some(f=>f.id===remembered.data.id&&abandoned(f))){
+ enrollment=remembered.data;factor=enrollment;
+ }else{
+ developerMfaEnrollment=null;
+ for(const old of rows.filter(abandoned)){
+ // Refresh immediately before deleting: never remove a factor that was
+ // confirmed in another session, nor a phone/passkey/other app factor.
+ rows=await list();factor=verified(rows);if(factor)break;
+ if(!rows.some(f=>f.id===old.id&&abandoned(f)))continue;
+ const reset=await c.auth.mfa.unenroll({factorId:old.id});check();
+ if(reset.error&&reset.error.code!=='mfa_factor_not_found'&&!/factor.*not found/i.test(reset.error.message||'')){
+ throw new Error('Не удалось восстановить незавершённую настройку 2FA. Проверьте соединение и откройте раздел ещё раз.');
+ }
+ }
+ if(!factor){
+ rows=await list();factor=verified(rows);
+ if(!factor){
+ const enr=await c.auth.mfa.enroll({factorType:'totp',friendlyName:'Caterium Developer'});check();
+ if(enr.error){
+ // A second device may have completed enrollment while this one
+ // was waiting. Reuse only its verified factor; never bypass MFA.
+ rows=await list();factor=verified(rows);
+ if(!factor)throw new Error(/already exists|friendly name/i.test(enr.error.message||'')?'Настройка 2FA начата в другом окне. Закройте его и повторно откройте кабинет разработчика.':enr.error.message||'Не удалось создать QR-код. Повторите попытку.');
+ }else{
+ if(!enr.data?.id||!enr.data?.totp?.qr_code)throw new Error('Сервер не вернул QR-код. Повторно откройте кабинет разработчика.');
+ enrollment=enr.data;factor=enrollment;
+ developerMfaEnrollment={client:c,uid,data:enrollment};
+ }
+ }
}
- const enr=await c.auth.mfa.enroll({factorType:'totp',friendlyName:'Caterium Developer'});if(enr.error)throw enr.error;factor=enr.data;enrollment=enr.data;
}
- const firstSetup=Boolean(enrollment),qr=enrollment?.totp?.qr_code||'',secret=enrollment?.totp?.secret||'';
- const ui=modal(firstSetup?'Подключение 2FA разработчика':'Подтверждение разработчика',firstSetup?`Отсканируйте этот QR-код приложением-аутентификатором. Затем введите текущие 6 цифр и нажмите Подтвердить и открыть.
${qr?`
`:''}${secret?`Если QR не сканируется, добавьте ключ вручную:
${esc(secret)}
`:''}`:`Введите текущий 6-значный код из приложения-аутентификатора.
`);
- return await new Promise(resolve=>{
- let settled=false,busy=false;
- const finish=v=>{if(settled)return;settled=true;try{ui.close()}catch(_){}resolve(v)};
- const showError=(e,notify=true)=>{const msg=e?.message||String(e||'Не удалось подтвердить код.');const box=$('sunDevMfaError');if(box)box.textContent=msg;if(notify)toast(msg,'error',6500)};
+ check();
+ const firstSetup=Boolean(enrollment),rawQr=enrollment?.totp?.qr_code||'';
+ const qr=rawQr.trim().startsWith('