Fix employee sync and expose mobile profile/logout (#37)

Keep notification-read state personal to user/company, preserve server read-only sections during staff synchronization, and respect separate order-create/edit/delete rights. Add visible mobile header session actions and a sticky logout that survives profile RPC failure; scope asynchronous profile/branding to user and workspace. Targeted browser suites and isolated SQL recovery tests passed in run 35506145405, iPhone screenshots reviewed. Full main QA remains required before production promotion. Workspace branding RPC migration is included but has NOT been applied to production Supabase; older servers retain safe owner-only fallback. No live membership/business-data repair is claimed without identifying the reported employee.
This commit is contained in:
pavlov346346-source 2026-09-20 13:55:56 +03:00 committed by GitHub
parent bc3f186992
commit 1e644e6dfb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 370 additions and 34 deletions

View File

@ -43,6 +43,9 @@ jobs:
files=( files=(
index.html index.html
app-runtime.js 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.js
core/login-signature-v1776.css core/login-signature-v1776.css
core/help-center.js core/help-center.js
@ -124,6 +127,9 @@ jobs:
- name: Check the published single-page banquet menu - name: Check the published single-page banquet menu
timeout-minutes: 4 timeout-minutes: 4
run: node tests/production-banquet-client-menu.mjs 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 - name: Save production UI verification
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

View File

@ -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.

View File

@ -2308,7 +2308,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
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 === 'sunUsersV1' || key === 'sunSessionUserV1') 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 === 'sunStaticMapGeocodeCacheV2') return false;
if (key === 'sunCloudSyncMetaV1' || key === 'sunCloudSyncTokenV1' || key === 'sunCloudSyncWorkspaceV1') return false; if (key === 'sunCloudSyncMetaV1' || key === 'sunCloudSyncTokenV1' || key === 'sunCloudSyncWorkspaceV1') return false;
if (key === 'sunOrdersView' || key === 'sunCalendarMode' || key === 'sunCatalogViewModeV1' || key === 'sunNewOrderSplitRatioV5') 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) { 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=>{ document.querySelectorAll(selectors.join(',')).forEach(btn=>{
if(!(btn instanceof HTMLButtonElement))return; if(!(btn instanceof HTMLButtonElement))return;
if(busy){btn.dataset.sunOldText=btn.textContent||'';btn.disabled=true;btn.textContent='Выходим…';} if(busy){btn.dataset.sunOldText=btn.textContent||'';btn.disabled=true;btn.textContent='Выходим…';}
@ -2641,6 +2641,22 @@ window.SUN_LEGACY_CATALOG_V175=[];
return clone(local); 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) { function mergePayloads(basePayload, localPayload, remotePayload) {
const conflicts = []; const conflicts = [];
const base = basePayload?.storage || {}; 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)]); const keys = new Set([...Object.keys(base), ...Object.keys(local), ...Object.keys(remote)]);
for (const key of keys) { for (const key of keys) {
const b = base[key], l = local[key], r = remote[key]; 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 && r === undefined) continue;
if (l === undefined) { if (l === undefined) {
if (b !== undefined && !equal(r,b)) conflicts.push(key); 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); 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)]}; 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(!current())return;
if (!remote || payloadEmpty(remote.payload)) { if (!remote || payloadEmpty(remote.payload)) {
if (!canWrite()) throw new Error('В облаке нет базы, а у пользователя нет прав на её создание.'); 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; if(!current())return;
await setBaseline(row); if(!current())return; dirty=false; config.lastSync=row.updated_at||new Date().toISOString();saveConfig(); await setBaseline(row); if(!current())return; dirty=false; config.lastSync=row.updated_at||new Date().toISOString();saveConfig();
setStatus('ready','Синхронизировано.'); if(!quiet)toast('Синхронизация завершена.','success'); return; setStatus('ready','Синхронизировано.'); if(!quiet)toast('Синхронизация завершена.','success'); return;
@ -2970,8 +3002,8 @@ window.SUN_LEGACY_CATALOG_V175=[];
const merged = mergePayloads(baseline.payload, localPayload, remote.payload); const merged = mergePayloads(baseline.payload, localPayload, remote.payload);
let finalRow = remote; let finalRow = remote;
const changedVsRemote = !equal(merged.payload, remote.payload); const changedVsRemote = !equalSyncPayload(merged.payload, remote.payload);
const changedVsLocal = !equal(merged.payload, localPayload); const changedVsLocal = !equalSyncPayload(merged.payload, localPayload);
if (changedVsRemote) { if (changedVsRemote) {
if (!canWrite()) { if (!canWrite()) {

View File

@ -1,7 +1,7 @@
(()=>{ (()=>{
'use strict'; 'use strict';
if(window.CateriumAccountCenterV1780)return; 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 $=(s,r=document)=>r.querySelector(s);
const qa=(s,r=document)=>[...r.querySelectorAll(s)]; const qa=(s,r=document)=>[...r.querySelectorAll(s)];
const esc=v=>window.SunSafe?.escapeHTML?window.SunSafe.escapeHTML(String(v??'')):String(v??''); 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 snapshot=null;
let modal=null; let modal=null;
let lastUserId=''; let lastUserId='';
let snapshotScope='',loadSequence=0,openSequence=0;
const accountScope=()=>JSON.stringify([session()?.user?.id||'',workspace()?.id||'']);
const profileIcon='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="12" cy="8" r="4"/><path d="M4 21v-2a8 8 0 0 1 16 0v2"/></svg>';
const logoutIcon='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M10 4H4v16h6M14 8l4 4-4 4M8 12h12"/></svg>';
function installStyle(){ function installStyle(){
if($('#caterium-account-center-style'))return; 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{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.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-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 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-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-close{border:0;background:transparent;font-size:31px;color:#5c564d;padding:0 4px}
#cateriumAccountCenter .cac-body{padding:24px 30px 30px} #cateriumAccountCenter .cac-body{padding:24px 30px 30px}
@ -45,31 +50,44 @@ function installStyle(){
#cateriumAccountButton{display:flex!important} #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 [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} #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}} @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); `;document.head.appendChild(s);
} }
function ensureModal(){ function ensureModal(){
if(modal)return modal; if(modal)return modal;
modal=document.createElement('div');modal.id='cateriumAccountCenter';modal.innerHTML='<div class="cac-shell"><div class="cac-head"><div><h2>Мой аккаунт</h2><div class="cac-sub">Профиль и безопасность Caterium</div></div><button class="cac-close" type="button" aria-label="Закрыть">×</button></div><div class="cac-body" id="cateriumAccountCenterBody"></div></div>'; modal=document.createElement('div');modal.id='cateriumAccountCenter';modal.innerHTML='<div class="cac-shell"><div class="cac-head"><div><h2>Мой аккаунт</h2><div class="cac-sub">Профиль и безопасность Caterium</div></div><div class="cac-head-actions"><button class="outline" id="cacHeaderLogout" type="button">Выйти</button><button class="cac-close" type="button" aria-label="Закрыть">×</button></div></div><div class="cac-body" id="cateriumAccountCenterBody"></div></div>';
document.body.appendChild(modal); document.body.appendChild(modal);
$('#cacHeaderLogout',modal).onclick=logout;
modal.addEventListener('click',e=>{if(e.target===modal||e.target.closest('.cac-close'))close();}); modal.addEventListener('click',e=>{if(e.target===modal||e.target.closest('.cac-close'))close();});
return modal; return modal;
} }
async function loadSnapshot(){ async function loadSnapshot(){
const c=client(),ws=workspace();if(!c||!ws?.id)return null; 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}); 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(){ function render(){
const body=$('#cateriumAccountCenterBody');if(!body)return; 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}`:'—'; const companyNo=s.company_number?`${s.company_number}`:'—';
body.innerHTML=`<div class="cac-grid"> body.innerHTML=`<div class="cac-grid">
<section class="cac-card"> <section class="cac-card">
<h3>Профиль</h3> <h3>Профиль</h3>
${owner?'<div class="cac-owner"><div><b>Главный аккаунт компании</b><span>Только этот аккаунт создаёт сотрудников и распределяет права.</span></div></div>':''} ${owner?'<div class="cac-owner"><div><b>Главный аккаунт компании</b><span>Только этот аккаунт создаёт сотрудников и распределяет права.</span></div></div>':''}
<div class="cac-meta"><div><span>Компания</span><b>${esc(s.company_name||'')} · ${esc(companyNo)}</b></div><div><span>Статус</span><b>${esc(owner?'Владелец':ROLE_LABELS[s.role]||s.role||'Сотрудник')}</b></div></div> <div class="cac-meta"><div><span>Компания</span><b>${esc(s.company_name||ws?.name||'')} · ${esc(companyNo)}</b></div><div><span>Статус</span><b>${esc(owner?'Владелец':ROLE_LABELS[s.role||ws?.role]||s.role||ws?.role||'Сотрудник')}</b></div></div>
<label>Имя<input id="cacName" value="${esc(s.display_name||ss?.user?.user_metadata?.name||'')}"></label> <label>Имя<input id="cacName" value="${esc(s.display_name||ss?.user?.user_metadata?.name||'')}"></label>
<div class="cac-actions"><button class="primary" id="cacSaveName" type="button">Сохранить имя</button></div> <div class="cac-actions"><button class="primary" id="cacSaveName" type="button">Сохранить имя</button></div>
</section> </section>
@ -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')} 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} 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(){ async function open(){
ensureModal();modal.classList.add('on');const body=$('#cateriumAccountCenterBody');body.innerHTML='<p class="cac-note">Загружаю аккаунт…</p>'; ensureModal();const request=++openSequence,scope=accountScope();modal.classList.add('on');render();
try{await loadSnapshot();render()}catch(e){body.innerHTML=`<p class="cac-note">${esc(e?.message||e||'Не удалось загрузить аккаунт.')}</p>`} 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(){ function sanitizeRbac(){
const body=$('#sunRbacBody');if(!body)return; const body=$('#sunRbacBody');if(!body)return;
qa('select option[value="admin"]',body).forEach(o=>o.remove()); qa('select option[value="admin"]',body).forEach(o=>o.remove());
@ -133,18 +154,25 @@ function ensureSidebarEntry(){
if(!button){ if(!button){
button=document.createElement('button'); button=document.createElement('button');
button.id='cateriumAccountButton';button.type='button';button.className='sun-side-action';button.title='Профиль, email и пароль'; button.id='cateriumAccountButton';button.type='button';button.className='sun-side-action';button.title='Профиль, email и пароль';
button.innerHTML='<span>◎</span><span>Мой аккаунт</span>'; button.innerHTML=profileIcon+'<span>Мой аккаунт</span>';
footer.insertBefore(button,footer.querySelector('#sunLogoutBtn')||footer.firstChild); footer.insertBefore(button,footer.querySelector('#sunLogoutBtn')||footer.firstChild);
} }
if(!button.dataset.cateriumAccountBound){button.dataset.cateriumAccountBound='1';button.addEventListener('click',open)} 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='<button id="cateriumMobileAccountButton" type="button" aria-label="Мой аккаунт">'+profileIcon+'<span>Профиль</span></button><button id="cateriumMobileLogout" type="button" aria-label="Выйти из аккаунта">'+logoutIcon+'<span>Выйти</span></button>';header.appendChild(bar);$('#cateriumMobileAccountButton',bar).onclick=open;$('#cateriumMobileLogout',bar).onclick=logout;}
}
function bindEntry(){ function bindEntry(){
const label=$('#sunCurrentUserLabel');if(label&&!label.dataset.cateriumAccountBound){label.dataset.cateriumAccountBound='1';label.title='Открыть личный кабинет';label.addEventListener('click',open)} 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;} const uid=accountScope();if(uid!==lastUserId){lastUserId=uid;snapshot=null;snapshotScope='';loadSequence++;close();}
ensureSidebarEntry();sanitizeRbac(); ensureSidebarEntry();ensureMobileEntry();sanitizeRbac();
} }
installStyle();ensureModal();bindEntry(); installStyle();ensureModal();bindEntry();
const obs=new MutationObserver(bindEntry);obs.observe(document.documentElement,{childList:true,subtree:true}); 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)); window.addEventListener('sun:cloud-permissions-changed',()=>setTimeout(bindEntry,0));
setInterval(()=>{if(!document.hidden)bindEntry()},3000); setInterval(()=>{if(!document.hidden)bindEntry()},3000);
window.CateriumAccountCenterV1780=Object.freeze({VERSION,open,close,reload:async()=>{await loadSnapshot();render();return snapshot}}); window.CateriumAccountCenterV1780=Object.freeze({VERSION,open,close,reload:async()=>{await loadSnapshot();render();return snapshot}});

View File

@ -60,6 +60,7 @@
let sidebar={name:'Caterium',logo:'caterium-mark-light.svg',special:false}; let sidebar={name:'Caterium',logo:'caterium-mark-light.svg',special:false};
let request=0,checkedUser='',pendingUser=''; let request=0,checkedUser='',pendingUser='';
const sidebarScope=()=>JSON.stringify([window.SunCloudV2?.getSession?.()?.user?.id||'',window.SunCloudV2?.getWorkspace?.()?.id||'']);
function sidebarHTML(){return `<img class="real-logo${sidebar.special?'':' caterium-product-mark'}" src="${esc(sidebar.logo)}" alt="${esc(sidebar.name)}"><span>${esc(sidebar.name)}</span>`;} function sidebarHTML(){return `<img class="real-logo${sidebar.special?'':' caterium-product-mark'}" src="${esc(sidebar.logo)}" alt="${esc(sidebar.name)}"><span>${esc(sidebar.name)}</span>`;}
function renderSidebar(){ function renderSidebar(){
const node=document.querySelector('body > header .brand');if(!node)return; const node=document.querySelector('body > header .brand');if(!node)return;
@ -70,14 +71,18 @@
async function refreshSidebar(){ async function refreshSidebar(){
const cloud=window.SunCloudV2,session=cloud?.getSession?.(),uid=session?.user?.id||''; const cloud=window.SunCloudV2,session=cloud?.getSession?.(),uid=session?.user?.id||'';
if(!uid){resetSidebar();return;} if(!uid){resetSidebar();return;}
if(checkedUser===uid||pendingUser===uid){renderSidebar();return;} const ws=cloud.getWorkspace?.()?.id||'',scope=sidebarScope();
resetSidebar();pendingUser=uid;const current=request; if(checkedUser===scope||pendingUser===scope){renderSidebar();return;}
resetSidebar();pendingUser=scope;const current=request;
try{ try{
const result=await cloud.getClient()?.rpc('caterium_my_sidebar_brand'); // New RPC verifies active membership and the selected company. An older
if(current!==request||window.SunCloudV2?.getSession?.()?.user?.id!==uid)return; // 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?.error||!result?.data)return;
if(result.data.variant==='solnce')sidebar={name:'Солнце Кейтеринг',logo:'sun-logo.png',special:true}; 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. */} }catch(_){/* Until identity is confirmed, show the shared product brand. */}
finally{if(current===request)pendingUser='';} finally{if(current===request)pendingUser='';}
} }
@ -90,6 +95,7 @@
nameHTML:()=>esc(documentName()),logoHTML:()=>esc(documentImage()),contactsHTML:()=>esc(documentContacts()),cityHTML:()=>esc(documentCity()), nameHTML:()=>esc(documentName()),logoHTML:()=>esc(documentImage()),contactsHTML:()=>esc(documentContacts()),cityHTML:()=>esc(documentCity()),
escapeHTML:esc,snapshot,saveName,saveLogo,clearLogo,prepareLogoImage,sidebarHTML,renderSidebar,refreshSidebar,resetSidebar}); 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()}); 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()}); 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(); if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot();
})(); })();

View File

@ -4,6 +4,11 @@
'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
})[ch]); })[ch]);
const escapeAttr=escapeHTML; 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 idToken=value=>String(value??'').replace(/[^a-zA-Z0-9_-]/g,'');
const jsArg=value=>escapeHTML(JSON.stringify(String(value??''))); const jsArg=value=>escapeHTML(JSON.stringify(String(value??'')));
// Existing trial databases and saved offers still reference the PNG originals. // 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); if(reference&&reference.parentNode===parent)parent.insertBefore(node,reference);else parent.appendChild(node);
return 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 // Small bootstrap for account/profile UI. Keeping it here makes the account
// center available on every Caterium screen without touching the legacy monolith. // center available on every Caterium screen without touching the legacy monolith.
if(!document.getElementById('cateriumAccountCenterV1780Script')){ if(!document.getElementById('cateriumAccountCenterV1780Script')){
const script=document.createElement('script'); const script=document.createElement('script');
script.id='cateriumAccountCenterV1780Script'; 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; script.async=true;
document.head.appendChild(script); document.head.appendChild(script);
} }

File diff suppressed because one or more lines are too long

View File

@ -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 VERSION='20260918-ui-stability';
const CORE=[ const CORE=[
'./core/banquet-client-menu.js?v=20260920-onepage', './core/banquet-client-menu.js?v=20260920-onepage',

View File

@ -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;

View File

@ -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:'<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"></head><body><header><div class="brand"></div></header></body></html>'}));
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');
});

View File

@ -2,13 +2,13 @@ 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:['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, 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},
projects:[ 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-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:'desktop',use:{...devices['Desktop Chrome']}},
{name:'mobile-390',use:{viewport:{width:390,height:844},isMobile:true,hasTouch:true}} {name:'mobile-390',use:{viewport:{width:390,height:844},isMobile:true,hasTouch:true}}
] ]

View File

@ -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()}

View File

@ -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');
}

View File

@ -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/); 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 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'); 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(); await db.close();
} }