diff --git a/docs/ACCOUNT-ACCESS-20260917.md b/docs/ACCOUNT-ACCESS-20260917.md new file mode 100644 index 0000000..bed2a05 --- /dev/null +++ b/docs/ACCOUNT-ACCESS-20260917.md @@ -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. diff --git a/docs/release-manifest.json b/docs/release-manifest.json index 5619823..0b880de 100644 --- a/docs/release-manifest.json +++ b/docs/release-manifest.json @@ -11,7 +11,7 @@ "serverReady": true, "workspaceAutoDiscovery": true, "invitesTemporarilyDisabled": false, - "pwaCache": "v86-20260917-empty-catalog", + "pwaCache": "v87-20260917-account-access", "fullOfferDescriptions": true, "dynamicOfferRows": true, "pdfOfferDescriptionFix": true, @@ -383,5 +383,7 @@ "clientOrderMetricsSource": "legacy orders verified locally", "clientServerCache": "cateriumClientsServerV1773", "supabaseProjectRef": "usfjwhztqoopzzfmfbis", - "recoveryMigration": "20260917150000_fresh_caterium" + "recoveryMigration": "20260917150000_fresh_caterium", + "anonymousAccessDisabled": true, + "clientCacheTenantIsolation": true } diff --git a/package.json b/package.json index 50b8e13..5468fac 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "version": "17.7.3", "type": "module", "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", "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", diff --git a/public/app-runtime.js b/public/app-runtime.js index 17ef6d6..145e103 100644 --- a/public/app-runtime.js +++ b/public/app-runtime.js @@ -2323,6 +2323,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; // accounts while avoiding a second large copy inside localStorage. function isTenantLocalKey(key) { key=String(key||''); + if(key==='cateriumClientsV1772'||key==='cateriumClientsServerV1773')return true; if(!key.startsWith('sun'))return false; if(key===CONFIG_KEY||key===CLIENT_ID_KEY)return false; if(key.startsWith('sunCloud'))return false; @@ -2348,6 +2349,10 @@ window.SUN_LEGACY_CATALOG_V175=[]; } 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)); } @@ -2370,6 +2375,18 @@ window.SUN_LEGACY_CATALOG_V175=[]; // account on the first login after upgrading. if(!config.tenantStorageReady){ 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){ await captureTenantLocal(legacy); clearTenantLocal(); @@ -2390,8 +2407,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; await captureTenantLocal(next); return {changed:false,restored:true,adopted:true}; } - // A purely local pre-cloud installation has no workspace id yet. Keep it - // untouched until the first company is selected, so migration stays safe. + // No signed-in company is available yet. return {changed:false,restored:false}; } const prev=String(config.localWorkspaceId||'').trim(); @@ -3429,7 +3445,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; function workspace(){ return cloud()?.getWorkspace?.() || null; } function session(){ return cloud()?.getSession?.() || 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 normalizeLegacyLocalAuth(){ @@ -3746,13 +3762,13 @@ window.SUN_LEGACY_CATALOG_V175=[]; // ever saw it. function ensureAuthGate(){ - const st=cloud()?.status?.();if(!st?.connected)return; + const st=cloud()?.status?.()||{}; const ws=workspace(),ss=session(),inviteToken=currentInviteToken(); 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='

Проверяю приглашение…

';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){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;} document.body.classList.add('sun-cloud-auth-required'); 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.innerHTML=`
Caterium

Вход в Caterium

Введите данные своего аккаунта
${passwordField('sunGatePasswordV3','Пароль','current-password')}

Нет аккаунта?

`;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 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}} @@ -4671,7 +4689,9 @@ window.SUN_LEGACY_CATALOG_V175=[]; function clearWorkspaceLocalData(){ const preserve=new Set(['sunCloudV2Config','sunCloudClientIdV2','sunStaticMapGeocodeCacheV2']); - const keys=[];for(let i=0;ilocalStorage.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;ilocalStorage.removeItem(k)); } function seedWorkspace(){clearWorkspaceLocalData();localStorage.setItem('sunBoxes','[]');localStorage.setItem('sunOrders','[]');} diff --git a/public/core/access-policy.js b/public/core/access-policy.js new file mode 100644 index 0000000..7a64fd0 --- /dev/null +++ b/public/core/access-policy.js @@ -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}} + }); +})(); diff --git a/public/core/data-layer-v1773.js b/public/core/data-layer-v1773.js index b43c4b4..b9482e7 100644 --- a/public/core/data-layer-v1773.js +++ b/public/core/data-layer-v1773.js @@ -9,6 +9,7 @@ const LEGACY_LOYALTY_KEY='sunClientLoyaltyV1'; const LEGACY_COMM_KEY='sunClientCommunicationV1'; const listeners=new Map(); + let tenantChanging=false,tenantEpoch=0; 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 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 writeClientCache(map){localStorage.setItem(CLIENT_CACHE_KEY,JSON.stringify(map||{}));return map} function buildClients(){ + if(tenantChanging)return []; 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)}; 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')); } - 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(){ 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); @@ -137,12 +139,15 @@ } let serverRefreshPromise=null; async function refreshServerClients({force=false,reason='manual'}={}){ + if(tenantChanging)return {status:'switching'}; 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 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()=>{ 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:[]; + 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}); 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(_){} @@ -160,6 +165,7 @@ 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 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)}; } 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 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){ + 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 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 {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)} return {status:'saved',key,version:Number(row?.version||0)||null}; } 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)} - 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; function installLegacyClientBridge(){ @@ -207,6 +216,8 @@ window.CateriumDataV1770=api; window.CateriumData=api; 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'}); 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)); diff --git a/public/core/performance.js b/public/core/performance.js index ef9f89b..f9057bc 100644 --- a/public/core/performance.js +++ b/public/core/performance.js @@ -1,7 +1,7 @@ (()=>{ 'use strict'; 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}}; function installAuthBoot(){ diff --git a/public/index.html b/public/index.html index 47a42c3..bf2a2d7 100644 --- a/public/index.html +++ b/public/index.html @@ -1,4 +1,4 @@ - - + diff --git a/public/service-worker.js b/public/service-worker.js index 08305ee..e75974a 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,7 +1,7 @@ -const CACHE='sun-catering-pwa-v86-20260917-empty-catalog'; -const VERSION='20260917-empty-catalog'; +const CACHE='sun-catering-pwa-v87-20260917-account-access'; +const VERSION='20260917-account-access'; 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}`, './offer-gallery/001.jpg','./offer-gallery/002.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' ]; 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=>{ event.waitUntil(caches.open(CACHE).then(cache=>cache.addAll(CORE)).then(()=>self.skipWaiting())); diff --git a/tests/account-access.spec.mjs b/tests/account-access.spec.mjs new file mode 100644 index 0000000..9548680 --- /dev/null +++ b/tests/account-access.spec.mjs @@ -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:''})); + 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}); +}); diff --git a/tests/playwright.config.mjs b/tests/playwright.config.mjs index b007e96..4039189 100644 --- a/tests/playwright.config.mjs +++ b/tests/playwright.config.mjs @@ -2,7 +2,7 @@ import { defineConfig, devices } from '@playwright/test'; import {fileURLToPath} from 'node:url'; export default defineConfig({ 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, 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}, diff --git a/tests/release-check.mjs b/tests/release-check.mjs index 64324d3..d8bf70c 100644 --- a/tests/release-check.mjs +++ b/tests/release-check.mjs @@ -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(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(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(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(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-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("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'); @@ -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(releaseManifest.version===`v${pkg.version}`,'release manifest version matches package.json'); 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(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'); @@ -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(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(index.includes('20260917-empty-catalog'),'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(index.includes('20260917-account-access'),'index cache bust is v17.7.3'); +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(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'); diff --git a/tests/static-security.mjs b/tests/static-security.mjs index 12d3e31..65b4d4e 100644 --- a/tests/static-security.mjs +++ b/tests/static-security.mjs @@ -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'); 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(!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(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(!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-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("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');