Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 38 additions & 11 deletions static/js/core/shell.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,22 @@
*/

import { BASE_URL, getConfig } from "./config.js";
import { ensureDefaultPolicy } from "./trusted-types.js";
import { initResponsive } from "./responsive.js";
import { initTheme } from "./theme-engine.js";
import { initAuth } from "../system/auth-integration.js";
import { checkAccess, renderAccessWall } from "../system/access-guard.js";
import { filterAppsByRole, getManifestSync } from "../system/manifest.js";
import {
fetchManifest,
filterAppsByRole,
getManifestSync,
} from "../system/manifest.js";
import { initDropdowns } from "../ui/dropdowns.js";
import { SHELL_CONFIG_DEFAULTS } from "./shell-config.js";

// Must run before any DOM injection sink is used (innerHTML, script.src, etc.)
ensureDefaultPolicy();

window.__componentsJS = true;
let _injected = false;

Expand Down Expand Up @@ -202,12 +210,20 @@ function safeIconClass(value) {
}

function renderAppsGrid(shellRoot, apps) {
const grids = shellRoot.querySelectorAll("[data-app-menu-grid]");
// Backward compatibility:
// - New markup uses: [data-app-menu-grid="desktop"|"mobile"]
// - Older injected markup used: [data-apps-grid] and [data-apps-grid-mobile]
const grids = shellRoot.querySelectorAll(
"[data-app-menu-grid], [data-apps-grid], [data-apps-grid-mobile]",
);
if (!grids.length) return;

const entries = Array.isArray(apps) ? apps : [];
grids.forEach((grid) => {
const isDesktop = grid.getAttribute("data-app-menu-grid") === "desktop";
const gridMode = grid.getAttribute("data-app-menu-grid");
const isDesktop =
gridMode === "desktop" ||
(!gridMode && grid.hasAttribute("data-apps-grid"));
const padding = isDesktop ? "p-3" : "p-2.5";

const fragment = document.createDocumentFragment();
Expand Down Expand Up @@ -243,11 +259,17 @@ function renderAppsGrid(shellRoot, apps) {
});
}

function updateAppsGridForRole(shellRoot, role) {
const fallback = getManifestSync();
const visibleApps = filterAppsByRole(fallback.apps, role);
renderAppsGrid(shellRoot, visibleApps);
return Promise.resolve(visibleApps);
async function updateAppsGridForRole(shellRoot, role) {
// Render cached/fallback immediately (fast paint).
const cached = getManifestSync();
const cachedVisible = filterAppsByRole(cached.apps, role);
renderAppsGrid(shellRoot, cachedVisible);

// Then refresh from the live manifest source of truth.
const fresh = await fetchManifest(role);
const freshVisible = filterAppsByRole(fresh.apps, role);
renderAppsGrid(shellRoot, freshVisible);
return freshVisible;
}

// --- Mobile panel logic ---
Expand Down Expand Up @@ -311,7 +333,14 @@ function syncMobileAuth(shellRoot, authStatus) {
if (authedAvatar) {
authedAvatar.classList.remove("hidden");
const img = authedAvatar.querySelector("img");
if (img) img.src = user.avatar_url || "";
if (img) {
const avatarUrl = user.avatar_url;
if (avatarUrl) {
img.src = avatarUrl;
} else {
img.removeAttribute("src");
}
}
}
if (nameEl) nameEl.textContent = user.name || "User";
if (emailEl) emailEl.textContent = user.email || "";
Expand Down Expand Up @@ -382,8 +411,6 @@ function hydrate(shellRoot) {

applyChromeVisibility(shellRoot, config);

const cachedManifest = getManifestSync();
renderAppsGrid(shellRoot, filterAppsByRole(cachedManifest.apps, "guest"));
updateAppsGridForRole(shellRoot, "guest");

initResponsive();
Expand Down
52 changes: 52 additions & 0 deletions static/js/core/trusted-types.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Trusted Types Bootstrap — Shared across main site and subdomains.
*
* Creates the 'default' Trusted Types policy so that raw string
* assignments to innerHTML, script.src, etc. are automatically
* converted to TrustedHTML / TrustedScriptURL by the browser.
*
* Must run BEFORE any code that touches DOM injection sinks.
*/
export function ensureDefaultPolicy() {
if (window.trustedTypes && window.trustedTypes.createPolicy) {
try {
window.__defaultPolicy = window.trustedTypes.createPolicy("default", {
createScriptURL: function (s) {
return s;
},
createHTML: function (s) {
return s;
},
});
} catch {
/* policy already exists — safe to ignore */
}
}

// Patch appendChild/insertBefore so dynamically-created iframes
// (e.g. Cloudflare challenge, giscus) also get a default policy.
function installFramePolicy(methodName) {
const original = Element.prototype[methodName];
Element.prototype[methodName] = function (...args) {
const result = original.apply(this, args);
const node = args[0];
if (node && node.tagName === "IFRAME") {
try {
const win = node.contentWindow;
if (win && win.trustedTypes && !win.trustedTypes.defaultPolicy) {
win.trustedTypes.createPolicy("default", {
createHTML: (s) => s,
createScript: (s) => s,
createScriptURL: (s) => s,
});
}
} catch {
/* cross-origin — expected */
}
}
return result;
};
}
installFramePolicy("appendChild");
installFramePolicy("insertBefore");
}
Loading