auth ui: prevent flash of manager-only controls on load

Anonymous visitors briefly saw the manager-only filters (Brand/Manager/Status),
checkboxes and appearance menu on refresh, because applyRoleUi() hides them only
after the async Keycloak check-sso resolves.

Fix: mark those elements .manager-only and hide them pre-paint via a .role-pending
class set on <html> by the inline head script, gated by a render-blocking CSS rule
(html.role-pending .manager-only { display:none }). applyRoleUi() drops
.role-pending once the role is known and then applies the final per-element state.
Default is now fail-closed (hidden until confirmed manager), so anonymous never
flashes them; managers see the controls appear a moment after load.
This commit is contained in:
aaverbitskiy 2026-08-14 11:29:51 +00:00
parent 56760fbcff
commit 341bc42778
3 changed files with 17 additions and 5 deletions

View File

@ -9,6 +9,10 @@
// hidden — force light regardless of stored/OS preference. To re-enable, // hidden — force light regardless of stored/OS preference. To re-enable,
// restore the localStorage/prefers-color-scheme logic and unhide the toggle. // restore the localStorage/prefers-color-scheme logic and unhide the toggle.
document.documentElement.setAttribute('data-theme', 'light'); document.documentElement.setAttribute('data-theme', 'light');
// Role is unknown until Keycloak check-sso resolves (async). Start in the
// "pending" state that hides manager-only controls, so anonymous visitors
// never see a flash of them before applyRoleUi() runs.
document.documentElement.classList.add('role-pending');
</script> </script>
</head> </head>
<body> <body>
@ -47,7 +51,7 @@
<div class="dropdown-panel" id="city-panel"></div> <div class="dropdown-panel" id="city-panel"></div>
</div> </div>
</div> </div>
<div class="field"> <div class="field manager-only">
<label>Бренд</label> <label>Бренд</label>
<div class="dropdown" id="brand-dropdown"> <div class="dropdown" id="brand-dropdown">
<button type="button" class="dropdown-btn" id="brand-btn"> <button type="button" class="dropdown-btn" id="brand-btn">
@ -67,7 +71,7 @@
<div class="dropdown-panel" id="dimension-panel"></div> <div class="dropdown-panel" id="dimension-panel"></div>
</div> </div>
</div> </div>
<div class="field"> <div class="field manager-only">
<label>Менеджер</label> <label>Менеджер</label>
<div class="dropdown" id="manager-dropdown"> <div class="dropdown" id="manager-dropdown">
<button type="button" class="dropdown-btn" id="manager-btn"> <button type="button" class="dropdown-btn" id="manager-btn">
@ -85,7 +89,7 @@
<label for="date-end">Дата окончания (до)</label> <label for="date-end">Дата окончания (до)</label>
<input type="date" id="date-end" /> <input type="date" id="date-end" />
</div> </div>
<div class="field"> <div class="field manager-only">
<label>Статус</label> <label>Статус</label>
<div class="dropdown" id="status-dropdown"> <div class="dropdown" id="status-dropdown">
<button type="button" class="dropdown-btn" id="status-btn"> <button type="button" class="dropdown-btn" id="status-btn">
@ -109,7 +113,7 @@
<input type="range" id="zoom-slider" min="0" max="9" step="1" value="3" /> <input type="range" id="zoom-slider" min="0" max="9" step="1" value="3" />
</div> </div>
</div> </div>
<div class="checks"> <div class="checks manager-only">
<div class="field checkbox-field"> <div class="field checkbox-field">
<label for="show-brand"><input type="checkbox" id="show-brand" checked /> Бренд</label> <label for="show-brand"><input type="checkbox" id="show-brand" checked /> Бренд</label>
</div> </div>
@ -128,7 +132,7 @@
</div> </div>
<!-- Appearance ("+") button, pinned to the header's bottom-right corner --> <!-- Appearance ("+") button, pinned to the header's bottom-right corner -->
<div class="decor-wrap"> <div class="decor-wrap manager-only">
<button type="button" class="decor-btn" id="decor-btn" aria-label="Оформление" data-tip="Оформление">+</button> <button type="button" class="decor-btn" id="decor-btn" aria-label="Оформление" data-tip="Оформление">+</button>
<div class="decor-menu" id="decor-menu"></div> <div class="decor-menu" id="decor-menu"></div>
</div> </div>

View File

@ -59,6 +59,9 @@ const el = {
// sense for the full-data (manager) tier so the anonymous view stays clean. // sense for the full-data (manager) tier so the anonymous view stays clean.
function applyRoleUi(): void { function applyRoleUi(): void {
const manager = isManager(); const manager = isManager();
// Role resolved — leave the pre-paint "pending" state; from here explicit
// per-element display below is the source of truth.
document.documentElement.classList.remove('role-pending');
const hideField = (node: Element | null): void => { const hideField = (node: Element | null): void => {
const f = node?.closest('.field') as HTMLElement | null; const f = node?.closest('.field') as HTMLElement | null;
if (f) f.style.display = manager ? '' : 'none'; if (f) f.style.display = manager ? '' : 'none';

View File

@ -313,6 +313,11 @@ input::placeholder { color: var(--text-muted); }
} }
.theme-toggle:hover { border-color: var(--border-strong); background: var(--surface); } .theme-toggle:hover { border-color: var(--border-strong); background: var(--surface); }
/* Anti-flash: until the role is resolved (Keycloak check-sso), hide every
manager-only control so anonymous visitors never see them flash on load.
applyRoleUi() drops .role-pending and then sets each element's final state. */
html.role-pending .manager-only { display: none !important; }
/* Login / logout button — pinned to the header's top-right corner */ /* Login / logout button — pinned to the header's top-right corner */
.auth-wrap { position: absolute; right: 18px; top: 12px; z-index: 3; } .auth-wrap { position: absolute; right: 18px; top: 12px; z-index: 3; }