CLASSIC KNACK BUILDER ONLY
One of the annoyances I’ve felt with Knack is that it sets up circumstances where you can present a menu button or a link to a user for a Knack page they don’t have access to - which gives them an error message and is a poor user experience.
This JS fixes that issue - it looks at every link/menu button as a page is rendered, and evaluates whether the logged in user has access to the Knack page behind the menu button or link, and if they don’t, it hides that page or link. Automatically, across all pages, without lifting a finger.
Its heavily documented so easy to understand what its up to. It “fails safe” by blocking nothing unless its certain. Free to any good home. I hope it helps you in some way.
/* ==========================================================================
* knack-hide-inaccessible-links.js — Version 2
* --------------------------------------------------------------------------
* Hide links and menu buttons to pages the logged-in user isn't allowed
* to open.
*
* Licence: MIT — free to use, modify and share. No warranty.
*
* --------------------------------------------------------------------------
* THE PROBLEM
* --------------------------------------------------------------------------
* Knack lets you restrict a page (including a child page, such as an "Edit"
* page under a "View" page) to specific user roles. Knack enforces that
* restriction properly: a user without the right role gets a "doesn't have
* permission to access this page" login screen, and the API returns 403.
*
* But Knack still SHOWS the menu buttons and links that lead to those
* pages. Users click "Edit", hit a login wall, and get confused. The usual
* workaround is to add display rules to every menu and view by hand —
* tedious, easy to get wrong, and easy to forget on new views.
*
* --------------------------------------------------------------------------
* WHAT THIS SCRIPT DOES
* --------------------------------------------------------------------------
* Whenever a page or view renders, it looks at the links that point to
* other pages in your app, works out which roles each target page
* requires, and hides the link if the current user doesn't have them.
*
* - Reads the page structure Knack already loads into the browser, so there
* is NOTHING TO CONFIGURE per page. Add a restriction in the Builder and
* the matching buttons disappear automatically.
* - Handles stacked restrictions: if the path to a page passes through more
* than one role-restricted login, the user must satisfy every one of them
* (exactly what Knack itself enforces).
*
* --------------------------------------------------------------------------
* NEW IN VERSION 2: SCOPE (and why it matters for speed)
* --------------------------------------------------------------------------
* Version 1 checked EVERY link on the page and watched the whole page for
* changes, re-checking whenever anything was added. On busy pages (large
* grids, calendars, other custom scripts or libraries changing the page)
* that watcher could fire constantly and make the app feel slow.
*
* Version 2 adds a SCOPE setting:
*
* 'menus' (default) — checks only menu buttons (Knack menu views), the
* app's top navigation and its dropdown menus. No page watcher runs;
* checks happen only when Knack itself renders a page or view. This
* is fast, and covers the common pattern where restricted pages
* (Edit, Add, admin areas) are reached through menu buttons, while
* grid links only go to View pages everyone can open.
*
* 'all' — checks every in-app link on the page (grid link columns,
* details links, rich text links too), and watches the page for
* links added later. Use this only if your app links to restricted
* pages from grids or details views, and accept the extra cost on
* busy pages.
*
* Not sure which you need? Start with 'menus'. If a link to a restricted
* page still shows somewhere, either switch to 'all' or add that link's
* container to MENU_SELECTOR in the settings below.
*
* Also new: set debug to true to log how long each check takes and how
* many links it looked at — handy for confirming whether this script is
* what's slowing a page down.
*
* --------------------------------------------------------------------------
* IMPORTANT: THIS IS A USER-EXPERIENCE TOOL, NOT A SECURITY CONTROL
* --------------------------------------------------------------------------
* Hiding a link does not protect anything. Anyone can type a URL or call
* the API directly. Your real protection is Knack's page login restriction
* (which is enforced on the server). This script only stops users stumbling
* into pages they can't open.
*
* Worth knowing: hiding a view with a display rule is ALSO not security. If
* a page contains a form or editable grid, any logged-in user who can open
* that page can usually reach the form's API endpoint, even if the view is
* hidden on screen. Put edit and admin views on pages that are
* role-restricted in the Builder, and test with a non-privileged user.
*
* --------------------------------------------------------------------------
* HOW IT DECIDES (the rules)
* --------------------------------------------------------------------------
* 1. Take the link's href, e.g. #customers/view-customer/{id}/edit-customer/{id}
* Strip out record IDs (24-character hex strings) and take the deepest
* remaining part that matches a page slug in your app ("edit-customer").
*
* 2. Starting at that page, walk UP through its parent pages to the top.
* In Knack, a page's login restriction lives on a login view, usually on
* a separate login page that sits above the content page (Knack creates
* it when you set the page to require login).
*
* 3. For every login view on that path that has "limit access to specific
* user roles" switched ON, the user must hold AT LEAST ONE of that
* login's allowed roles. Login views with role limiting switched OFF
* are ignored — any logged-in user gets through them.
*
* 4. If any restricted login on the path isn't satisfied, hide the link
* (for a menu button, the whole button).
*
* --------------------------------------------------------------------------
* INSTALLATION
* --------------------------------------------------------------------------
* 1. Builder → Settings → API & Code → JavaScript.
* 2. Paste this whole file in. It doesn't depend on anything except jQuery,
* which Knack already provides.
* 3. Save, then open your live app.
*
* To test: log in as a user WITHOUT access to a restricted page and confirm
* the button to it has gone. Then log in as a user WITH access and confirm
* it's still there.
*
* Console helpers (open the browser console on your live app):
* KnackLinkAccess.refresh() — rebuild and re-check every link
* KnackLinkAccess.explain(href) — show why a given link is shown/hidden
*
* --------------------------------------------------------------------------
* CAVEATS
* --------------------------------------------------------------------------
* - This relies on Knack's browser-side objects (Knack.scenes,
* Knack.objects, Knack.getUserRoles). These are not a documented public
* API, so a future Knack release could change them. If that happens the
* script fails safe: it can't read the page structure, so it hides
* nothing and your app behaves exactly as it did before.
* - Buttons that navigate using your own custom JavaScript (rather than a
* normal link) aren't checked. Make them plain links, or apply the same
* role check in your own code.
* - Links that open records in a modal pop-up are checked the same way,
* because they still point at a page slug.
* ========================================================================== */
(function () {
'use strict';
// Guard against the script being pasted (or loaded) twice.
if (window.KnackLinkAccess) return;
/* ------------------------------------------------------------------------
* SETTINGS — the only part you might want to change.
* ---------------------------------------------------------------------- */
var SETTINGS = {
// 'menus' (default, fast) or 'all' (every link, heavier on busy pages).
// See "NEW IN VERSION 2: SCOPE" above.
scope: 'menus',
// Which links count as "menu buttons" in 'menus' scope: Knack menu
// views, the top navigation, and dropdown menus. Add your own
// selectors here if you have other containers you want checked.
menuSelector: '.kn-menu a[href*="#"], .knHeader a[href*="#"], .kn-dropdown-menu a[href*="#"]',
// CSS class added to hidden elements. Change it if it clashes with
// anything in your own CSS.
hideClass: 'kla-no-access',
// In menu views, hide the whole menu button (true) or just the link
// inside it (false). True gives a cleaner result in most themes.
hideWholeMenuButton: true,
// How long to wait (ms) after a render before checking. Batches
// several renders that happen close together into one check.
debounceMs: 120,
// Any link with this attribute is never hidden, e.g.
// <a href="#some-page" data-kla-ignore>...</a>
ignoreAttribute: 'data-kla-ignore',
// true = log each check's duration and link count to the console,
// plus each hide decision. Leave false in production.
debug: false
};
/* ------------------------------------------------------------------------
* INTERNAL STATE
* ---------------------------------------------------------------------- */
// Lookup of page slug -> page definition, built from Knack.scenes.
var sceneBySlug = null;
// Cache of page slug -> list of role requirements on the path to it.
// Each requirement is an array of allowed profile keys; the user must
// match at least one key in EVERY requirement.
var requirementCache = {};
// A fingerprint of the user's current roles. If it changes (someone logs
// in or out without a full page reload) every link is re-checked.
var lastRolesFingerprint = null;
// Each link we've checked is stamped with its href in this attribute, so
// unchanged links aren't re-evaluated on every check.
var CHECKED_ATTR = 'data-kla-checked-href';
// Record IDs in Knack URLs are 24-character hex strings.
var RECORD_ID_PATTERN = /^[0-9a-f]{24}$/i;
var pendingScan = null;
/* ------------------------------------------------------------------------
* SMALL HELPERS
* ---------------------------------------------------------------------- */
function log() {
if (!SETTINGS.debug || !window.console) return;
var args = Array.prototype.slice.call(arguments);
args.unshift('[KnackLinkAccess]');
console.log.apply(console, args);
}
// Add one tiny CSS rule so hidden elements stay hidden even if Knack or
// a theme sets display on them later.
function injectCss() {
if (document.getElementById('kla-styles')) return;
var style = document.createElement('style');
style.id = 'kla-styles';
style.textContent = '.' + SETTINGS.hideClass + '{display:none !important;}';
document.head.appendChild(style);
}
/* ------------------------------------------------------------------------
* STEP A — READ THE APP'S PAGE STRUCTURE
* ------------------------------------------------------------------------
* Knack.scenes is a Backbone collection holding every page in the app.
* Each page's attributes include:
* slug — the URL segment for the page
* parent — the slug of the page above it (null at the top)
* views — the views on the page, including any login view
* ---------------------------------------------------------------------- */
function buildPageIndex() {
if (sceneBySlug) return true;
if (!window.Knack || !Knack.scenes || !Knack.scenes.models) {
// Knack hasn't finished loading yet (or a future version stores this
// elsewhere). Do nothing for now — we'll try again on the next render.
return false;
}
var index = {};
Knack.scenes.models.forEach(function (model) {
var page = model && model.attributes;
if (page && page.slug) index[page.slug] = page;
});
sceneBySlug = index;
requirementCache = {};
log('Indexed', Object.keys(index).length, 'pages');
return true;
}
/* ------------------------------------------------------------------------
* STEP B — WORK OUT WHAT A PAGE REQUIRES
* ------------------------------------------------------------------------
* Walk from the target page up to the top of the app. Every login view on
* the way that has role limiting switched on adds one requirement: its
* list of allowed roles (profile keys such as "profile_12").
* ---------------------------------------------------------------------- */
function requirementsForPage(slug) {
if (Object.prototype.hasOwnProperty.call(requirementCache, slug)) {
return requirementCache[slug];
}
var requirements = [];
var page = sceneBySlug[slug];
var safety = 0; // protects against an unexpected loop in the parent chain
while (page && safety++ < 25) {
(page.views || []).forEach(function (view) {
var isRestrictedLogin =
view &&
view.type === 'login' &&
view.limit_profile_access === true &&
Array.isArray(view.allowed_profiles) &&
view.allowed_profiles.length > 0;
if (isRestrictedLogin) {
requirements.push(view.allowed_profiles.slice());
}
});
page = page.parent ? sceneBySlug[page.parent] : null;
}
requirementCache[slug] = requirements;
return requirements;
}
/* ------------------------------------------------------------------------
* STEP C — WORK OUT WHAT ROLES THE USER HAS
* ------------------------------------------------------------------------
* Knack.getUserRoles() returns the user's roles as OBJECT keys
* (e.g. "object_12"), but login views store allowed roles as PROFILE keys
* (e.g. "profile_12"). Knack.objects tells us which object belongs to
* which profile, so we convert. If that lookup isn't available, we fall
* back to swapping the prefix, which matches how Knack numbers them.
* ---------------------------------------------------------------------- */
function currentUserProfiles() {
var roles = [];
try {
roles = (Knack.getUserRoles && Knack.getUserRoles()) || [];
} catch (e) {
roles = []; // not logged in, or Knack not ready
}
if (typeof roles === 'string') roles = [roles];
var objectToProfile = {};
try {
((Knack.objects && Knack.objects.models) || []).forEach(function (model) {
var obj = model && model.attributes;
if (obj && obj.key && obj.profile_key) {
objectToProfile[obj.key] = obj.profile_key;
}
});
} catch (e) {
// ignore — the prefix swap below covers this
}
return roles.map(function (role) {
role = String(role);
return objectToProfile[role] || role.replace(/^object_/, 'profile_');
});
}
// True if the user's roles satisfy every requirement for the page.
function userCanOpen(slug, userProfiles) {
var requirements = requirementsForPage(slug);
for (var i = 0; i < requirements.length; i++) {
var allowedRoles = requirements[i];
var matched = allowedRoles.some(function (profileKey) {
return userProfiles.indexOf(profileKey) !== -1;
});
if (!matched) return false; // failed one restricted login on the path
}
return true; // no restrictions, or every one satisfied
}
/* ------------------------------------------------------------------------
* STEP D — FIND WHICH PAGE A LINK POINTS AT
* ------------------------------------------------------------------------
* Knack page URLs look like:
* #parent-page/child-page/{recordId}/grandchild-page/{recordId}
* We drop the record IDs and pick the DEEPEST segment that is a known
* page slug — that's the page the link opens.
* Returns null for links that don't point at a page in this app.
* ---------------------------------------------------------------------- */
function targetPageSlug(href) {
if (!href) return null;
var hashAt = href.indexOf('#');
if (hashAt === -1) return null; // not an in-app link
var path = href.slice(hashAt + 1).split('?')[0];
if (!path) return null;
var segments = path.split('/').filter(function (part) {
return part && !RECORD_ID_PATTERN.test(part);
});
for (var i = segments.length - 1; i >= 0; i--) {
if (sceneBySlug[segments[i]]) return segments[i];
}
return null;
}
// Decide which element to hide: the whole menu button inside a menu view,
// otherwise just the link itself.
function elementToHide(anchor) {
if (!SETTINGS.hideWholeMenuButton) return anchor;
var menu = anchor.closest('.kn-menu');
if (menu) {
var button = anchor.closest('li');
if (button && menu.contains(button)) return button;
}
return anchor;
}
/* ------------------------------------------------------------------------
* STEP E — CHECK THE LINKS IN SCOPE
* ---------------------------------------------------------------------- */
function scan(forceRecheck) {
if (!buildPageIndex()) return;
var startedAt = SETTINGS.debug ? performance.now() : 0;
var checkedCount = 0;
var userProfiles = currentUserProfiles();
// If the user's roles changed since the last check, re-check everything.
var fingerprint = userProfiles.slice().sort().join(',');
if (fingerprint !== lastRolesFingerprint) {
lastRolesFingerprint = fingerprint;
forceRecheck = true;
log('User roles:', userProfiles);
}
var selector = SETTINGS.scope === 'all' ? 'a[href*="#"]' : SETTINGS.menuSelector;
var links = document.querySelectorAll(selector);
for (var i = 0; i < links.length; i++) {
var link = links[i];
var href = link.getAttribute('href');
// Skip links we've already checked, unless something changed.
if (!forceRecheck && link.getAttribute(CHECKED_ATTR) === href) continue;
link.setAttribute(CHECKED_ATTR, href);
checkedCount++;
var target = elementToHide(link);
// Respect the opt-out attribute.
if (link.hasAttribute(SETTINGS.ignoreAttribute)) {
target.classList.remove(SETTINGS.hideClass);
continue;
}
var slug = targetPageSlug(href);
if (!slug || userCanOpen(slug, userProfiles)) {
target.classList.remove(SETTINGS.hideClass);
} else {
target.classList.add(SETTINGS.hideClass);
log('Hid link to', slug, href);
}
}
log('Check complete — scope ' + SETTINGS.scope + ': ' + links.length + ' links found, ' +
checkedCount + ' checked, ' +
(SETTINGS.debug ? (performance.now() - startedAt).toFixed(1) + 'ms' : ''));
}
// Batch several renders that happen close together into a single check.
function scheduleScan() {
if (pendingScan) return;
pendingScan = setTimeout(function () {
pendingScan = null;
scan(false);
}, SETTINGS.debounceMs);
}
/* ------------------------------------------------------------------------
* STEP F — WHEN TO CHECK
* ------------------------------------------------------------------------
* Both scopes: Knack's own page and view render events. Menu views and
* the top navigation are always drawn through these, so this is all the
* 'menus' scope needs.
*
* 'all' scope only: also grid re-renders (paging, sorting, filtering), and
* a page watcher (MutationObserver) to catch links added by anything
* else — other scripts, libraries, your own custom code. This is the part
* that can cost time on busy pages, which is why 'menus' leaves it off.
* ---------------------------------------------------------------------- */
injectCss();
$(document).on('knack-scene-render.any knack-view-render.any', scheduleScan);
if (SETTINGS.scope === 'all') {
$(document).on('knack-records-render.any', scheduleScan);
var startObserver = function () {
var observer = new MutationObserver(function (mutations) {
for (var i = 0; i < mutations.length; i++) {
if (mutations[i].addedNodes && mutations[i].addedNodes.length) {
scheduleScan();
return;
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
};
if (document.body) {
startObserver();
} else {
document.addEventListener('DOMContentLoaded', startObserver);
}
}
/* ------------------------------------------------------------------------
* PUBLIC HELPERS (for the browser console)
* ---------------------------------------------------------------------- */
window.KnackLinkAccess = {
// Throw away cached data and re-check every link in scope.
refresh: function () {
sceneBySlug = null;
requirementCache = {};
lastRolesFingerprint = null;
scan(true);
},
// Explain the decision for one link, e.g.
// KnackLinkAccess.explain('#customers/view-customer/123.../edit-customer/123...')
explain: function (href) {
if (!buildPageIndex()) {
return 'Knack page structure not available yet.';
}
var slug = targetPageSlug(href);
if (!slug) {
return 'Not a link to a page in this app — left alone.';
}
var userProfiles = currentUserProfiles();
return {
targetPage: slug,
userRoles: userProfiles,
requirements: requirementsForPage(slug),
shown: userCanOpen(slug, userProfiles)
};
},
// Read-only view of the current settings. Scope and selectors take
// effect at page load, so change them in the SETTINGS block above
// rather than from the console.
settings: SETTINGS
};
})();