Require account login and isolate all company client caches
This commit is contained in:
parent
c18c49770d
commit
57f5807953
9
docs/ACCOUNT-ACCESS-20260917.md
Normal file
9
docs/ACCOUNT-ACCESS-20260917.md
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# Account access and company isolation
|
||||||
|
|
||||||
|
Production requires a signed-in account and a company membership. The login form remains available when the backend is unreachable, and an old `sunLocalOnlyModeV1` preference cannot bypass it. Application sections are hidden from the initial HTML until access is resolved.
|
||||||
|
|
||||||
|
The emergency local workflow remains implemented. To restore it during an incident, deliberately change `emergencyLocalEnabled` in `public/core/access-policy.js`, bump the release/cache version, run the release checks and deploy. There is no public browser setting that enables this workflow in the current release. This switch does not change server authentication or RLS.
|
||||||
|
|
||||||
|
A developer with their own company opens the normal application. Developer tools are selected separately in the sidebar and continue to require MFA. The production developer test company is provisioned empty, independently of the exclusive company catalog and imported customer history.
|
||||||
|
|
||||||
|
Client caches are captured, cleared and restored together with orders, catalog photos and company documents. Changing company cancels queued client writes, suppresses rebuilding caches from old runtime arrays, and ignores in-flight responses for the previous workspace. Tests cover switching to an empty company and back, old anonymous-mode preferences, unavailable authentication endpoints, and normal developer entry.
|
||||||
@ -11,7 +11,7 @@
|
|||||||
"serverReady": true,
|
"serverReady": true,
|
||||||
"workspaceAutoDiscovery": true,
|
"workspaceAutoDiscovery": true,
|
||||||
"invitesTemporarilyDisabled": false,
|
"invitesTemporarilyDisabled": false,
|
||||||
"pwaCache": "v86-20260917-empty-catalog",
|
"pwaCache": "v87-20260917-account-access",
|
||||||
"fullOfferDescriptions": true,
|
"fullOfferDescriptions": true,
|
||||||
"dynamicOfferRows": true,
|
"dynamicOfferRows": true,
|
||||||
"pdfOfferDescriptionFix": true,
|
"pdfOfferDescriptionFix": true,
|
||||||
@ -383,5 +383,7 @@
|
|||||||
"clientOrderMetricsSource": "legacy orders verified locally",
|
"clientOrderMetricsSource": "legacy orders verified locally",
|
||||||
"clientServerCache": "cateriumClientsServerV1773",
|
"clientServerCache": "cateriumClientsServerV1773",
|
||||||
"supabaseProjectRef": "usfjwhztqoopzzfmfbis",
|
"supabaseProjectRef": "usfjwhztqoopzzfmfbis",
|
||||||
"recoveryMigration": "20260917150000_fresh_caterium"
|
"recoveryMigration": "20260917150000_fresh_caterium",
|
||||||
|
"anonymousAccessDisabled": true,
|
||||||
|
"clientCacheTenantIsolation": true
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
"version": "17.7.3",
|
"version": "17.7.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"check:syntax": "node --check public/app-runtime.js && node --check public/service-worker.js && node --check public/legacy/bootstrap.js && node --check public/core/sun-safe.js && node --check public/core/account-center-v1780.js && node --check public/core/performance.js && node --check public/core/auth-security-v1774.js && node --check public/core/trial-promo-developer-v181.js && node --check public/core/order-enhancements-v1775.js && node --check public/core/data-layer-v1773.js && node --check public/core/server-automation-v1770.js && node --check public/core/hotfix-v1763.js && node --check public/core/ops-ux-v1762.js && node --check public/core/ux-fixes-v1764.js && node --check public/core/pdf-engine.js && node --check public/core/classic-offer-pdf-v1767.js && node --check public/core/signature-offer-pdf-v18.js && node --check public/core/developer-console-v1768.js && node --check public/core/offer-workspace-v1769.js && node --check public/core/brand-theme.js && node --check public/core/company-branding.js && node --check public/core/import-archive.js",
|
"check:syntax": "node --check public/app-runtime.js && node --check public/service-worker.js && node --check public/legacy/bootstrap.js && node --check public/core/sun-safe.js && node --check public/core/account-center-v1780.js && node --check public/core/performance.js && node --check public/core/auth-security-v1774.js && node --check public/core/trial-promo-developer-v181.js && node --check public/core/order-enhancements-v1775.js && node --check public/core/data-layer-v1773.js && node --check public/core/server-automation-v1770.js && node --check public/core/hotfix-v1763.js && node --check public/core/ops-ux-v1762.js && node --check public/core/ux-fixes-v1764.js && node --check public/core/pdf-engine.js && node --check public/core/classic-offer-pdf-v1767.js && node --check public/core/signature-offer-pdf-v18.js && node --check public/core/developer-console-v1768.js && node --check public/core/offer-workspace-v1769.js && node --check public/core/brand-theme.js && node --check public/core/company-branding.js && node --check public/core/import-archive.js && node --check public/core/access-policy.js",
|
||||||
"test:static": "node tests/static-security.mjs && node tests/auth-security-v1774.mjs && node tests/employee-create-v1774.mjs && node tests/html-integrity-v1774.mjs && node tests/edge-security-v1774.mjs && node tests/branding-v1774.mjs && node tests/order-enhancements-v1775.mjs",
|
"test:static": "node tests/static-security.mjs && node tests/auth-security-v1774.mjs && node tests/employee-create-v1774.mjs && node tests/html-integrity-v1774.mjs && node tests/edge-security-v1774.mjs && node tests/branding-v1774.mjs && node tests/order-enhancements-v1775.mjs",
|
||||||
"check:release": "node tests/release-check.mjs",
|
"check:release": "node tests/release-check.mjs",
|
||||||
"check:deploy": "npm run check:syntax && npm run test:static && npm run check:release && node tests/backend-cutover.mjs && npm run test:db",
|
"check:deploy": "npm run check:syntax && npm run test:static && npm run check:release && node tests/backend-cutover.mjs && npm run test:db",
|
||||||
|
|||||||
@ -2323,6 +2323,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
|
|||||||
// accounts while avoiding a second large copy inside localStorage.
|
// accounts while avoiding a second large copy inside localStorage.
|
||||||
function isTenantLocalKey(key) {
|
function isTenantLocalKey(key) {
|
||||||
key=String(key||'');
|
key=String(key||'');
|
||||||
|
if(key==='cateriumClientsV1772'||key==='cateriumClientsServerV1773')return true;
|
||||||
if(!key.startsWith('sun'))return false;
|
if(!key.startsWith('sun'))return false;
|
||||||
if(key===CONFIG_KEY||key===CLIENT_ID_KEY)return false;
|
if(key===CONFIG_KEY||key===CLIENT_ID_KEY)return false;
|
||||||
if(key.startsWith('sunCloud'))return false;
|
if(key.startsWith('sunCloud'))return false;
|
||||||
@ -2348,6 +2349,10 @@ window.SUN_LEGACY_CATALOG_V175=[];
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearTenantLocal() {
|
function clearTenantLocal() {
|
||||||
|
// Stop delayed client writes before removing legacy maps. Their storage
|
||||||
|
// listeners must not rebuild the previous company's clients during a switch.
|
||||||
|
window.dispatchEvent(new CustomEvent('sun:cloud-tenant-changing'));
|
||||||
|
try{if(typeof orders!=='undefined')orders=[];if(typeof boxes!=='undefined')boxes=[];}catch(_){}
|
||||||
tenantLocalKeys().forEach(key=>localStorage.removeItem(key));
|
tenantLocalKeys().forEach(key=>localStorage.removeItem(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2370,6 +2375,18 @@ window.SUN_LEGACY_CATALOG_V175=[];
|
|||||||
// account on the first login after upgrading.
|
// account on the first login after upgrading.
|
||||||
if(!config.tenantStorageReady){
|
if(!config.tenantStorageReady){
|
||||||
const legacy=String(config.legacyLocalWorkspaceId||'').trim();
|
const legacy=String(config.legacyLocalWorkspaceId||'').trim();
|
||||||
|
if(!legacy){
|
||||||
|
if(!next)return {changed:false,restored:false};
|
||||||
|
// Never attach an old anonymous browser database to the next account.
|
||||||
|
// Preserve it privately for recovery, then pull the selected company.
|
||||||
|
if(tenantLocalKeys().length)await captureTenantLocal('unassigned-local-recovery');
|
||||||
|
clearTenantLocal();
|
||||||
|
config.tenantStorageReady=true;config.localWorkspaceId=next;config.legacyLocalWorkspaceId='';
|
||||||
|
const restored=next?await restoreTenantLocal(next):false;
|
||||||
|
if(next&&!restored)config.migrated[next]=false;
|
||||||
|
saveConfig();
|
||||||
|
return {changed:Boolean(next),restored};
|
||||||
|
}
|
||||||
if(legacy&&legacy!==next){
|
if(legacy&&legacy!==next){
|
||||||
await captureTenantLocal(legacy);
|
await captureTenantLocal(legacy);
|
||||||
clearTenantLocal();
|
clearTenantLocal();
|
||||||
@ -2390,8 +2407,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
|
|||||||
await captureTenantLocal(next);
|
await captureTenantLocal(next);
|
||||||
return {changed:false,restored:true,adopted:true};
|
return {changed:false,restored:true,adopted:true};
|
||||||
}
|
}
|
||||||
// A purely local pre-cloud installation has no workspace id yet. Keep it
|
// No signed-in company is available yet.
|
||||||
// untouched until the first company is selected, so migration stays safe.
|
|
||||||
return {changed:false,restored:false};
|
return {changed:false,restored:false};
|
||||||
}
|
}
|
||||||
const prev=String(config.localWorkspaceId||'').trim();
|
const prev=String(config.localWorkspaceId||'').trim();
|
||||||
@ -3429,7 +3445,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
|
|||||||
function workspace(){ return cloud()?.getWorkspace?.() || null; }
|
function workspace(){ return cloud()?.getWorkspace?.() || null; }
|
||||||
function session(){ return cloud()?.getSession?.() || null; }
|
function session(){ return cloud()?.getSession?.() || null; }
|
||||||
function client(){ return cloud()?.getClient?.() || null; }
|
function client(){ return cloud()?.getClient?.() || null; }
|
||||||
function has(p){ try{if(localStorage.getItem('sunLocalOnlyModeV1')==='1')return true;}catch(_){} const c=cloud(); return c?.hasPermission ? c.hasPermission(p) : false; }
|
function has(p){ if(window.CateriumAccessPolicy?.emergencyLocalActive())return true; const c=cloud(); return c?.hasPermission ? c.hasPermission(p) : false; }
|
||||||
function currentPermissions(){ return workspace()?.permissions || {}; }
|
function currentPermissions(){ return workspace()?.permissions || {}; }
|
||||||
|
|
||||||
function normalizeLegacyLocalAuth(){
|
function normalizeLegacyLocalAuth(){
|
||||||
@ -3746,13 +3762,13 @@ window.SUN_LEGACY_CATALOG_V175=[];
|
|||||||
// ever saw it.
|
// ever saw it.
|
||||||
|
|
||||||
function ensureAuthGate(){
|
function ensureAuthGate(){
|
||||||
const st=cloud()?.status?.();if(!st?.connected)return;
|
const st=cloud()?.status?.()||{};
|
||||||
const ws=workspace(),ss=session(),inviteToken=currentInviteToken();
|
const ws=workspace(),ss=session(),inviteToken=currentInviteToken();
|
||||||
if(inviteToken){
|
if(inviteToken){
|
||||||
let gate=$('sunCloudAuthGateV3');if(!gate){document.body.classList.add('sun-cloud-auth-required');gate=document.createElement('div');gate.id='sunCloudAuthGateV3';gate.className='sun-cloud-auth-gate';gate.innerHTML='<div class="sun-cloud-auth-card"><p class="hint">Проверяю приглашение…</p></div>';document.body.appendChild(gate);renderInviteGate(gate,inviteToken);}return;
|
let gate=$('sunCloudAuthGateV3');if(!gate){document.body.classList.add('sun-cloud-auth-required');gate=document.createElement('div');gate.id='sunCloudAuthGateV3';gate.className='sun-cloud-auth-gate';gate.innerHTML='<div class="sun-cloud-auth-card"><p class="hint">Проверяю приглашение…</p></div>';document.body.appendChild(gate);renderInviteGate(gate,inviteToken);}return;
|
||||||
}
|
}
|
||||||
if(st.signedIn&&ws){$('sunCloudAuthGateV3')?.remove();document.body.classList.remove('sun-cloud-auth-required');try{localStorage.removeItem(LOCAL_ONLY_KEY)}catch(_){}return;}
|
if(st.signedIn&&ws){$('sunCloudAuthGateV3')?.remove();document.body.classList.remove('sun-cloud-auth-required');try{localStorage.removeItem(LOCAL_ONLY_KEY)}catch(_){}return;}
|
||||||
if(!st.signedIn){try{if(localStorage.getItem(LOCAL_ONLY_KEY)==='1'){$('sunCloudAuthGateV3')?.remove();document.body.classList.remove('sun-cloud-auth-required');return;}}catch(_){}}
|
if(!st.signedIn&&window.CateriumAccessPolicy?.emergencyLocalActive()){$('sunCloudAuthGateV3')?.remove();document.body.classList.remove('sun-cloud-auth-required');return;}
|
||||||
if($('sunCloudAuthGateV3')){if(st.signedIn&&st.membershipsLoading){const e=$('sunGateErrorV3');if(e)e.textContent='Загружаю рабочую базу…';}return;}
|
if($('sunCloudAuthGateV3')){if(st.signedIn&&st.membershipsLoading){const e=$('sunGateErrorV3');if(e)e.textContent='Загружаю рабочую базу…';}return;}
|
||||||
document.body.classList.add('sun-cloud-auth-required');
|
document.body.classList.add('sun-cloud-auth-required');
|
||||||
const gate=document.createElement('div');gate.id='sunCloudAuthGateV3';gate.className='sun-cloud-auth-gate';
|
const gate=document.createElement('div');gate.id='sunCloudAuthGateV3';gate.className='sun-cloud-auth-gate';
|
||||||
@ -3764,7 +3780,9 @@ window.SUN_LEGACY_CATALOG_V175=[];
|
|||||||
gate.querySelector('#sunGateSignOutV3').onclick=async()=>{if(window.SunCloudV2?.signOut)return window.SunCloudV2.signOut();try{await client()?.auth.signOut();}catch(_){}location.reload();};return;
|
gate.querySelector('#sunGateSignOutV3').onclick=async()=>{if(window.SunCloudV2?.signOut)return window.SunCloudV2.signOut();try{await client()?.auth.signOut();}catch(_){}location.reload();};return;
|
||||||
}
|
}
|
||||||
gate.innerHTML=`<div class="sun-cloud-auth-card"><div class="sun-cloud-auth-brand"><img src="caterium-login-logo.png" alt="Caterium"><div><h2 id="sunGateTitleV3">Вход в Caterium</h2><div class="hint" id="sunGateSubtitleV3">Введите данные своего аккаунта</div></div></div><div id="sunGateRegisterFieldsV27" hidden><label>Название компании<input id="sunGateCompanyV3" autocomplete="organization" placeholder="Например, Мой Кейтеринг"></label></div><label>Email<input id="sunGateEmailV3" type="email" autocomplete="username"></label>${passwordField('sunGatePasswordV3','Пароль','current-password')}<div id="sunGateConfirmWrapV27" hidden>${passwordField('sunGatePassword2V27','Подтвердите пароль','new-password')}</div><div class="sun-cloud-auth-actions"><button class="primary" id="sunGateSubmitV3" type="button">Войти</button></div><div class="sun-cloud-auth-error" id="sunGateErrorV3"></div><p class="sun-auth-switch"><span id="sunGateSwitchTextV27">Нет аккаунта?</span> <button type="button" id="sunGateSwitchV27">Создать аккаунт</button></p><p class="sun-auth-switch"><button type="button" id="sunGateLocalOnlyV1" style="background:none;border:0;padding:0;color:inherit;opacity:.6;text-decoration:underline;cursor:pointer;font:inherit">Работать локально без интернета</button></p></div>`;document.body.appendChild(gate);bindPasswordEyes(gate);
|
gate.innerHTML=`<div class="sun-cloud-auth-card"><div class="sun-cloud-auth-brand"><img src="caterium-login-logo.png" alt="Caterium"><div><h2 id="sunGateTitleV3">Вход в Caterium</h2><div class="hint" id="sunGateSubtitleV3">Введите данные своего аккаунта</div></div></div><div id="sunGateRegisterFieldsV27" hidden><label>Название компании<input id="sunGateCompanyV3" autocomplete="organization" placeholder="Например, Мой Кейтеринг"></label></div><label>Email<input id="sunGateEmailV3" type="email" autocomplete="username"></label>${passwordField('sunGatePasswordV3','Пароль','current-password')}<div id="sunGateConfirmWrapV27" hidden>${passwordField('sunGatePassword2V27','Подтвердите пароль','new-password')}</div><div class="sun-cloud-auth-actions"><button class="primary" id="sunGateSubmitV3" type="button">Войти</button></div><div class="sun-cloud-auth-error" id="sunGateErrorV3"></div><p class="sun-auth-switch"><span id="sunGateSwitchTextV27">Нет аккаунта?</span> <button type="button" id="sunGateSwitchV27">Создать аккаунт</button></p><p class="sun-auth-switch"><button type="button" id="sunGateLocalOnlyV1" style="background:none;border:0;padding:0;color:inherit;opacity:.6;text-decoration:underline;cursor:pointer;font:inherit">Работать локально без интернета</button></p></div>`;document.body.appendChild(gate);bindPasswordEyes(gate);
|
||||||
gate.querySelector('#sunGateLocalOnlyV1').onclick=()=>{try{localStorage.setItem(LOCAL_ONLY_KEY,'1')}catch(_){}location.reload();};
|
const localOnlyButton=gate.querySelector('#sunGateLocalOnlyV1');
|
||||||
|
if(window.CateriumAccessPolicy?.emergencyLocalEnabled){localOnlyButton.onclick=()=>{try{localStorage.setItem(LOCAL_ONLY_KEY,'1')}catch(_){}location.reload();};}
|
||||||
|
else localOnlyButton.closest('p').remove();
|
||||||
const emailInput=gate.querySelector('#sunGateEmailV3'),passwordInput=gate.querySelector('#sunGatePasswordV3'),password2Input=gate.querySelector('#sunGatePassword2V27'),companyInput=gate.querySelector('#sunGateCompanyV3'),submitBtn=gate.querySelector('#sunGateSubmitV3'),gateError=gate.querySelector('#sunGateErrorV3');let registerMode=false;
|
const emailInput=gate.querySelector('#sunGateEmailV3'),passwordInput=gate.querySelector('#sunGatePasswordV3'),password2Input=gate.querySelector('#sunGatePassword2V27'),companyInput=gate.querySelector('#sunGateCompanyV3'),submitBtn=gate.querySelector('#sunGateSubmitV3'),gateError=gate.querySelector('#sunGateErrorV3');let registerMode=false;
|
||||||
const setMode=(registration)=>{registerMode=Boolean(registration);gate.querySelector('#sunGateRegisterFieldsV27').hidden=!registerMode;gate.querySelector('#sunGateConfirmWrapV27').hidden=!registerMode;gate.querySelector('#sunGateTitleV3').textContent=registerMode?'Создать аккаунт Caterium':'Вход в Caterium';gate.querySelector('#sunGateSubtitleV3').textContent=registerMode?'Заполните данные — после регистрации Caterium откроется автоматически':'Введите данные своего аккаунта';submitBtn.textContent=registerMode?'Создать аккаунт':'Войти';gate.querySelector('#sunGateSwitchTextV27').textContent=registerMode?'Уже есть аккаунт?':'Нет аккаунта?';gate.querySelector('#sunGateSwitchV27').textContent=registerMode?'Войти':'Создать аккаунт';passwordInput.autocomplete=registerMode?'new-password':'current-password';gateError.textContent='';if(registerMode)setTimeout(()=>companyInput.focus(),30);else setTimeout(()=>emailInput.focus(),30)};
|
const setMode=(registration)=>{registerMode=Boolean(registration);gate.querySelector('#sunGateRegisterFieldsV27').hidden=!registerMode;gate.querySelector('#sunGateConfirmWrapV27').hidden=!registerMode;gate.querySelector('#sunGateTitleV3').textContent=registerMode?'Создать аккаунт Caterium':'Вход в Caterium';gate.querySelector('#sunGateSubtitleV3').textContent=registerMode?'Заполните данные — после регистрации Caterium откроется автоматически':'Введите данные своего аккаунта';submitBtn.textContent=registerMode?'Создать аккаунт':'Войти';gate.querySelector('#sunGateSwitchTextV27').textContent=registerMode?'Уже есть аккаунт?':'Нет аккаунта?';gate.querySelector('#sunGateSwitchV27').textContent=registerMode?'Войти':'Создать аккаунт';passwordInput.autocomplete=registerMode?'new-password':'current-password';gateError.textContent='';if(registerMode)setTimeout(()=>companyInput.focus(),30);else setTimeout(()=>emailInput.focus(),30)};
|
||||||
async function auth(){const c=client(),email=emailInput.value.trim(),password=passwordInput.value,password2=password2Input.value,companyName=String(companyInput.value||'').trim();if(!c){gateError.textContent='Облачный сервис не подключён.';return;}if(registerMode&&!companyName){gateError.textContent='Введите название компании.';return;}if(!email||password.length<6){gateError.textContent='Введите email и пароль минимум из 6 символов.';return;}if(registerMode&&password!==password2){gateError.textContent='Пароли не совпадают.';return;}submitBtn.disabled=true;gateError.textContent=registerMode?'Создаю аккаунт…':'Выполняю вход…';try{let r;if(registerMode){savePendingRegistration(email,companyName);const existing=await c.auth.signInWithPassword({email,password});if(!existing.error&&existing.data.session){r=existing;}else{r=await c.auth.signUp({email,password,options:{data:{company_name:companyName,registration_source:'caterium_public_signup'}}});if(r.error)throw r.error;if(!r.data.session){const retry=await c.auth.signInWithPassword({email,password});if(retry.error){clearPendingRegistration();throw new Error('Этот email уже зарегистрирован. Войдите в аккаунт или восстановите пароль.');}r=retry;}}}else r=await c.auth.signInWithPassword({email,password});if(r.error)throw r.error;const authUser=r.data.user||r.data.session?.user||null;const userId=authUser?.id||'';if(userId)sessionStorage.setItem(`sunCloudQuickPinUnlockedV3:${userId}`,'1');gateError.textContent=registerMode?'Аккаунт готов. Создаю компанию…':'Вход выполнен. Загружаю рабочую базу…';setTimeout(async()=>{await cloud()?.reloadMemberships?.();if(registerMode&&workspace()){clearPendingRegistration();gate.remove();document.body.classList.remove('sun-cloud-auth-required');await cloud()?.pull?.();applyNavPermissions();return;}if(getPendingRegistration(email)&&!workspace()){await finishPendingRegistration(gate,email);return;}if(workspace()){gate.remove();document.body.classList.remove('sun-cloud-auth-required');await cloud()?.pull?.();applyNavPermissions();}else{gate.remove();ensureAuthGate();}},180);}catch(err){gateError.textContent=errText(err,'Не удалось выполнить операцию.')}finally{submitBtn.disabled=false}}
|
async function auth(){const c=client(),email=emailInput.value.trim(),password=passwordInput.value,password2=password2Input.value,companyName=String(companyInput.value||'').trim();if(!c){gateError.textContent='Облачный сервис не подключён.';return;}if(registerMode&&!companyName){gateError.textContent='Введите название компании.';return;}if(!email||password.length<6){gateError.textContent='Введите email и пароль минимум из 6 символов.';return;}if(registerMode&&password!==password2){gateError.textContent='Пароли не совпадают.';return;}submitBtn.disabled=true;gateError.textContent=registerMode?'Создаю аккаунт…':'Выполняю вход…';try{let r;if(registerMode){savePendingRegistration(email,companyName);const existing=await c.auth.signInWithPassword({email,password});if(!existing.error&&existing.data.session){r=existing;}else{r=await c.auth.signUp({email,password,options:{data:{company_name:companyName,registration_source:'caterium_public_signup'}}});if(r.error)throw r.error;if(!r.data.session){const retry=await c.auth.signInWithPassword({email,password});if(retry.error){clearPendingRegistration();throw new Error('Этот email уже зарегистрирован. Войдите в аккаунт или восстановите пароль.');}r=retry;}}}else r=await c.auth.signInWithPassword({email,password});if(r.error)throw r.error;const authUser=r.data.user||r.data.session?.user||null;const userId=authUser?.id||'';if(userId)sessionStorage.setItem(`sunCloudQuickPinUnlockedV3:${userId}`,'1');gateError.textContent=registerMode?'Аккаунт готов. Создаю компанию…':'Вход выполнен. Загружаю рабочую базу…';setTimeout(async()=>{await cloud()?.reloadMemberships?.();if(registerMode&&workspace()){clearPendingRegistration();gate.remove();document.body.classList.remove('sun-cloud-auth-required');await cloud()?.pull?.();applyNavPermissions();return;}if(getPendingRegistration(email)&&!workspace()){await finishPendingRegistration(gate,email);return;}if(workspace()){gate.remove();document.body.classList.remove('sun-cloud-auth-required');await cloud()?.pull?.();applyNavPermissions();}else{gate.remove();ensureAuthGate();}},180);}catch(err){gateError.textContent=errText(err,'Не удалось выполнить операцию.')}finally{submitBtn.disabled=false}}
|
||||||
@ -4671,7 +4689,9 @@ window.SUN_LEGACY_CATALOG_V175=[];
|
|||||||
|
|
||||||
function clearWorkspaceLocalData(){
|
function clearWorkspaceLocalData(){
|
||||||
const preserve=new Set(['sunCloudV2Config','sunCloudClientIdV2','sunStaticMapGeocodeCacheV2']);
|
const preserve=new Set(['sunCloudV2Config','sunCloudClientIdV2','sunStaticMapGeocodeCacheV2']);
|
||||||
const keys=[];for(let i=0;i<localStorage.length;i++){const k=localStorage.key(i);if(k?.startsWith('sun')&&!preserve.has(k))keys.push(k);}keys.forEach(k=>localStorage.removeItem(k));
|
window.dispatchEvent(new CustomEvent('sun:cloud-tenant-changing'));
|
||||||
|
try{if(typeof orders!=='undefined')orders=[];if(typeof boxes!=='undefined')boxes=[];}catch(_){}
|
||||||
|
const keys=[];for(let i=0;i<localStorage.length;i++){const k=localStorage.key(i);if((k?.startsWith('sun')&&!preserve.has(k))||k==='cateriumClientsV1772'||k==='cateriumClientsServerV1773')keys.push(k);}keys.forEach(k=>localStorage.removeItem(k));
|
||||||
}
|
}
|
||||||
function seedWorkspace(){clearWorkspaceLocalData();localStorage.setItem('sunBoxes','[]');localStorage.setItem('sunOrders','[]');}
|
function seedWorkspace(){clearWorkspaceLocalData();localStorage.setItem('sunBoxes','[]');localStorage.setItem('sunOrders','[]');}
|
||||||
|
|
||||||
|
|||||||
12
public/core/access-policy.js
Normal file
12
public/core/access-policy.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
(()=>{
|
||||||
|
'use strict';
|
||||||
|
// Recovery switch: enabling this requires a deliberate application deployment.
|
||||||
|
// A saved browser preference alone can never enable anonymous production use.
|
||||||
|
const emergencyLocalEnabled=false;
|
||||||
|
const key='sunLocalOnlyModeV1';
|
||||||
|
if(!emergencyLocalEnabled){try{localStorage.removeItem(key)}catch(_){}}
|
||||||
|
window.CateriumAccessPolicy=Object.freeze({
|
||||||
|
emergencyLocalEnabled,
|
||||||
|
emergencyLocalActive(){try{return emergencyLocalEnabled&&localStorage.getItem(key)==='1'}catch(_){return false}}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@ -9,6 +9,7 @@
|
|||||||
const LEGACY_LOYALTY_KEY='sunClientLoyaltyV1';
|
const LEGACY_LOYALTY_KEY='sunClientLoyaltyV1';
|
||||||
const LEGACY_COMM_KEY='sunClientCommunicationV1';
|
const LEGACY_COMM_KEY='sunClientCommunicationV1';
|
||||||
const listeners=new Map();
|
const listeners=new Map();
|
||||||
|
let tenantChanging=false,tenantEpoch=0;
|
||||||
const clone=value=>{try{return structuredClone(value)}catch(_){return JSON.parse(JSON.stringify(value))}};
|
const clone=value=>{try{return structuredClone(value)}catch(_){return JSON.parse(JSON.stringify(value))}};
|
||||||
const safeJson=(raw,fallback)=>{try{const value=JSON.parse(raw);return value==null?fallback:value}catch(_){return fallback}};
|
const safeJson=(raw,fallback)=>{try{const value=JSON.parse(raw);return value==null?fallback:value}catch(_){return fallback}};
|
||||||
const emit=(topic,payload)=>{for(const fn of listeners.get(topic)||[]){try{fn(payload)}catch(error){console.error('[Caterium Data]',error)}}};
|
const emit=(topic,payload)=>{for(const fn of listeners.get(topic)||[]){try{fn(payload)}catch(error){console.error('[Caterium Data]',error)}}};
|
||||||
@ -98,6 +99,7 @@
|
|||||||
function readClientCache(){return readObject(CLIENT_CACHE_KEY)}
|
function readClientCache(){return readObject(CLIENT_CACHE_KEY)}
|
||||||
function writeClientCache(map){localStorage.setItem(CLIENT_CACHE_KEY,JSON.stringify(map||{}));return map}
|
function writeClientCache(map){localStorage.setItem(CLIENT_CACHE_KEY,JSON.stringify(map||{}));return map}
|
||||||
function buildClients(){
|
function buildClients(){
|
||||||
|
if(tenantChanging)return [];
|
||||||
const cached=readClientCache(),loyalty=readObject(LEGACY_LOYALTY_KEY),communication=readObject(LEGACY_COMM_KEY),people=new Map();
|
const cached=readClientCache(),loyalty=readObject(LEGACY_LOYALTY_KEY),communication=readObject(LEGACY_COMM_KEY),people=new Map();
|
||||||
const ensure=key=>{if(!people.has(key)){const old=cached[key]||{};people.set(key,{key,name:String(old.name||''),phone:String(old.phone||''),latestAddress:String(old.latestAddress||''),addresses:new Set(),orderIds:[],orderCount:0,totalSpent:0,latestStamp:'',loyalty:null,communication:null,serverVersion:Number(old.serverVersion||0)||null,serverUpdatedAt:String(old.serverUpdatedAt||'')})}return people.get(key)};
|
const ensure=key=>{if(!people.has(key)){const old=cached[key]||{};people.set(key,{key,name:String(old.name||''),phone:String(old.phone||''),latestAddress:String(old.latestAddress||''),addresses:new Set(),orderIds:[],orderCount:0,totalSpent:0,latestStamp:'',loyalty:null,communication:null,serverVersion:Number(old.serverVersion||0)||null,serverUpdatedAt:String(old.serverUpdatedAt||'')})}return people.get(key)};
|
||||||
for(const order of getOrders()){
|
for(const order of getOrders()){
|
||||||
@ -129,7 +131,7 @@
|
|||||||
}
|
}
|
||||||
return [...byKey.values()].sort((a,b)=>(b.orderCount-a.orderCount)||String(a.name||a.key).localeCompare(String(b.name||b.key),'ru'));
|
return [...byKey.values()].sort((a,b)=>(b.orderCount-a.orderCount)||String(a.name||a.key).localeCompare(String(b.name||b.key),'ru'));
|
||||||
}
|
}
|
||||||
function listClients(options={}){const source=typeof options==='string'?options:String(options?.source||'auto');if(source==='local'||source==='legacy')return clone(buildClients());if(source==='server')return clone(serverListClients());return clone(mergeClientSources())}
|
function listClients(options={}){if(tenantChanging)return [];const source=typeof options==='string'?options:String(options?.source||'auto');if(source==='local'||source==='legacy')return clone(buildClients());if(source==='server')return clone(serverListClients());return clone(mergeClientSources())}
|
||||||
function compareClientSources(){
|
function compareClientSources(){
|
||||||
const local=buildClients(),server=serverListClients(),merged=mergeClientSources(local,server),serverKeys=new Set(server.map(x=>x.key)),localKeys=new Set(local.map(x=>x.key));
|
const local=buildClients(),server=serverListClients(),merged=mergeClientSources(local,server),serverKeys=new Set(server.map(x=>x.key)),localKeys=new Set(local.map(x=>x.key));
|
||||||
const metricMismatches=local.map(item=>{const m=merged.find(x=>x.key===item.key);return !m||m.orderCount!==item.orderCount||Number(m.totalSpent)!==Number(item.totalSpent)?item.key:null}).filter(Boolean);
|
const metricMismatches=local.map(item=>{const m=merged.find(x=>x.key===item.key);return !m||m.orderCount!==item.orderCount||Number(m.totalSpent)!==Number(item.totalSpent)?item.key:null}).filter(Boolean);
|
||||||
@ -137,12 +139,15 @@
|
|||||||
}
|
}
|
||||||
let serverRefreshPromise=null;
|
let serverRefreshPromise=null;
|
||||||
async function refreshServerClients({force=false,reason='manual'}={}){
|
async function refreshServerClients({force=false,reason='manual'}={}){
|
||||||
|
if(tenantChanging)return {status:'switching'};
|
||||||
if(serverRefreshPromise)return serverRefreshPromise;if(!isSignedIn())return {status:'local',diagnostics:compareClientSources()};
|
if(serverRefreshPromise)return serverRefreshPromise;if(!isSignedIn())return {status:'local',diagnostics:compareClientSources()};
|
||||||
const c=cloud()?.getClient?.(),ws=workspace();if(!c?.rpc||!ws?.id)return {status:'offline',diagnostics:compareClientSources()};
|
const c=cloud()?.getClient?.(),ws=workspace();if(!c?.rpc||!ws?.id)return {status:'offline',diagnostics:compareClientSources()};
|
||||||
const cache=readServerClientCache(),age=cache?.fetchedAt?Date.now()-Date.parse(cache.fetchedAt):Infinity;if(!force&&String(cache?.workspaceId||'')===String(ws.id)&&age>=0&&age<15000)return {status:'cached',count:currentServerRows().length,diagnostics:compareClientSources()};
|
const cache=readServerClientCache(),age=cache?.fetchedAt?Date.now()-Date.parse(cache.fetchedAt):Infinity;if(!force&&String(cache?.workspaceId||'')===String(ws.id)&&age>=0&&age<15000)return {status:'cached',count:currentServerRows().length,diagnostics:compareClientSources()};
|
||||||
|
const epoch=tenantEpoch,userId=session()?.user?.id;
|
||||||
serverRefreshPromise=(async()=>{
|
serverRefreshPromise=(async()=>{
|
||||||
try{
|
try{
|
||||||
const {data,error}=await c.rpc('sun_v17_clients_snapshot_v1773',{p_workspace:ws.id});if(error)throw error;const rows=Array.isArray(data)?data:[];
|
const {data,error}=await c.rpc('sun_v17_clients_snapshot_v1773',{p_workspace:ws.id});if(error)throw error;const rows=Array.isArray(data)?data:[];
|
||||||
|
if(epoch!==tenantEpoch||tenantChanging||workspace()?.id!==ws.id||session()?.user?.id!==userId)return {status:'stale'};
|
||||||
storage.write(SERVER_CLIENT_CACHE_KEY,{workspaceId:String(ws.id),fetchedAt:new Date().toISOString(),rows:clone(rows)},{silent:true});
|
storage.write(SERVER_CLIENT_CACHE_KEY,{workspaceId:String(ws.id),fetchedAt:new Date().toISOString(),rows:clone(rows)},{silent:true});
|
||||||
const diagnostics=compareClientSources();emit('clients',{reason:`server:${reason}`,changedKeys:rows.map(r=>String(r?.client_key||'')).filter(Boolean),clients:listClients(),diagnostics});
|
const diagnostics=compareClientSources();emit('clients',{reason:`server:${reason}`,changedKeys:rows.map(r=>String(r?.client_key||'')).filter(Boolean),clients:listClients(),diagnostics});
|
||||||
try{window.dispatchEvent(new CustomEvent('caterium:clients-server-refresh',{detail:{reason,count:rows.length,diagnostics}}))}catch(_){}
|
try{window.dispatchEvent(new CustomEvent('caterium:clients-server-refresh',{detail:{reason,count:rows.length,diagnostics}}))}catch(_){}
|
||||||
@ -160,6 +165,7 @@
|
|||||||
function persistClientCacheFromList(list){const map={};for(const item of list||[])map[item.key]=cacheCore(item);writeClientCache(map);return map}
|
function persistClientCacheFromList(list){const map={};for(const item of list||[])map[item.key]=cacheCore(item);writeClientCache(map);return map}
|
||||||
function changedClientKeys(before,next){const keys=new Set([...Object.keys(before||{}),...Object.keys(next||{})]),changed=[];for(const key of keys){if(JSON.stringify(before?.[key]||null)!==JSON.stringify(next?.[key]||null))changed.push(key)}return changed}
|
function changedClientKeys(before,next){const keys=new Set([...Object.keys(before||{}),...Object.keys(next||{})]),changed=[];for(const key of keys){if(JSON.stringify(before?.[key]||null)!==JSON.stringify(next?.[key]||null))changed.push(key)}return changed}
|
||||||
function adoptLegacy({pushServer=false,reason='legacy-adopt'}={}){
|
function adoptLegacy({pushServer=false,reason='legacy-adopt'}={}){
|
||||||
|
if(tenantChanging)return {changed:0,keys:[],clients:[]};
|
||||||
const before=readClientCache(),list=buildClients(),after={};for(const item of list)after[item.key]=cacheCore(item);const changed=changedClientKeys(before,after);writeClientCache(after);emit('clients',{reason,changedKeys:changed,clients:clone(list)});if(pushServer&&changed.length)scheduleServerPush(changed);return {changed:changed.length,keys:changed,clients:clone(list)};
|
const before=readClientCache(),list=buildClients(),after={};for(const item of list)after[item.key]=cacheCore(item);const changed=changedClientKeys(before,after);writeClientCache(after);emit('clients',{reason,changedKeys:changed,clients:clone(list)});if(pushServer&&changed.length)scheduleServerPush(changed);return {changed:changed.length,keys:changed,clients:clone(list)};
|
||||||
}
|
}
|
||||||
function upsertClient(profile,{pushServer=true,reason='client.upsert'}={}){
|
function upsertClient(profile,{pushServer=true,reason='client.upsert'}={}){
|
||||||
@ -173,16 +179,19 @@
|
|||||||
function setClientCommunication(key,value,{pushServer=true}={}){writeLegacyMap(LEGACY_COMM_KEY,String(key),value);adoptLegacy({pushServer:false,reason:'client.communication'});if(pushServer)scheduleServerPush([String(key)]);return clone(getClient(key)?.communication||null)}
|
function setClientCommunication(key,value,{pushServer=true}={}){writeLegacyMap(LEGACY_COMM_KEY,String(key),value);adoptLegacy({pushServer:false,reason:'client.communication'});if(pushServer)scheduleServerPush([String(key)]);return clone(getClient(key)?.communication||null)}
|
||||||
function serverPayload(profile){return {schemaVersion:1,key:profile.key,identity:{name:String(profile.name||''),phone:String(profile.phone||''),latestAddress:String(profile.latestAddress||'')},loyalty:profile.loyalty?clone(profile.loyalty):null,communication:profile.communication?clone(profile.communication):null,source:'data-layer-v1772',updatedAt:new Date().toISOString()}}
|
function serverPayload(profile){return {schemaVersion:1,key:profile.key,identity:{name:String(profile.name||''),phone:String(profile.phone||''),latestAddress:String(profile.latestAddress||'')},loyalty:profile.loyalty?clone(profile.loyalty):null,communication:profile.communication?clone(profile.communication):null,source:'data-layer-v1772',updatedAt:new Date().toISOString()}}
|
||||||
async function pushClient(key,knownProfile){
|
async function pushClient(key,knownProfile){
|
||||||
|
if(tenantChanging)return {status:'switching',key};
|
||||||
const profile=knownProfile||getClient(key);if(!profile)return {status:'missing',key};if(!isSignedIn())return {status:'local',key};if(isSupportReadOnly()||!canWrite('clients.edit'))return {status:'read-only',key};
|
const profile=knownProfile||getClient(key);if(!profile)return {status:'missing',key};if(!isSignedIn())return {status:'local',key};if(isSupportReadOnly()||!canWrite('clients.edit'))return {status:'read-only',key};
|
||||||
const c=cloud()?.getClient?.(),ws=workspace();if(!c?.rpc||!ws?.id)return {status:'offline',key};
|
const c=cloud()?.getClient?.(),ws=workspace();if(!c?.rpc||!ws?.id)return {status:'offline',key};
|
||||||
|
const epoch=tenantEpoch,userId=session()?.user?.id;
|
||||||
const cached=readClientCache()[key]||{},expected=Number(cached.serverVersion||0)||null;
|
const cached=readClientCache()[key]||{},expected=Number(cached.serverVersion||0)||null;
|
||||||
const {data,error}=await c.rpc('sun_v17_save_client_v1772',{p_workspace:ws.id,p_client_key:key,p_profile:serverPayload(profile),p_expected_version:expected,p_client_id:cloud()?.getClientId?.()||'browser'});if(error)throw error;
|
const {data,error}=await c.rpc('sun_v17_save_client_v1772',{p_workspace:ws.id,p_client_key:key,p_profile:serverPayload(profile),p_expected_version:expected,p_client_id:cloud()?.getClientId?.()||'browser'});if(error)throw error;
|
||||||
|
if(epoch!==tenantEpoch||tenantChanging||workspace()?.id!==ws.id||session()?.user?.id!==userId)return {status:'stale',key};
|
||||||
const row=Array.isArray(data)?data[0]:data;if(row){const map=readClientCache();map[key]={...(map[key]||cacheCore(profile)),serverVersion:Number(row.version||0)||null,serverUpdatedAt:String(row.updated_at||'')};writeClientCache(map)}
|
const row=Array.isArray(data)?data[0]:data;if(row){const map=readClientCache();map[key]={...(map[key]||cacheCore(profile)),serverVersion:Number(row.version||0)||null,serverUpdatedAt:String(row.updated_at||'')};writeClientCache(map)}
|
||||||
return {status:'saved',key,version:Number(row?.version||0)||null};
|
return {status:'saved',key,version:Number(row?.version||0)||null};
|
||||||
}
|
}
|
||||||
const pendingServerKeys=new Set();let serverPushTimer=0;
|
const pendingServerKeys=new Set();let serverPushTimer=0;
|
||||||
function scheduleServerPush(keys){for(const key of keys||[])if(key)pendingServerKeys.add(String(key));if(serverPushTimer)return;serverPushTimer=setTimeout(async()=>{serverPushTimer=0;const keys=[...pendingServerKeys];pendingServerKeys.clear();for(const key of keys){try{await pushClient(key)}catch(error){console.warn('[Caterium Clients] server save failed',key,error?.message||error)}}},250)}
|
function scheduleServerPush(keys){for(const key of keys||[])if(key)pendingServerKeys.add(String(key));if(serverPushTimer)return;serverPushTimer=setTimeout(async()=>{serverPushTimer=0;const keys=[...pendingServerKeys];pendingServerKeys.clear();for(const key of keys){try{await pushClient(key)}catch(error){console.warn('[Caterium Clients] server save failed',key,error?.message||error)}}},250)}
|
||||||
async function pushAllClients(){const result=[];for(const profile of listClients()){try{result.push(await pushClient(profile.key,profile))}catch(error){result.push({status:'error',key:profile.key,error:String(error?.message||error)})}}return result}
|
async function pushAllClients(){const result=[],epoch=tenantEpoch,workspaceId=workspace()?.id;for(const profile of listClients()){if(tenantChanging||epoch!==tenantEpoch||workspace()?.id!==workspaceId)break;try{result.push(await pushClient(profile.key,profile))}catch(error){result.push({status:'error',key:profile.key,error:String(error?.message||error)})}}return result}
|
||||||
|
|
||||||
let bridgeSuppressed=false;
|
let bridgeSuppressed=false;
|
||||||
function installLegacyClientBridge(){
|
function installLegacyClientBridge(){
|
||||||
@ -207,6 +216,8 @@
|
|||||||
window.CateriumDataV1770=api;
|
window.CateriumDataV1770=api;
|
||||||
window.CateriumData=api;
|
window.CateriumData=api;
|
||||||
installLegacyClientBridge();
|
installLegacyClientBridge();
|
||||||
|
window.addEventListener('sun:cloud-tenant-changing',()=>{tenantChanging=true;tenantEpoch++;clearTimeout(serverPushTimer);serverPushTimer=0;pendingServerKeys.clear()});
|
||||||
|
window.addEventListener('sun:cloud-state-applied',()=>{tenantChanging=false});
|
||||||
adoptLegacy({pushServer:false,reason:'boot'});
|
adoptLegacy({pushServer:false,reason:'boot'});
|
||||||
window.addEventListener('sun:cloud-permissions-changed',()=>setTimeout(()=>{clientsRepo.refreshServer({force:true,reason:'permissions'}).catch(()=>{});clientsRepo.pushAll().catch(()=>{})},800));
|
window.addEventListener('sun:cloud-permissions-changed',()=>setTimeout(()=>{clientsRepo.refreshServer({force:true,reason:'permissions'}).catch(()=>{});clientsRepo.pushAll().catch(()=>{})},800));
|
||||||
window.addEventListener('sun:cloud-sync-complete',()=>setTimeout(()=>{clientsRepo.adoptLegacy({pushServer:true,reason:'cloud-sync'});clientsRepo.refreshServer({force:true,reason:'cloud-sync'}).catch(()=>{})},500));
|
window.addEventListener('sun:cloud-sync-complete',()=>setTimeout(()=>{clientsRepo.adoptLegacy({pushServer:true,reason:'cloud-sync'});clientsRepo.refreshServer({force:true,reason:'cloud-sync'}).catch(()=>{})},500));
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
(()=>{
|
(()=>{
|
||||||
'use strict';
|
'use strict';
|
||||||
const VERSION='17.7.3';
|
const VERSION='17.7.3';
|
||||||
const RELEASE='20260917-empty-catalog';
|
const RELEASE='20260917-account-access';
|
||||||
|
|
||||||
const hasStoredSession=()=>{try{return Object.keys(localStorage).some(k=>/^sb-.*-auth-token$/i.test(k)&&String(localStorage.getItem(k)||'').length>20)}catch(_){return false}};
|
const hasStoredSession=()=>{try{return Object.keys(localStorage).some(k=>/^sb-.*-auth-token$/i.test(k)&&String(localStorage.getItem(k)||'').length>20)}catch(_){return false}};
|
||||||
function installAuthBoot(){
|
function installAuthBoot(){
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -1,7 +1,7 @@
|
|||||||
const CACHE='sun-catering-pwa-v86-20260917-empty-catalog';
|
const CACHE='sun-catering-pwa-v87-20260917-account-access';
|
||||||
const VERSION='20260917-empty-catalog';
|
const VERSION='20260917-account-access';
|
||||||
const CORE=[
|
const CORE=[
|
||||||
'./','./index.html',`./core/import-archive.js?v=${VERSION}`,`./core/company-branding.js?v=${VERSION}`,`./core/signature-offer-pdf-v18.js?v=${VERSION}`,`./core/brand-theme.js?v=${VERSION}`,
|
'./','./index.html',`./core/access-policy.js?v=${VERSION}`,`./core/import-archive.js?v=${VERSION}`,`./core/company-branding.js?v=${VERSION}`,`./core/signature-offer-pdf-v18.js?v=${VERSION}`,`./core/brand-theme.js?v=${VERSION}`,
|
||||||
`./core/sun-safe.js?v=${VERSION}`,`./core/performance.js?v=${VERSION}`,`./core/account-center-v1780.js?v=${VERSION}`,`./core/login-signature-v1776.js?v=${VERSION}`,`./core/data-layer-v1773.js?v=${VERSION}`,`./core/server-automation-v1770.js?v=${VERSION}`,`./core/hotfix-v1763.js?v=${VERSION}`,`./core/ops-ux-v1762.js?v=${VERSION}`,`./core/ux-fixes-v1764.js?v=${VERSION}`,`./core/pdf-engine.js?v=${VERSION}`,`./core/classic-offer-pdf-v1767.js?v=${VERSION}`,`./core/developer-console-v1768.js?v=${VERSION}`,`./core/offer-workspace-v1769.js?v=${VERSION}`,`./core/auth-security-v1774.js?v=${VERSION}`,`./core/order-enhancements-v1775.js?v=${VERSION}`,`./legacy/bootstrap.js?v=${VERSION}`,`./app-runtime.js?v=${VERSION}`,
|
`./core/sun-safe.js?v=${VERSION}`,`./core/performance.js?v=${VERSION}`,`./core/account-center-v1780.js?v=${VERSION}`,`./core/login-signature-v1776.js?v=${VERSION}`,`./core/data-layer-v1773.js?v=${VERSION}`,`./core/server-automation-v1770.js?v=${VERSION}`,`./core/hotfix-v1763.js?v=${VERSION}`,`./core/ops-ux-v1762.js?v=${VERSION}`,`./core/ux-fixes-v1764.js?v=${VERSION}`,`./core/pdf-engine.js?v=${VERSION}`,`./core/classic-offer-pdf-v1767.js?v=${VERSION}`,`./core/developer-console-v1768.js?v=${VERSION}`,`./core/offer-workspace-v1769.js?v=${VERSION}`,`./core/auth-security-v1774.js?v=${VERSION}`,`./core/order-enhancements-v1775.js?v=${VERSION}`,`./legacy/bootstrap.js?v=${VERSION}`,`./app-runtime.js?v=${VERSION}`,
|
||||||
'./offer-gallery/001.jpg','./offer-gallery/002.jpg',
|
'./offer-gallery/001.jpg','./offer-gallery/002.jpg',
|
||||||
'./catalog/001.jpg','./catalog/002.jpg','./catalog/003.jpg',
|
'./catalog/001.jpg','./catalog/002.jpg','./catalog/003.jpg',
|
||||||
@ -9,7 +9,7 @@ const CORE=[
|
|||||||
'./offer-templates/thumb-light.jpg','./offer-templates/thumb-editorial-grid.jpg','./offer-templates/thumb-midnight-glass.jpg','./offer-templates/thumb-emerald-gold.jpg'
|
'./offer-templates/thumb-light.jpg','./offer-templates/thumb-editorial-grid.jpg','./offer-templates/thumb-midnight-glass.jpg','./offer-templates/thumb-emerald-gold.jpg'
|
||||||
];
|
];
|
||||||
const CRITICAL_FRESH=new Set([
|
const CRITICAL_FRESH=new Set([
|
||||||
'/core/import-archive.js','/core/company-branding.js','/core/brand-theme.js','/core/sun-safe.js','/core/performance.js','/core/account-center-v1780.js','/core/login-signature-v1776.js','/core/auth-security-v1774.js','/legacy/bootstrap.js','/app-runtime.js'
|
'/core/access-policy.js','/core/import-archive.js','/core/company-branding.js','/core/brand-theme.js','/core/sun-safe.js','/core/performance.js','/core/account-center-v1780.js','/core/login-signature-v1776.js','/core/auth-security-v1774.js','/legacy/bootstrap.js','/app-runtime.js'
|
||||||
]);
|
]);
|
||||||
self.addEventListener('install',event=>{
|
self.addEventListener('install',event=>{
|
||||||
event.waitUntil(caches.open(CACHE).then(cache=>cache.addAll(CORE)).then(()=>self.skipWaiting()));
|
event.waitUntil(caches.open(CACHE).then(cache=>cache.addAll(CORE)).then(()=>self.skipWaiting()));
|
||||||
|
|||||||
83
tests/account-access.spec.mjs
Normal file
83
tests/account-access.spec.mjs
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import {test,expect} from '@playwright/test';
|
||||||
|
|
||||||
|
test('anonymous login stays closed with an old local-mode preference and an unavailable backend',async({page})=>{
|
||||||
|
await page.addInitScript(()=>localStorage.setItem('sunLocalOnlyModeV1','1'));
|
||||||
|
await page.route('**://api.caterium.ru/**',r=>r.abort());
|
||||||
|
await page.route('**://*.supabase.co/**',r=>r.abort());
|
||||||
|
await page.goto('/index.html',{waitUntil:'domcontentloaded'});
|
||||||
|
await expect(page.locator('#sunGateEmailV3')).toBeVisible();
|
||||||
|
await expect(page.locator('#sunGatePasswordV3')).toBeVisible();
|
||||||
|
await expect(page.locator('#sunGateLocalOnlyV1')).toHaveCount(0);
|
||||||
|
await expect(page.locator('body > header')).toBeHidden();
|
||||||
|
expect(await page.evaluate(()=>localStorage.getItem('sunLocalOnlyModeV1'))).toBeNull();
|
||||||
|
expect(await page.evaluate(()=>{localStorage.setItem('sunLocalOnlyModeV1','1');return window.SunAdminRBACV3.hasPermission('orders.create')})).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workspace changes isolate client caches and ignore delayed responses from the previous company',async({page})=>{
|
||||||
|
await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:'<!doctype html><html><body></body></html>'}));
|
||||||
|
await page.goto('/index.html');
|
||||||
|
await page.addScriptTag({url:'/core/sun-safe.js'});
|
||||||
|
const runtime=fs.readFileSync('public/app-runtime.js','utf8');
|
||||||
|
let cloud=runtime.slice(runtime.indexOf('/* ===== MODULE: cloud-sync-v2.js'),runtime.indexOf('/* ===== MODULE: admin-rbac-v3.js'));
|
||||||
|
cloud=cloud.replace('async function boot(){','async function boot(){return;');
|
||||||
|
cloud=cloud.replace('window.SunCloudV2={',`window.SunCloudV2={
|
||||||
|
testSwitch:async id=>{workspace=id?{id}:null;return switchTenantLocal(id)},
|
||||||
|
testRefresh:refreshRuntime,`);
|
||||||
|
await page.addScriptTag({content:'var orders=[];var boxes=[];'+cloud});
|
||||||
|
expect(await page.evaluate(async()=>{
|
||||||
|
localStorage.setItem('cateriumClientsV1772','{"old-anonymous":{"name":"Old anonymous customer"}}');
|
||||||
|
localStorage.setItem('sunBoxes','[{"id":"old-anonymous-box"}]');
|
||||||
|
await window.SunCloudV2.testSwitch('company-a');
|
||||||
|
return {clients:localStorage.getItem('cateriumClientsV1772'),catalog:localStorage.getItem('sunBoxes')};
|
||||||
|
})).toEqual({clients:null,catalog:null});
|
||||||
|
await page.evaluate(async()=>{
|
||||||
|
await window.SunCloudV2.adoptCurrentLocalAsWorkspace('company-a');
|
||||||
|
await window.SunCloudV2.testSwitch('company-a');
|
||||||
|
orders=[{id:1,contact:'Клиент A',phone:'79990000001',address:'Адрес A',total:1234}];
|
||||||
|
boxes=[{id:'a',name:'Бокс A',price:1234,photo:'private-photo-a'}];
|
||||||
|
localStorage.setItem('sunOrders',JSON.stringify(orders));localStorage.setItem('sunBoxes',JSON.stringify(boxes));
|
||||||
|
localStorage.setItem('sunClientCommunicationV1','{}');
|
||||||
|
localStorage.setItem('cateriumClientsV1772',JSON.stringify({'p:79990000002':{key:'p:79990000002',name:'Ручной клиент A',phone:'79990000002',latestAddress:'Ещё адрес A'}}));
|
||||||
|
localStorage.setItem('sunTelegramImportArchiveV1','{"messages":[{"text":"Private A"}]}');
|
||||||
|
window.pendingRpc=[];window.rpcCalls=[];
|
||||||
|
window.SunCloudV2.getSession=()=>({user:{id:'test-user'}});
|
||||||
|
window.SunCloudV2.hasPermission=()=>true;
|
||||||
|
window.SunCloudV2.getClient=()=>({rpc:(name,args)=>{window.rpcCalls.push({name,workspace:args.p_workspace});return new Promise(resolve=>window.pendingRpc.push(()=>resolve({data:name.includes('snapshot')?[{client_key:'old-server-a',name:'Сервер A'}]:{version:99}})))}});
|
||||||
|
});
|
||||||
|
await page.addScriptTag({url:'/core/data-layer-v1773.js'});
|
||||||
|
const result=await page.evaluate(async()=>{
|
||||||
|
const c=window.SunCloudV2,d=window.CateriumDataV1773;
|
||||||
|
const refresh=d.clients.refreshServer({force:true}),push=d.clients.pushAll();
|
||||||
|
await c.testSwitch('company-b');c.testRefresh();
|
||||||
|
window.pendingRpc.splice(0).forEach(resolve=>resolve());
|
||||||
|
const staleResults=[await refresh,await push];
|
||||||
|
await new Promise(resolve=>setTimeout(resolve,300));
|
||||||
|
const empty={clients:d.clients.list().length,orders:d.orders.list().length,boxes:d.catalog.list().length,archive:localStorage.getItem('sunTelegramImportArchiveV1'),cache:localStorage.getItem('cateriumClientsV1772'),server:localStorage.getItem('cateriumClientsServerV1773')};
|
||||||
|
await c.testSwitch('company-a');c.testRefresh();
|
||||||
|
return {empty,staleResults,calls:window.rpcCalls,restored:{clients:d.clients.list().map(c=>c.name).sort(),orders:d.orders.list().length,boxes:d.catalog.list(),archive:localStorage.getItem('sunTelegramImportArchiveV1')}};
|
||||||
|
});
|
||||||
|
expect(result.empty).toEqual({clients:0,orders:0,boxes:0,archive:null,cache:null,server:null});
|
||||||
|
expect(result.staleResults[0].status).toBe('stale');
|
||||||
|
expect(result.staleResults[1]).toHaveLength(1);
|
||||||
|
expect(result.calls.every(c=>c.workspace==='company-a')).toBe(true);
|
||||||
|
expect(result.restored.clients).toEqual(['Клиент A','Ручной клиент A']);
|
||||||
|
expect(result.restored.orders).toBe(1);
|
||||||
|
expect(result.restored.boxes[0].photo).toBe('private-photo-a');
|
||||||
|
expect(result.restored.archive).toContain('Private A');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a developer with a personal company opens the ordinary app before choosing developer tools',async({page})=>{
|
||||||
|
await page.route('**://api.caterium.ru/**',r=>r.abort());
|
||||||
|
await page.route('**://*.supabase.co/**',r=>r.abort());
|
||||||
|
await page.goto('/index.html',{waitUntil:'domcontentloaded'});
|
||||||
|
await expect(page.locator('#sunGateEmailV3')).toBeVisible();
|
||||||
|
await page.evaluate(()=>{
|
||||||
|
window.SunCloudV2={getSession:()=>({user:{id:'developer-test',email:'developer@example.invalid'}}),getWorkspace:()=>({id:'developer-workspace',name:'Тестовая компания',role:'admin'}),status:()=>({connected:true,signedIn:true,membershipsLoaded:true}),hasPermission:()=>true,getClient:()=>null};
|
||||||
|
window.dispatchEvent(new Event('sun:cloud-permissions-changed'));
|
||||||
|
});
|
||||||
|
await expect(page.locator('#sunCloudAuthGateV3')).toHaveCount(0);
|
||||||
|
await expect(page.locator('body > header')).toBeVisible();
|
||||||
|
await expect(page.locator('#sun-developer-console-v22.on')).toHaveCount(0);
|
||||||
|
expect(await page.evaluate(()=>({orders:orders.length,boxes:boxes.length}))).toEqual({orders:0,boxes:0});
|
||||||
|
});
|
||||||
@ -2,7 +2,7 @@ import { defineConfig, devices } from '@playwright/test';
|
|||||||
import {fileURLToPath} from 'node:url';
|
import {fileURLToPath} from 'node:url';
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir:'.',
|
testDir:'.',
|
||||||
testMatch:['app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs'],
|
testMatch:['app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs','account-access.spec.mjs'],
|
||||||
timeout:30000,
|
timeout:30000,
|
||||||
use:{baseURL:'http://127.0.0.1:4173'},
|
use:{baseURL:'http://127.0.0.1:4173'},
|
||||||
webServer:{command:'npx http-server public -p 4173 -c-1',cwd:fileURLToPath(new URL('../',import.meta.url)),port:4173,reuseExistingServer:true},
|
webServer:{command:'npx http-server public -p 4173 -c-1',cwd:fileURLToPath(new URL('../',import.meta.url)),port:4173,reuseExistingServer:true},
|
||||||
|
|||||||
@ -14,8 +14,8 @@ check(!index.includes('offer-gallery-data.js'),'blocking Base64 gallery absent')
|
|||||||
check((runtime.match(/\/Type \/Catalog/g)||[]).length===0,'runtime contains no PDF binary writer');
|
check((runtime.match(/\/Type \/Catalog/g)||[]).length===0,'runtime contains no PDF binary writer');
|
||||||
check(read('core/pdf-engine.js').includes('595.28')&&read('core/pdf-engine.js').includes('841.89'),'PDF engine uses A4 MediaBox');
|
check(read('core/pdf-engine.js').includes('595.28')&&read('core/pdf-engine.js').includes('841.89'),'PDF engine uses A4 MediaBox');
|
||||||
check([...index.matchAll(/@page\{([^}]*)\}/g)].every(m=>/size:A4/i.test(m[1])),'compact @page rules use A4');
|
check([...index.matchAll(/@page\{([^}]*)\}/g)].every(m=>/size:A4/i.test(m[1])),'compact @page rules use A4');
|
||||||
check(sw.includes('v86-20260917-empty-catalog')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js')&&sw.includes('offer-workspace-v1769.js'),'service worker cache is v17.7.3');
|
check(sw.includes('v87-20260917-account-access')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js')&&sw.includes('offer-workspace-v1769.js'),'service worker cache is v17.7.3');
|
||||||
check(index.includes('20260917-empty-catalog')&&index.includes('classic-offer-pdf-v1767.js')&&!index.includes('20260907-v17-6-0-stability-security'),'index cache-busting points to v17.7.3');
|
check(index.includes('20260917-account-access')&&index.includes('classic-offer-pdf-v1767.js')&&!index.includes('20260907-v17-6-0-stability-security'),'index cache-busting points to v17.7.3');
|
||||||
check(performance.includes('SunAttachmentGuard')&&performance.includes('TARGET=2*1024*1024'),'chat photo auto-compression is versioned');
|
check(performance.includes('SunAttachmentGuard')&&performance.includes('TARGET=2*1024*1024'),'chat photo auto-compression is versioned');
|
||||||
check(performance.includes("rpc('sun_dev_dashboard')")&&performance.includes('server_size')&&performance.includes('storage_size'),'Developer Console server/storage counters are versioned');
|
check(performance.includes("rpc('sun_dev_dashboard')")&&performance.includes('server_size')&&performance.includes('storage_size'),'Developer Console server/storage counters are versioned');
|
||||||
check(performance.includes('MEMORY_REFRESH_MS=30000')&&performance.includes('MEMORY_TIMEOUT_MS=8000')&&performance.includes('memoryPromise'),'Developer Console memory refresh is bounded');
|
check(performance.includes('MEMORY_REFRESH_MS=30000')&&performance.includes('MEMORY_TIMEOUT_MS=8000')&&performance.includes('memoryPromise'),'Developer Console memory refresh is bounded');
|
||||||
@ -39,7 +39,7 @@ check(!/sb_secret_[A-Za-z0-9_-]{20,}|service_role\s*[:=]\s*["'][A-Za-z0-9._-]{30
|
|||||||
check(lock.version===pkg.version&&lock.packages?.['']?.version===pkg.version,'package.json and package-lock.json versions match');
|
check(lock.version===pkg.version&&lock.packages?.['']?.version===pkg.version,'package.json and package-lock.json versions match');
|
||||||
check(releaseManifest.version===`v${pkg.version}`,'release manifest version matches package.json');
|
check(releaseManifest.version===`v${pkg.version}`,'release manifest version matches package.json');
|
||||||
check(releaseManifest.channel==='production','release manifest channel is production');
|
check(releaseManifest.channel==='production','release manifest channel is production');
|
||||||
check(String(releaseManifest.pwaCache||'').includes('v86-20260917-empty-catalog'),'release manifest points to current PWA cache');
|
check(String(releaseManifest.pwaCache||'').includes('v87-20260917-account-access'),'release manifest points to current PWA cache');
|
||||||
check(['17.6.2','17.6.3','17.6.4','17.6.5','17.6.6','17.6.7','17.6.8','17.6.9','17.7.0','17.7.1','17.7.2','17.7.3'].every(v=>fs.existsSync(path.join(root,`docs/releases/V${v}-CHANGES.txt`))),'release notes exist through v17.7.3');
|
check(['17.6.2','17.6.3','17.6.4','17.6.5','17.6.6','17.6.7','17.6.8','17.6.9','17.7.0','17.7.1','17.7.2','17.7.3'].every(v=>fs.existsSync(path.join(root,`docs/releases/V${v}-CHANGES.txt`))),'release notes exist through v17.7.3');
|
||||||
check(runtime.includes('CLOUD_RPC_TIMEOUT_MS=12000')&&runtime.includes('CLOUD_CONFLICT_MAX_RETRIES=4')&&runtime.includes('retryCount'),'cloud sync has timeout and capped exponential conflict retries');
|
check(runtime.includes('CLOUD_RPC_TIMEOUT_MS=12000')&&runtime.includes('CLOUD_CONFLICT_MAX_RETRIES=4')&&runtime.includes('retryCount'),'cloud sync has timeout and capped exponential conflict retries');
|
||||||
check(runtime.includes("const VERSION = '17.7.3'")&&runtime.includes('ERROR_DEDUPE_MS=5*60*1000')&&runtime.includes('mirrorBusy=false')&&runtime.includes('backupBusy=false'),'stability logger uses current version, dedupe and single-flight guards');
|
check(runtime.includes("const VERSION = '17.7.3'")&&runtime.includes('ERROR_DEDUPE_MS=5*60*1000')&&runtime.includes('mirrorBusy=false')&&runtime.includes('backupBusy=false'),'stability logger uses current version, dedupe and single-flight guards');
|
||||||
@ -65,8 +65,8 @@ check(offerWorkspace.includes('PDF и предпросмотр')&&offerWorkspace
|
|||||||
check(offerWorkspace.includes('SunClassicOfferPDFV1767')&&offerWorkspace.includes('finalGallery=galleryFor'),'custom gallery is injected into PDF renderer');
|
check(offerWorkspace.includes('SunClassicOfferPDFV1767')&&offerWorkspace.includes('finalGallery=galleryFor'),'custom gallery is injected into PDF renderer');
|
||||||
check(releaseManifest.offerWorkspaceTabs===true&&releaseManifest.offerTemplatesSeparateTab===true&&releaseManifest.offerTwoCustomGalleryPhotos===true,'release manifest records offer workspace changes');
|
check(releaseManifest.offerWorkspaceTabs===true&&releaseManifest.offerTemplatesSeparateTab===true&&releaseManifest.offerTwoCustomGalleryPhotos===true,'release manifest records offer workspace changes');
|
||||||
check(pkg.version==='17.7.3','package version is v17.7.3');
|
check(pkg.version==='17.7.3','package version is v17.7.3');
|
||||||
check(index.includes('20260917-empty-catalog'),'index cache bust is v17.7.3');
|
check(index.includes('20260917-account-access'),'index cache bust is v17.7.3');
|
||||||
check(sw.includes('v86-20260917-empty-catalog')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js'),'PWA caches v17.7.3 client foundation modules');
|
check(sw.includes('v87-20260917-account-access')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js'),'PWA caches v17.7.3 client foundation modules');
|
||||||
check(fs.existsSync(path.join(root,'public/core/data-layer-v1773.js'))&&fs.existsSync(path.join(root,'public/core/server-automation-v1770.js')),'data layer and server automation modules exist');
|
check(fs.existsSync(path.join(root,'public/core/data-layer-v1773.js'))&&fs.existsSync(path.join(root,'public/core/server-automation-v1770.js')),'data layer and server automation modules exist');
|
||||||
check(ux.includes('CateriumServerAutomationV1770?.enabled'),'cloud browser auto completion is disabled when server automation is active');
|
check(ux.includes('CateriumServerAutomationV1770?.enabled'),'cloud browser auto completion is disabled when server automation is active');
|
||||||
check(runtime.includes("const VERSION = '17.7.3'")&&runtime.includes("v17.7.3 Clients Server Read"),'stability logger reports v17.7.3');
|
check(runtime.includes("const VERSION = '17.7.3'")&&runtime.includes("v17.7.3 Clients Server Read"),'stability logger reports v17.7.3');
|
||||||
|
|||||||
@ -28,8 +28,8 @@ if(current!==113)fail(`current catalog photo count ${current}, expected 113`);el
|
|||||||
if(legacyCount!==60)fail(`legacy catalog photo count ${legacyCount}, expected 60`);else ok('60 legacy catalog photos');
|
if(legacyCount!==60)fail(`legacy catalog photo count ${legacyCount}, expected 60`);else ok('60 legacy catalog photos');
|
||||||
const gallery=fs.readdirSync(path.join(pub,'offer-gallery')).filter(x=>/\.jpg$/i.test(x));
|
const gallery=fs.readdirSync(path.join(pub,'offer-gallery')).filter(x=>/\.jpg$/i.test(x));
|
||||||
if(gallery.length!==2)fail(`offer gallery contains ${gallery.length} jpg files, expected 2`);else ok('offer gallery trimmed');
|
if(gallery.length!==2)fail(`offer gallery contains ${gallery.length} jpg files, expected 2`);else ok('offer gallery trimmed');
|
||||||
if(!sw.includes('20260917-empty-catalog')||!sw.includes('login-signature-v1776.js')||!sw.includes('data-layer-v1773.js')||!sw.includes('server-automation-v1770.js')||!sw.includes('offer-workspace-v1769.js')||sw.includes('offer-gallery-data.js'))fail('service worker cache is stale');else ok('PWA cache updated for login refresh');
|
if(!sw.includes('20260917-account-access')||!sw.includes('login-signature-v1776.js')||!sw.includes('data-layer-v1773.js')||!sw.includes('server-automation-v1770.js')||!sw.includes('offer-workspace-v1769.js')||sw.includes('offer-gallery-data.js'))fail('service worker cache is stale');else ok('PWA cache updated for login refresh');
|
||||||
if(html.includes('20260907-v17-6-0-stability-security')||html.includes('20260909-v17-7-3-clients-server-read')||!html.includes('20260917-empty-catalog')||!html.includes('classic-offer-pdf-v1767.js'))fail('index still serves stale core asset version');else ok('index cache-busting is current');
|
if(html.includes('20260907-v17-6-0-stability-security')||html.includes('20260909-v17-7-3-clients-server-read')||!html.includes('20260917-account-access')||!html.includes('classic-offer-pdf-v1767.js'))fail('index still serves stale core asset version');else ok('index cache-busting is current');
|
||||||
if(!performance.includes('SunAttachmentGuard')||!performance.includes('MAX_SIDE=2048'))fail('chat photo compression guard missing');else ok('chat photo compression guard present');
|
if(!performance.includes('SunAttachmentGuard')||!performance.includes('MAX_SIDE=2048'))fail('chat photo compression guard missing');else ok('chat photo compression guard present');
|
||||||
if(!performance.includes("rpc('sun_dev_dashboard')")||!performance.includes('storage_size')||!performance.includes('server_size'))fail('Developer Console memory counters missing');else ok('Developer Console memory counters present');
|
if(!performance.includes("rpc('sun_dev_dashboard')")||!performance.includes('storage_size')||!performance.includes('server_size'))fail('Developer Console memory counters missing');else ok('Developer Console memory counters present');
|
||||||
if(performance.includes('records.forEach(r=>r.addedNodes.forEach(n=>{if(n.nodeType===1)scan(n)}));enhanceDeveloperMemory()'))fail('Developer Console memory refresh is still coupled to MutationObserver');else ok('Developer Console memory refresh loop removed');
|
if(performance.includes('records.forEach(r=>r.addedNodes.forEach(n=>{if(n.nodeType===1)scan(n)}));enhanceDeveloperMemory()'))fail('Developer Console memory refresh is still coupled to MutationObserver');else ok('Developer Console memory refresh loop removed');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user