diff --git a/.github/workflows/deploy-timeweb.yml b/.github/workflows/deploy-timeweb.yml index 118c2df..8a8221a 100644 --- a/.github/workflows/deploy-timeweb.yml +++ b/.github/workflows/deploy-timeweb.yml @@ -43,6 +43,9 @@ jobs: files=( index.html app-runtime.js + core/account-center-v1780.js + core/company-branding.js + core/sun-safe.js core/login-signature-v1776.js core/login-signature-v1776.css core/help-center.js @@ -124,6 +127,9 @@ jobs: - name: Check the published single-page banquet menu timeout-minutes: 4 run: node tests/production-banquet-client-menu.mjs + - name: Check published employee session and mobile logout + timeout-minutes: 4 + run: node tests/production-employee-session.mjs - name: Save production UI verification if: always() uses: actions/upload-artifact@v4 diff --git a/docs/releases/2026-09-20-EMPLOYEE-SESSION.md b/docs/releases/2026-09-20-EMPLOYEE-SESSION.md new file mode 100644 index 0000000..94927ac --- /dev/null +++ b/docs/releases/2026-09-20-EMPLOYEE-SESSION.md @@ -0,0 +1,46 @@ +# Company employee sessions and mobile logout + +## Application changes + +Notification read flags now use a device-local key scoped to the authenticated +user and selected company. They do not enter the company synchronization payload. +Older remote `sunReadNotificationsV1` values are preserved, not overwritten or +removed by an employee. Read-only sections are taken from the remote snapshot +instead of uploading bootstrap defaults or empty caches as staff changes. Order +reconciliation respects the existing create/edit/delete permissions separately. +The server still enforces all permissions; no role or membership is granted. + +On mobile, the main header contains labelled Profile and Logout buttons with SVG +icons. Profile also has a sticky top Logout button, including while its detail RPC +is pending or unavailable. Logout uses the existing bounded local-device sign-out +and tenant-cache preservation. Late account/company replies cannot restore stale +profile or sidebar information. + +## Database change — must be deployed separately + +Apply `supabase/migrations/20260920104500_workspace_sidebar_brand.sql` to the +Caterium database. It adds a membership-checked, selected-workspace sidebar RPC. +An exclusive branding assignment is associated with its owner's company only +when that company is unambiguous. Multiple owned companies need an explicit +assignment by an authorized database administrator, never a guessed name match. +Active confirmed employees of the assigned company receive the Solnce sidebar. +The old owner-only RPC remains compatible with older clients. + +Publishing application assets does NOT apply SQL migrations. Until the database +migration is applied, the client safely falls back to the old owner-only RPC; +this release must not be reported as fixing employee branding on that server. +No company catalog, order, client, membership, or production account is changed +by the migration. The separate ai-staff project is outside this change. + +## Verification scope + +Browser scenarios use synthetic company accounts and do not access production +business data. They cover an empty employee device, pre-existing baselines, +allowed order edits with forbidden UI preferences, record-level permissions, +personal notification isolation, selected-workspace branding, old-server +compatibility, late replies, and visible mobile logout with unavailable details. +The recovery database suite checks the new RPC against actual SQL permissions. + +The reported employee's actual company membership is a separate diagnosis and +requires the exact new email and an authorized server/account read. Empty UI alone +is not evidence that company orders were deleted. diff --git a/public/app-runtime.js b/public/app-runtime.js index e84e4c8..8b16f70 100644 --- a/public/app-runtime.js +++ b/public/app-runtime.js @@ -2308,7 +2308,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; if (!key.startsWith('sun')) return false; if (key === CONFIG_KEY || key === CLIENT_ID_KEY) return false; if (key === 'sunUsersV1' || key === 'sunSessionUserV1') return false; - if (key === 'sunFallbackBackupsV1') return false; + if (key === 'sunFallbackBackupsV1' || key === 'sunReadNotificationsV1') return false; if (key === 'sunStaticMapGeocodeCacheV2') return false; if (key === 'sunCloudSyncMetaV1' || key === 'sunCloudSyncTokenV1' || key === 'sunCloudSyncWorkspaceV1') return false; if (key === 'sunOrdersView' || key === 'sunCalendarMode' || key === 'sunCatalogViewModeV1' || key === 'sunNewOrderSplitRatioV5') return false; @@ -2509,7 +2509,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; } function setSignOutUiBusy(busy) { - const selectors=['#sunCloudSignOutV2','#sunLogoutBtn','#sunGateSignOutV3','#sunCloudUsePasswordV3','[data-signout]','[data-saas-signout]']; + const selectors=['#cacLogout','#cacHeaderLogout','#cateriumMobileLogout','#sunCloudSignOutV2','#sunLogoutBtn','#sunGateSignOutV3','#sunCloudUsePasswordV3','[data-signout]','[data-saas-signout]']; document.querySelectorAll(selectors.join(',')).forEach(btn=>{ if(!(btn instanceof HTMLButtonElement))return; if(busy){btn.dataset.sunOldText=btn.textContent||'';btn.disabled=true;btn.textContent='Выходим…';} @@ -2641,6 +2641,22 @@ window.SUN_LEGACY_CATALOG_V175=[]; return clone(local); } + // Match the server's section permissions without expanding a user's rights. + // Bootstrap/default UI writes in a read-only section cannot block a valid order + // save or turn an omitted company setting into a deletion. + function canWriteStorageKey(key) { + if(key==='sunOrders')return ['orders.create','orders.edit','orders.delete'].some(hasPermission); + if(key==='sunAuditLogV1')return Boolean(workspace&&workspace.is_active!==false); + const permissions={sunBoxes:'catalog.edit',sunOfficialCatalogVersion:'catalog.edit', + sunClientLoyaltyV1:'clients.edit',sunClientCommunicationV1:'clients.edit', + sunFinanceRecordsV2:'money.edit',sunStock:'stock.edit',sunStockMoves:'stock.edit', + sunEmployees:'team.edit',sunSuppliers:'suppliers.edit',sunPromoCodesV1:'mailings.edit'}; + return hasPermission(permissions[key]||(key.startsWith('sunRoute')?'routes.edit':key.startsWith('sunMarketing')?'mailings.edit':'settings.edit')); + } + function equalSyncPayload(a,b){ + const filtered=p=>({...p,storage:Object.fromEntries(Object.entries(p?.storage||{}).filter(([key])=>shouldSyncKey(key)))}); + return equal(filtered(a),filtered(b)); + } function mergePayloads(basePayload, localPayload, remotePayload) { const conflicts = []; const base = basePayload?.storage || {}; @@ -2650,6 +2666,12 @@ window.SUN_LEGACY_CATALOG_V175=[]; const keys = new Set([...Object.keys(base), ...Object.keys(local), ...Object.keys(remote)]); for (const key of keys) { const b = base[key], l = local[key], r = remote[key]; + if(!shouldSyncKey(key)||(workspace&&!canWriteStorageKey(key))){ + // Keep legacy remote-only preferences byte-for-byte, never upload local + // notification flags and never ask staff to delete an owner's settings. + if(r!==undefined)merged[key]=clone(r); + continue; + } if (l === undefined && r === undefined) continue; if (l === undefined) { if (b !== undefined && !equal(r,b)) conflicts.push(key); @@ -2663,6 +2685,16 @@ window.SUN_LEGACY_CATALOG_V175=[]; } merged[key] = mergeNode(b, l, r, conflicts, key); } + if(workspace&&(remote.sunOrders||merged.sunOrders)){ + const existing=remote.sunOrders?.v,proposed=merged.sunOrders?.v; + if(idArray(existing)&&(!proposed||idArray(proposed))){ + const byId=new Map(existing.map(o=>[String(o.id),o])); + const rows=(proposed||[]).filter(o=>byId.has(String(o.id))||hasPermission('orders.create')) + .map(o=>byId.has(String(o.id))&&!hasPermission('orders.edit')?clone(byId.get(String(o.id))):o); + if(!hasPermission('orders.delete')){const ids=new Set(rows.map(o=>String(o.id)));for(const o of existing)if(!ids.has(String(o.id)))rows.push(clone(o));} + merged.sunOrders={...(merged.sunOrders||remote.sunOrders),v:rows}; + } + } return {payload:{format:'sun-cloud-v2',version:2,storage:merged}, conflicts:[...new Set(conflicts)]}; } @@ -2955,7 +2987,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; if(!current())return; if (!remote || payloadEmpty(remote.payload)) { if (!canWrite()) throw new Error('В облаке нет базы, а у пользователя нет прав на её создание.'); - const row = await upsertPayload(localPayload, remote?.revision ?? null); + const row = await upsertPayload(mergePayloads(null,localPayload,remote?.payload).payload, remote?.revision ?? null); if(!current())return; await setBaseline(row); if(!current())return; dirty=false; config.lastSync=row.updated_at||new Date().toISOString();saveConfig(); setStatus('ready','Синхронизировано.'); if(!quiet)toast('Синхронизация завершена.','success'); return; @@ -2970,8 +3002,8 @@ window.SUN_LEGACY_CATALOG_V175=[]; const merged = mergePayloads(baseline.payload, localPayload, remote.payload); let finalRow = remote; - const changedVsRemote = !equal(merged.payload, remote.payload); - const changedVsLocal = !equal(merged.payload, localPayload); + const changedVsRemote = !equalSyncPayload(merged.payload, remote.payload); + const changedVsLocal = !equalSyncPayload(merged.payload, localPayload); if (changedVsRemote) { if (!canWrite()) { diff --git a/public/core/account-center-v1780.js b/public/core/account-center-v1780.js index 070e41e..40ba540 100644 --- a/public/core/account-center-v1780.js +++ b/public/core/account-center-v1780.js @@ -1,7 +1,7 @@ (()=>{ 'use strict'; if(window.CateriumAccountCenterV1780)return; -const VERSION='17.8.0-account-center-v3'; +const VERSION='17.8.0-employee-session-20260920'; const $=(s,r=document)=>r.querySelector(s); const qa=(s,r=document)=>[...r.querySelectorAll(s)]; const esc=v=>window.SunSafe?.escapeHTML?window.SunSafe.escapeHTML(String(v??'')):String(v??''); @@ -14,6 +14,10 @@ const ROLE_LABELS={admin:'Владелец',manager:'Менеджер',kitchen:' let snapshot=null; let modal=null; let lastUserId=''; +let snapshotScope='',loadSequence=0,openSequence=0; +const accountScope=()=>JSON.stringify([session()?.user?.id||'',workspace()?.id||'']); +const profileIcon=''; +const logoutIcon=''; function installStyle(){ if($('#caterium-account-center-style'))return; @@ -21,8 +25,9 @@ function installStyle(){ #cateriumAccountCenter{position:fixed;inset:0;z-index:21000;display:none;place-items:center;padding:22px;background:#102c3d99;backdrop-filter:blur(5px)} #cateriumAccountCenter.on{display:grid} #cateriumAccountCenter .cac-shell{width:min(980px,100%);max-height:min(860px,92vh);overflow:auto;background:#f6f2ea;border-radius:22px;box-shadow:0 28px 90px #0004;color:#2f2b25} - #cateriumAccountCenter .cac-head{display:flex;justify-content:space-between;gap:16px;align-items:flex-start;padding:28px 30px 20px;border-bottom:1px solid #d9d1c5} + #cateriumAccountCenter .cac-head{position:sticky;top:0;z-index:2;background:#f6f2ea;display:flex;justify-content:space-between;gap:16px;align-items:flex-start;padding:28px 30px 20px;border-bottom:1px solid #d9d1c5} #cateriumAccountCenter h2{margin:0;font:500 38px/1.04 Georgia,'Times New Roman',serif;letter-spacing:-.03em;color:#27231f} + #cateriumAccountCenter .cac-head-actions{display:flex;align-items:center;gap:12px;flex-shrink:0} #cateriumAccountCenter .cac-sub{margin-top:7px;color:#8a8379;font-size:14px} #cateriumAccountCenter .cac-close{border:0;background:transparent;font-size:31px;color:#5c564d;padding:0 4px} #cateriumAccountCenter .cac-body{padding:24px 30px 30px} @@ -45,31 +50,44 @@ function installStyle(){ #cateriumAccountButton{display:flex!important} #sun-cloud-users-modal [data-caterium-owner-row="1"]{background:#fff9e8;border:1px solid #ead39a;border-radius:10px;padding:10px} #sun-cloud-users-modal .caterium-owner-chip{display:inline-flex;align-items:center;border-radius:999px;background:#f4dfad;color:#6d5118;padding:5px 9px;font-size:11px;font-weight:900} + #cateriumMobileAccountActions{display:none} + #cateriumMobileAccountActions button svg,#cateriumAccountButton svg{width:20px;height:20px;flex-shrink:0} + @media(max-width:900px){ + body.sun-enterprise-sidebar header #cateriumMobileAccountActions{display:flex!important;width:100%;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;margin:6px 0 0} + #cateriumMobileAccountActions button{display:flex!important;align-items:center;justify-content:center;gap:7px;min-height:44px;border:1px solid #b8b4a9;border-radius:10px;padding:8px 12px;background:#faf7ef;color:#333c30;font:700 13px Arial;touch-action:manipulation} + #cateriumAccountCenter h2{font-size:27px} + #cateriumAccountCenter .cac-head-actions{gap:8px} + #cacHeaderLogout{min-height:44px;font-size:12px;padding:8px!important;white-space:nowrap} + } + @media print{#cateriumMobileAccountActions,#cateriumAccountCenter{display:none!important}} @media(max-width:720px){#cateriumAccountCenter{padding:0;place-items:stretch}#cateriumAccountCenter .cac-shell{width:100%;max-height:none;height:100dvh;border-radius:0}#cateriumAccountCenter .cac-grid{grid-template-columns:1fr}#cateriumAccountCenter .cac-card.wide{grid-column:auto}#cateriumAccountCenter .cac-head,#cateriumAccountCenter .cac-body{padding-left:18px;padding-right:18px}} `;document.head.appendChild(s); } function ensureModal(){ if(modal)return modal; - modal=document.createElement('div');modal.id='cateriumAccountCenter';modal.innerHTML='

Мой аккаунт

Профиль и безопасность Caterium
'; + modal=document.createElement('div');modal.id='cateriumAccountCenter';modal.innerHTML='

Мой аккаунт

Профиль и безопасность Caterium
'; document.body.appendChild(modal); + $('#cacHeaderLogout',modal).onclick=logout; modal.addEventListener('click',e=>{if(e.target===modal||e.target.closest('.cac-close'))close();}); return modal; } async function loadSnapshot(){ const c=client(),ws=workspace();if(!c||!ws?.id)return null; + const scope=accountScope(),request=++loadSequence; const {data,error}=await c.rpc('caterium_account_snapshot',{p_workspace:ws.id}); - if(error)throw error;snapshot=data||null;return snapshot; + if(scope!==accountScope()||request!==loadSequence)return null; + if(error)throw error;snapshot=data||null;snapshotScope=scope;return snapshot; } function render(){ const body=$('#cateriumAccountCenterBody');if(!body)return; - const ss=session(),s=snapshot||{},owner=Boolean(s.is_owner); + const ss=session(),ws=workspace(),s=snapshotScope===accountScope()?(snapshot||{}):{},owner=Boolean(s.is_owner); const companyNo=s.company_number?`№${s.company_number}`:'—'; body.innerHTML=`

Профиль

${owner?'
Главный аккаунт компанииТолько этот аккаунт создаёт сотрудников и распределяет права.
':''} -
Компания${esc(s.company_name||'—')} · ${esc(companyNo)}
Статус${esc(owner?'Владелец':ROLE_LABELS[s.role]||s.role||'Сотрудник')}
+
Компания${esc(s.company_name||ws?.name||'—')} · ${esc(companyNo)}
Статус${esc(owner?'Владелец':ROLE_LABELS[s.role||ws?.role]||s.role||ws?.role||'Сотрудник')}
@@ -111,12 +129,15 @@ async function savePassword(){ try{const {error}=await c.auth.updateUser({password:p});if(error)throw error;$('#cacPassword').value='';$('#cacPassword2').value='';toast('Пароль изменён.','success')} catch(e){toast(e?.message||String(e),'error',6500)}finally{btn.disabled=false} } -async function logout(){try{await cloud()?.signOut?.()}catch(_){try{await client()?.auth.signOut()}catch(__){}location.reload()}} +async function logout(){if(typeof cloud()?.signOut==='function')return cloud().signOut();try{await client()?.auth.signOut({scope:'local'})}finally{location.reload()}} async function open(){ - ensureModal();modal.classList.add('on');const body=$('#cateriumAccountCenterBody');body.innerHTML='

Загружаю аккаунт…

'; - try{await loadSnapshot();render()}catch(e){body.innerHTML=`

${esc(e?.message||e||'Не удалось загрузить аккаунт.')}

`} + ensureModal();const request=++openSequence,scope=accountScope();modal.classList.add('on');render(); + const body=$('#cateriumAccountCenterBody');body.setAttribute('aria-busy','true'); + try{await loadSnapshot();if(request===openSequence&&scope===accountScope())render()} + catch(e){if(request===openSequence&&scope===accountScope()){render();const note=document.createElement('p');note.className='cac-note';note.textContent='Не удалось обновить данные профиля. Выход из аккаунта доступен.';body.prepend(note)}} + finally{if(request===openSequence)body.removeAttribute('aria-busy')} } -function close(){modal?.classList.remove('on')} +function close(){openSequence++;modal?.classList.remove('on')} function sanitizeRbac(){ const body=$('#sunRbacBody');if(!body)return; qa('select option[value="admin"]',body).forEach(o=>o.remove()); @@ -133,18 +154,25 @@ function ensureSidebarEntry(){ if(!button){ button=document.createElement('button'); button.id='cateriumAccountButton';button.type='button';button.className='sun-side-action';button.title='Профиль, email и пароль'; - button.innerHTML='Мой аккаунт'; + button.innerHTML=profileIcon+'Мой аккаунт'; footer.insertBefore(button,footer.querySelector('#sunLogoutBtn')||footer.firstChild); } if(!button.dataset.cateriumAccountBound){button.dataset.cateriumAccountBound='1';button.addEventListener('click',open)} } +function ensureMobileEntry(){ + const header=$('body > header');if(!header)return; + let bar=$('#cateriumMobileAccountActions'); + if(!session()?.user){bar?.remove();return} + if(!bar){bar=document.createElement('div');bar.id='cateriumMobileAccountActions';bar.innerHTML='';header.appendChild(bar);$('#cateriumMobileAccountButton',bar).onclick=open;$('#cateriumMobileLogout',bar).onclick=logout;} +} function bindEntry(){ const label=$('#sunCurrentUserLabel');if(label&&!label.dataset.cateriumAccountBound){label.dataset.cateriumAccountBound='1';label.title='Открыть личный кабинет';label.addEventListener('click',open)} - const ss=session();const uid=ss?.user?.id||'';if(uid&&uid!==lastUserId){lastUserId=uid;snapshot=null;} - ensureSidebarEntry();sanitizeRbac(); + const uid=accountScope();if(uid!==lastUserId){lastUserId=uid;snapshot=null;snapshotScope='';loadSequence++;close();} + ensureSidebarEntry();ensureMobileEntry();sanitizeRbac(); } installStyle();ensureModal();bindEntry(); const obs=new MutationObserver(bindEntry);obs.observe(document.documentElement,{childList:true,subtree:true}); +window.addEventListener('sun:cloud-tenant-changing',()=>{snapshot=null;snapshotScope='';loadSequence++;close()}); window.addEventListener('sun:cloud-permissions-changed',()=>setTimeout(bindEntry,0)); setInterval(()=>{if(!document.hidden)bindEntry()},3000); window.CateriumAccountCenterV1780=Object.freeze({VERSION,open,close,reload:async()=>{await loadSnapshot();render();return snapshot}}); diff --git a/public/core/company-branding.js b/public/core/company-branding.js index 6536b48..2cbbd79 100644 --- a/public/core/company-branding.js +++ b/public/core/company-branding.js @@ -60,6 +60,7 @@ let sidebar={name:'Caterium',logo:'caterium-mark-light.svg',special:false}; let request=0,checkedUser='',pendingUser=''; + const sidebarScope=()=>JSON.stringify([window.SunCloudV2?.getSession?.()?.user?.id||'',window.SunCloudV2?.getWorkspace?.()?.id||'']); function sidebarHTML(){return `${esc(sidebar.name)}${esc(sidebar.name)}`;} function renderSidebar(){ const node=document.querySelector('body > header .brand');if(!node)return; @@ -70,14 +71,18 @@ async function refreshSidebar(){ const cloud=window.SunCloudV2,session=cloud?.getSession?.(),uid=session?.user?.id||''; if(!uid){resetSidebar();return;} - if(checkedUser===uid||pendingUser===uid){renderSidebar();return;} - resetSidebar();pendingUser=uid;const current=request; + const ws=cloud.getWorkspace?.()?.id||'',scope=sidebarScope(); + if(checkedUser===scope||pendingUser===scope){renderSidebar();return;} + resetSidebar();pendingUser=scope;const current=request; try{ - const result=await cloud.getClient()?.rpc('caterium_my_sidebar_brand'); - if(current!==request||window.SunCloudV2?.getSession?.()?.user?.id!==uid)return; + // New RPC verifies active membership and the selected company. An older + // database keeps its owner-only branding until the additive migration runs. + let result=ws?await cloud.getClient()?.rpc('caterium_workspace_sidebar_brand',{p_workspace:ws}):null; + if(!ws||['PGRST202','42883'].includes(result?.error?.code))result=await cloud.getClient()?.rpc('caterium_my_sidebar_brand'); + if(current!==request||sidebarScope()!==scope)return; if(result?.error||!result?.data)return; if(result.data.variant==='solnce')sidebar={name:'Солнце Кейтеринг',logo:'sun-logo.png',special:true}; - checkedUser=uid;renderSidebar(); + checkedUser=scope;renderSidebar(); }catch(_){/* Until identity is confirmed, show the shared product brand. */} finally{if(current===request)pendingUser='';} } @@ -90,6 +95,7 @@ nameHTML:()=>esc(documentName()),logoHTML:()=>esc(documentImage()),contactsHTML:()=>esc(documentContacts()),cityHTML:()=>esc(documentCity()), escapeHTML:esc,snapshot,saveName,saveLogo,clearLogo,prepareLogoImage,sidebarHTML,renderSidebar,refreshSidebar,resetSidebar}); for(const event of ['sun:cloud-state-applied','sun:cloud-permissions-changed','suncloudsync'])window.addEventListener(event,()=>{refreshSidebar();changed()}); + window.addEventListener('sun:cloud-tenant-changing',resetSidebar); window.addEventListener('storage',event=>{if([NAME_KEY,LOGO_KEY,'sunCompanyProfileV1'].includes(event.key))changed();if(/^sb-.*-auth-token$/.test(event.key||''))resetSidebar()}); if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot(); })(); diff --git a/public/core/sun-safe.js b/public/core/sun-safe.js index 04d7d91..a819a23 100644 --- a/public/core/sun-safe.js +++ b/public/core/sun-safe.js @@ -4,6 +4,11 @@ '&':'&','<':'<','>':'>','"':'"',"'":''' })[ch]); const escapeAttr=escapeHTML; + // Personal UI preferences must never be included in a company payload. + const personalStorageKey=name=>{ + const cloud=window.SunCloudV2,user=cloud?.getSession?.()?.user?.id,ws=cloud?.getWorkspace?.()?.id; + return user&&ws?'caterium:personal:'+encodeURIComponent(user)+':'+encodeURIComponent(ws)+':'+encodeURIComponent(name):''; + }; const idToken=value=>String(value??'').replace(/[^a-zA-Z0-9_-]/g,''); const jsArg=value=>escapeHTML(JSON.stringify(String(value??''))); // Existing trial databases and saved offers still reference the PNG originals. @@ -29,14 +34,14 @@ if(reference&&reference.parentNode===parent)parent.insertBefore(node,reference);else parent.appendChild(node); return node; }; - window.SunSafe=Object.freeze({escapeHTML,escapeAttr,idToken,jsArg,safeImageSrc,imageAssetSrc,setText,insertBefore}); + window.SunSafe=Object.freeze({escapeHTML,escapeAttr,personalStorageKey,idToken,jsArg,safeImageSrc,imageAssetSrc,setText,insertBefore}); // Small bootstrap for account/profile UI. Keeping it here makes the account // center available on every Caterium screen without touching the legacy monolith. if(!document.getElementById('cateriumAccountCenterV1780Script')){ const script=document.createElement('script'); script.id='cateriumAccountCenterV1780Script'; - script.src='core/account-center-v1780.js?v=20260912-v17-8-0-account-center-2'; + script.src='core/account-center-v1780.js?v=20260920-employee-session'; script.async=true; document.head.appendChild(script); } diff --git a/public/index.html b/public/index.html index 03bfc3f..d65f319 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 ded00b9..76bfd11 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,4 +1,4 @@ -const CACHE='sun-catering-pwa-v110-20260918-ui-stability-20260919-client-menu-support-bot-training-catalog-banquet-onepage'; +const CACHE='sun-catering-pwa-v110-20260918-ui-stability-20260919-client-menu-support-bot-training-catalog-banquet-onepage-employee-session'; const VERSION='20260918-ui-stability'; const CORE=[ './core/banquet-client-menu.js?v=20260920-onepage', diff --git a/supabase/migrations/20260920104500_workspace_sidebar_brand.sql b/supabase/migrations/20260920104500_workspace_sidebar_brand.sql new file mode 100644 index 0000000..2ad4193 --- /dev/null +++ b/supabase/migrations/20260920104500_workspace_sidebar_brand.sql @@ -0,0 +1,34 @@ +begin; +-- Extend the exclusive identity to the selected company's active employees. +-- Keep the original zero-argument RPC for old installed clients. +alter table public.caterium_sidebar_brand_assignment + add column if not exists workspace_id uuid references public.sun_workspaces(id) on delete set null; + +-- Never guess between several companies owned by the assigned account. +update public.caterium_sidebar_brand_assignment a +set workspace_id=w.id +from public.sun_workspaces w +where a.workspace_id is null and w.created_by=a.user_id + and (select count(*) from public.sun_workspaces x where x.created_by=a.user_id)=1; + +create or replace function public.caterium_workspace_sidebar_brand(p_workspace uuid) +returns jsonb language plpgsql stable security definer +set search_path=public,pg_temp as $$ +declare variant text:='caterium'; +begin + if auth.uid() is null or public.sun_member_role(p_workspace) is null + or not exists(select 1 from auth.users where id=auth.uid() and email_confirmed_at is not null) + then raise exception 'Нет доступа к компании'; end if; + if exists( + select 1 from public.caterium_sidebar_brand_assignment a + join auth.users owner_account on owner_account.id=a.user_id and owner_account.email_confirmed_at is not null + join public.sun_workspaces w on w.id=p_workspace + where a.workspace_id=w.id or (a.workspace_id is null and w.created_by=a.user_id + and (select count(*) from public.sun_workspaces x where x.created_by=a.user_id)=1) + ) then variant:='solnce'; end if; + return jsonb_build_object('variant',variant,'workspace_id',p_workspace); +end $$; +revoke all on function public.caterium_workspace_sidebar_brand(uuid) from public,anon; +grant execute on function public.caterium_workspace_sidebar_brand(uuid) to authenticated; +notify pgrst,'reload schema'; +commit; diff --git a/tests/employee-session.spec.mjs b/tests/employee-session.spec.mjs new file mode 100644 index 0000000..a6d52ac --- /dev/null +++ b/tests/employee-session.spec.mjs @@ -0,0 +1,117 @@ +import {test,expect} from '@playwright/test'; +import fs from 'node:fs'; +import {trainingInit} from './training-catalog-fixture.mjs'; + +async function blank(page){ + await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:'
'})); + await page.goto('/index.html'); +} +async function syncHarness(page){ + await blank(page);await page.addScriptTag({url:'/core/sun-safe.js'});await page.addScriptTag({url:'/core/cloud-transport.js'}); + const runtime=fs.readFileSync('public/app-runtime.js','utf8');let source=runtime.slice(runtime.indexOf('/* ===== MODULE: cloud-sync-v2.js'),runtime.indexOf('/* ===== MODULE: admin-rbac-v3.js')); + source=source.replace('async function boot(){','async function boot(){return;').replace('window.SunCloudV2={',`window.SunCloudV2={ + testInit:(c,permissions,role='manager')=>{client=c;session={user:{id:'employee',email:'employee@example.invalid'}};workspace={id:'company',name:'Компания',role,permissions,is_active:true};config.migrated.company=true;config.tenantStorageReady=true;config.localWorkspaceId='company';config.workspaceId='company'}, + testBaseline:setBaseline,testMerge:mergePayloads,testShouldSync:shouldSyncKey,testApply:applyPayload,`); + await page.addScriptTag({content:'var orders=[],boxes=[];'+source}); +} + +for(const hasBaseline of [false,true])test(`employee with an empty device pulls company catalog and orders without a write, baseline=${hasBaseline}`,async({page})=>{ + await syncHarness(page); + const result=await page.evaluate(async hasBaseline=>{ + const payload={format:'sun-cloud-v2',version:2,storage:{sunBoxes:{t:'j',v:[{id:'real-box',name:'Рабочий бокс',price:1200}]},sunOrders:{t:'j',v:[{id:'real-order',event:'Рабочий заказ',total:2400}]},sunCompanyProfileV1:{t:'j',v:{name:'Рабочая компания'}},sunReadNotificationsV1:{t:'j',v:['owner-read']}}}; + const calls=[];window.toasts=[];window.SunEnterprise={toast:(s,t)=>toasts.push({s,t})}; + SunCloudV2.testInit({rpc:async(name,args)=>{calls.push(name);if(name==='sun_fetch_app_state')return {data:[{payload,revision:7}]};throw new Error('Unexpected write '+name)}},{'app.read':true,'orders.view':true,'orders.create':true,'orders.edit':true,'orders.delete':false,'catalog.view':true,'catalog.edit':false,'settings.edit':false}); + localStorage.setItem('sunBoxes','[]');localStorage.setItem('sunOrders','[]');localStorage.setItem('sunReadNotificationsV1','["employee-read"]');localStorage.setItem('sunPrintSettingsV1','{"localDefault":true}'); + if(hasBaseline)await SunCloudV2.testBaseline({payload,revision:7}); + await SunCloudV2.syncNow({quiet:true}); + return {calls,toasts,status:SunCloudV2.status().lastStatus,orders:JSON.parse(localStorage.sunOrders),boxes:JSON.parse(localStorage.sunBoxes),runtimeOrderIds:orders.map(o=>o.id),runtimeBoxIds:boxes.map(b=>b.id),profile:JSON.parse(localStorage.sunCompanyProfileV1)}; + },hasBaseline); + expect(result.status).toBe('ready');expect(result.calls).toEqual(['sun_fetch_app_state']);expect(result.toasts.filter(t=>t.t==='error')).toEqual([]); + expect(result.orders.map(o=>o.id)).toEqual(['real-order']);expect(result.boxes.map(o=>o.id)).toEqual(['real-box']);expect(result.runtimeOrderIds).toEqual(['real-order']);expect(result.runtimeBoxIds).toEqual(['real-box']);expect(result.profile.name).toBe('Рабочая компания'); +}); + +test('an allowed employee order edit does not change notifications or read-only sections',async({page})=>{ + await syncHarness(page); + const r=await page.evaluate(async()=>{ + const payload={format:'sun-cloud-v2',version:2,storage:{sunBoxes:{t:'j',v:[{id:'box',name:'Рабочий бокс',price:500}]},sunOrders:{t:'j',v:[{id:1,total:500}]},sunReadNotificationsV1:{t:'j',v:['owner-only']},sunCompanyProfileV1:{t:'j',v:{name:'Компания'}}}}; + let row={payload,revision:1};const writes=[];window.toasts=[];window.SunEnterprise={toast:(s,t)=>toasts.push({s,t})}; + SunCloudV2.testInit({rpc:async(name,args)=>{if(name==='sun_fetch_app_state')return {data:[structuredClone(row)]};if(name==='sun_save_app_state_v17'){ + for(const k of new Set([...Object.keys(payload.storage),...Object.keys(args.p_payload.storage)]))if(k!=='sunOrders'&&JSON.stringify(payload.storage[k])!==JSON.stringify(args.p_payload.storage[k]))return {error:{message:'Нет права изменять раздел: '+k}}; + writes.push(structuredClone(args.p_payload));row={payload:structuredClone(args.p_payload),revision:2};return {data:[row]}; + }throw new Error(name)}},{'app.read':true,'orders.view':true,'orders.edit':true,'catalog.view':true,'catalog.edit':false,'settings.edit':false}); + await SunCloudV2.testApply(payload,true);await SunCloudV2.testBaseline(row); + localStorage.setItem('sunOrders','[{"id":1,"total":700}]');localStorage.setItem('sunReadNotificationsV1','["employee-only"]');localStorage.setItem('sunPrintSettingsV1','{"bootstrap":true}'); + await SunCloudV2.syncNow({quiet:true});return {writes,toasts,status:SunCloudV2.status().lastStatus}; + }); + expect(r.status).toBe('ready');expect(r.writes).toHaveLength(1);expect(r.writes[0].storage.sunOrders.v).toEqual([{id:1,total:700}]);expect(r.writes[0].storage.sunReadNotificationsV1.v).toEqual(['owner-only']);expect(r.writes[0].storage.sunPrintSettingsV1).toBeUndefined();expect(r.toasts.filter(t=>t.t==='error')).toEqual([]); +}); + +test('order reconciliation preserves records without delete rights and does not grant create/edit rights',async({page})=>{ + await syncHarness(page); + const r=await page.evaluate(()=>{ + const p=rows=>({format:'sun-cloud-v2',version:2,storage:{sunOrders:{t:'j',v:rows}}}),base=p([{id:1,total:500},{id:2,total:300}]),local=p([{id:1,total:700},{id:3,total:900}]); + const merge=permissions=>{SunCloudV2.testInit({},permissions);return SunCloudV2.testMerge(base,local,base).payload.storage.sunOrders.v}; + return {editor:merge({'orders.edit':true}),creator:merge({'orders.create':true}),deleter:merge({'orders.delete':true}),all:merge({'orders.create':true,'orders.edit':true,'orders.delete':true})}; + }); + expect(r.editor).toEqual([{id:1,total:700},{id:2,total:300}]);expect(r.creator).toEqual([{id:1,total:500},{id:3,total:900},{id:2,total:300}]);expect(r.deleter).toEqual([{id:1,total:500}]);expect(r.all).toEqual([{id:1,total:700},{id:3,total:900}]); +}); + +test('notification read flags are personal per user and company and absent from cloud payload',async({page})=>{ + await syncHarness(page); + const r=await page.evaluate(async()=>{ + SunCloudV2.testInit({},{});const own=SunSafe.personalStorageKey('notifications-read');localStorage.setItem(own,'["n1"]');localStorage.setItem('sunReadNotificationsV1','["old-shared"]'); + const getSession=SunCloudV2.getSession,getWorkspace=SunCloudV2.getWorkspace; + SunCloudV2.getSession=()=>({user:{id:'another-employee'}});const another=SunSafe.personalStorageKey('notifications-read'); + SunCloudV2.getSession=getSession;SunCloudV2.getWorkspace=()=>({id:'another-company'});const otherCompany=SunSafe.personalStorageKey('notifications-read'); + SunCloudV2.getSession=()=>null;const signedOut=SunSafe.personalStorageKey('notifications-read');SunCloudV2.getSession=getSession;SunCloudV2.getWorkspace=getWorkspace; + return {own,another,otherCompany,signedOut,otherRead:localStorage.getItem(another),payload:await SunCloudV2.snapshotLocal(),oldSync:SunCloudV2.testShouldSync('sunReadNotificationsV1')}; + }); + expect(new Set([r.own,r.another,r.otherCompany]).size).toBe(3);expect(r.signedOut).toBe('');expect(r.otherRead).toBeNull();expect(r.oldSync).toBe(false);expect(Object.keys(r.payload.storage).some(k=>/Notifications|personal/.test(k))).toBe(false); + const html=fs.readFileSync('public/index.html','utf8');expect(html).toContain("personalStorageKey('notifications-read')");expect(html).not.toContain("const readKey='sunReadNotificationsV1'"); +}); + +test('employee sidebar follows verified workspace, resets during a switch and ignores late replies',async({page})=>{ + await blank(page); + await page.evaluate(()=>{ + window.selected='solnce-company';window.brandCalls=[];window.late=null; + window.SunCloudV2={getSession:()=>({user:{id:'employee'}}),getWorkspace:()=>({id:selected}),getClient:()=>({rpc:async(name,args)=>{brandCalls.push({name,args});if(args?.p_workspace==='pending-company')return new Promise(resolve=>late=resolve);return {data:{variant:args?.p_workspace==='solnce-company'?'solnce':'caterium'}}}})}; + }); + await page.addScriptTag({url:'/core/company-branding.js'});await expect(page.locator('header .brand')).toContainText('Солнце Кейтеринг'); + await page.evaluate(()=>{selected='pending-company';window.dispatchEvent(new Event('sun:cloud-tenant-changing'));CateriumBranding.refreshSidebar()}); + await expect(page.locator('header .brand')).toContainText('Caterium'); + await page.evaluate(async()=>{selected='ordinary-company';await CateriumBranding.refreshSidebar();late({data:{variant:'solnce'}})}); + await expect(page.locator('header .brand')).toContainText('Caterium'); + expect(await page.evaluate(()=>brandCalls.every(c=>c.name==='caterium_workspace_sidebar_brand'&&c.args.p_workspace))).toBe(true); +}); + +test('older server fallback does not fabricate employee company branding',async({page})=>{ + await blank(page);await page.evaluate(()=>{window.calls=[];window.SunCloudV2={getSession:()=>({user:{id:'employee'}}),getWorkspace:()=>({id:'company'}),getClient:()=>({rpc:async name=>{calls.push(name);return name==='caterium_workspace_sidebar_brand'?{error:{code:'PGRST202'}}:{data:{variant:'caterium'}}}})}}); + await page.addScriptTag({url:'/core/company-branding.js'});await expect.poll(()=>page.evaluate(()=>calls.length)).toBe(2);await expect(page.locator('header .brand')).toContainText('Caterium'); +}); + +test('mobile header has visible profile/logout; failed or pending account details never hide exit',async({page},info)=>{ + test.setTimeout(60000);await page.setViewportSize({width:390,height:844}); + await page.addInitScript(trainingInit);await page.route('https://**/*',r=>r.abort()); + await page.goto('/index.html',{waitUntil:'domcontentloaded'});await page.waitForFunction(()=>window.SunCloudV2?.getWorkspace()?.id&&window.CateriumAccountCenterV1780); + await expect(page.locator('#sunCloudAuthGateV3')).toHaveCount(0,{timeout:20000}); + const profile=page.locator('#cateriumMobileAccountButton'),logout=page.locator('#cateriumMobileLogout'); + await expect(profile).toBeVisible();await expect(logout).toBeVisible();await expect(logout).toHaveText('Выйти'); + const box=await logout.boundingBox();expect(box.height).toBeGreaterThanOrEqual(44);expect(box.x).toBeGreaterThanOrEqual(0);expect(box.x+box.width).toBeLessThanOrEqual(391); + await page.evaluate(()=>{window.logoutCalls=0;window.SunCloudV2.signOut=async()=>{logoutCalls++};const c=SunCloudV2.getClient(),rpc=c.rpc;c.rpc=(name,args)=>name==='caterium_account_snapshot'?new Promise(resolve=>window.resolveProfile=resolve):rpc(name,args)}); + await page.screenshot({path:`test-results/employee-mobile-header-${info.project.name}.png`}); + await profile.click();await expect(page.locator('#cacHeaderLogout')).toBeVisible();await expect(page.locator('#cacLogout')).toBeAttached(); + await page.locator('#cacHeaderLogout').click();expect(await page.evaluate(()=>logoutCalls)).toBe(1); + await page.evaluate(()=>resolveProfile({error:{message:'Offline'}}));await expect(page.locator('#cateriumAccountCenterBody')).toContainText('Выход из аккаунта доступен'); + await expect(page.locator('#cacHeaderLogout')).toBeVisible();await page.screenshot({path:`test-results/employee-profile-error-${info.project.name}.png`}); + await page.locator('#cateriumAccountCenter .cac-close').click();await logout.click();expect(await page.evaluate(()=>logoutCalls)).toBe(2); + const overflow=await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth+1);expect(overflow).toBe(false); +}); + +test('late profile details cannot reappear after changing account or workspace',async({page})=>{ + await blank(page);await page.evaluate(()=>{window.user='a';window.ws='company-a';window.pending=[];window.SunCloudV2={getSession:()=>({user:{id:user,email:user+'@example.invalid'}}),getWorkspace:()=>({id:ws,name:ws,role:'manager'}),getClient:()=>({rpc:()=>new Promise(resolve=>pending.push(resolve))})}}); + await page.addScriptTag({url:'/core/sun-safe.js'});await page.waitForFunction(()=>window.CateriumAccountCenterV1780); + await page.evaluate(()=>{CateriumAccountCenterV1780.open()});await expect(page.locator('#cateriumAccountCenter')).toHaveClass('on'); + await page.evaluate(()=>{user='b';ws='company-b';window.dispatchEvent(new Event('sun:cloud-tenant-changing'));pending[0]({data:{company_name:'PRIVATE OLD COMPANY',is_owner:true}})}); + await expect(page.locator('#cateriumAccountCenter')).not.toHaveClass('on');await page.evaluate(()=>{CateriumAccountCenterV1780.open()}); + await expect(page.locator('#cateriumAccountCenterBody')).toContainText('company-b');await expect(page.locator('#cateriumAccountCenterBody')).not.toContainText('PRIVATE OLD COMPANY'); +}); diff --git a/tests/playwright.config.mjs b/tests/playwright.config.mjs index 4fbb891..fdb5ed0 100644 --- a/tests/playwright.config.mjs +++ b/tests/playwright.config.mjs @@ -2,13 +2,13 @@ import { defineConfig, devices } from '@playwright/test'; import {fileURLToPath} from 'node:url'; export default defineConfig({ testDir:'.', - testMatch:['banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs','account-access.spec.mjs','banquet-menu.spec.mjs','calendar-print.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','trial-demo.spec.mjs','proposal-quality.spec.mjs'], + testMatch:['employee-session.spec.mjs','banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs','account-access.spec.mjs','banquet-menu.spec.mjs','calendar-print.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','trial-demo.spec.mjs','proposal-quality.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}, projects:[ {name:'iphone-pdf',testMatch:['proposal-quality.spec.mjs'],grep:/all six selections|transparent wide|an actual offer downloads/,use:{...devices['iPhone 13'],serviceWorkers:'block'}}, - {name:'iphone-webkit',testMatch:['banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','account-access.spec.mjs','calendar-print.spec.mjs'],use:{...devices['iPhone 13'],serviceWorkers:'block'}}, + {name:'iphone-webkit',testMatch:['employee-session.spec.mjs','banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','account-access.spec.mjs','calendar-print.spec.mjs'],use:{...devices['iPhone 13'],serviceWorkers:'block'}}, {name:'desktop',use:{...devices['Desktop Chrome']}}, {name:'mobile-390',use:{viewport:{width:390,height:844},isMobile:true,hasTouch:true}} ] diff --git a/tests/production-employee-session.mjs b/tests/production-employee-session.mjs new file mode 100644 index 0000000..a2b9258 --- /dev/null +++ b/tests/production-employee-session.mjs @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import {createHash} from 'node:crypto'; +import {chromium,expect} from '@playwright/test'; +import {trainingInit} from './training-catalog-fixture.mjs'; + +// Read only public application assets. Use synthetic SDK responses; no live +// customer credentials, database reads, writes, or session termination. +const base=new URL(process.env.TIMEWEB_BASE_URL||'https://app.caterium.ru'); +assert.equal(base.protocol,'https:'); +const output='production-ui-results';await fs.mkdir(output,{recursive:true}); +const files=['core/account-center-v1780.js','core/company-branding.js','core/sun-safe.js','app-runtime.js','index.html','service-worker.js']; +for(const file of files){ + const response=await fetch(new URL(file+'?employee_check='+Date.now(),base),{signal:AbortSignal.timeout(20000),headers:{'Cache-Control':'no-cache'}}); + assert(response.ok,'Published asset unavailable: '+file); + const actual=Buffer.from(await response.arrayBuffer()),expected=await fs.readFile('public/'+file); + assert.equal(createHash('sha256').update(actual).digest('hex'),createHash('sha256').update(expected).digest('hex'),'Published revision differs: '+file); +} +const browser=await chromium.launch(); +try{ + const context=await browser.newContext({viewport:{width:390,height:844},isMobile:true,hasTouch:true,serviceWorkers:'block'}); + await context.route('**/*',route=>{const req=route.request(),url=new URL(req.url());return req.method()==='GET'&&url.origin===base.origin&&!url.pathname.startsWith('/api/')?route.continue():route.abort()}); + await context.addInitScript(trainingInit); + const page=await context.newPage();await page.goto(base.href,{waitUntil:'domcontentloaded',timeout:60000}); + await page.waitForFunction(()=>window.SunCloudV2?.getWorkspace()?.id&&window.CateriumAccountCenterV1780); + await expect(page.locator('#sunCloudAuthGateV3')).toHaveCount(0,{timeout:20000}); + await expect(page.locator('#cateriumMobileLogout')).toBeVisible();await expect(page.locator('#cateriumMobileLogout')).toHaveText('Выйти'); + await expect(page.locator('#cateriumMobileAccountButton')).toBeVisible(); + await page.evaluate(()=>{window.exitChecks=0;SunCloudV2.signOut=async()=>{exitChecks++};const c=SunCloudV2.getClient(),rpc=c.rpc;c.rpc=(n,a)=>n==='caterium_account_snapshot'?Promise.resolve({error:{message:'Synthetic offline'}}):rpc(n,a)}); + await page.screenshot({path:output+'/employee-mobile-header.png'}); + await page.locator('#cateriumMobileAccountButton').click(); + await expect(page.locator('#cateriumAccountCenterBody')).toContainText('Выход из аккаунта доступен'); + await expect(page.locator('#cacHeaderLogout')).toBeVisible();await page.locator('#cacHeaderLogout').click(); + assert.equal(await page.evaluate(()=>exitChecks),1); + await page.screenshot({path:output+'/employee-profile-offline.png'}); + await page.locator('#cateriumAccountCenter .cac-close').click();await page.locator('#cateriumMobileLogout').click();assert.equal(await page.evaluate(()=>exitChecks),2); + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth+1),false); + await fs.writeFile(output+'/employee-session.json',JSON.stringify({checkedAt:new Date().toISOString(),base:base.href,assetHashes:'match',mobileLogout:'pass',profileRpcFailure:'exit remains usable',backend:'synthetic, blocked',liveMembership:'not inspected',sqlMigration:'not deployed by frontend workflow'},null,2)); + console.log('PASS published employee session: exact assets, visible mobile logout, profile unavailable fallback; synthetic backend only'); + await context.close(); +}finally{await browser.close()} diff --git a/tests/recovery/employee-session.mjs b/tests/recovery/employee-session.mjs new file mode 100644 index 0000000..d3cb1df --- /dev/null +++ b/tests/recovery/employee-session.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +export async function run(db,{owner,employee,workspace,foreign}){ + const q=(sql,args=[])=>db.query(sql,args),one=async(sql,args)=>(await q(sql,args)).rows[0]; + const actor=async id=>{await db.exec('reset role');await q("select set_config('request.jwt.claim.sub',$1,false),set_config('request.jwt.claims',$2,false)",[id,JSON.stringify({sub:id,aal:'aal1',role:'authenticated'})]);await db.exec('set role authenticated')}; + const brand=async (id=workspace)=>(await one('select public.caterium_workspace_sidebar_brand($1) as b',[id])).b; + await db.exec('reset role');const before=await one('select payload,revision from public.sun_app_state where workspace_id=$1',[workspace]); + await actor(owner);assert.equal((await brand()).variant,'solnce'); + await actor(employee);assert.equal((await brand()).variant,'solnce','active company employee inherits sidebar identity'); + await assert.rejects(brand(foreign),/Нет доступа/); + await assert.rejects(q('update public.caterium_sidebar_brand_assignment set workspace_id=$1',[foreign]),/permission denied/); + await db.exec('reset role');await q('update public.sun_workspace_members set is_active=false where user_id=$1 and workspace_id=$2',[employee,workspace]); + await actor(employee);await assert.rejects(brand(),/Нет доступа/); + await db.exec('reset role');await q('update public.sun_workspace_members set is_active=true where user_id=$1 and workspace_id=$2',[employee,workspace]);await q('update auth.users set email_confirmed_at=null where id=$1',[employee]); + await actor(employee);await assert.rejects(brand(),/Нет доступа/); + await db.exec('reset role');await q('update auth.users set email_confirmed_at=now() where id=$1',[employee]);await q("insert into public.sun_workspace_members(workspace_id,user_id,role,is_active,permissions) values($1,$2,'viewer',true,public.sun_role_default_permissions('viewer'))",[foreign,employee]); + await actor(employee);assert.equal((await brand(foreign)).variant,'caterium','same employee in another workspace does not carry branding'); + await db.exec('reset role');const after=await one('select payload,revision from public.sun_app_state where workspace_id=$1',[workspace]);assert.deepEqual(after,before,'branding never writes business state'); + await db.exec('set role anon');await assert.rejects(brand(),/permission denied/);await db.exec('reset role'); + console.log('PASS employee sidebar: active membership, selected workspace, confirmation, no branding spoof, no business-data changes'); +} diff --git a/tests/recovery/smoke.mjs b/tests/recovery/smoke.mjs index f74ffa7..9eafefe 100644 --- a/tests/recovery/smoke.mjs +++ b/tests/recovery/smoke.mjs @@ -151,5 +151,6 @@ await db.exec('reset role; set role anon');await denied('select public.caterium_ await db.exec('reset role; set role anon');await denied('select public.caterium_install_trial_demo($1)',[devWs],/permission denied/);await denied('select public.caterium_trial_demo_seed_v1()',[],/permission denied/); console.log('PASS demo: trial auto seed, 10 box + 18 banquet TTKs, stock links, additive upgrade preserving orders/prices/balances, idempotency, populated catalog guard, Solnce exclusion, own developer account and preserved clients'); console.log('PASS smoke: owner onboarding, save/read, optimistic conflict, normalized catalog/orders/clients, backups, employees, cross-tenant isolation, viewer write denial, AAL2, developer RPCs'); +await (await import('./employee-session.mjs')).run(db,{owner:ids[1],employee:ids[3],workspace:ws,foreign}); await db.close(); }