Skip to content

Commit 80d22a4

Browse files
committed
Add login page session modes and protected page access - PR_26157_016-login-page-session-mode-lockdown
1 parent 81ec4c4 commit 80d22a4

35 files changed

Lines changed: 879 additions & 105 deletions

admin/db-viewer.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -538,4 +538,6 @@ class AdminDbViewer {
538538
}
539539
}
540540

541-
new AdminDbViewer().start();
541+
if (!window.GameFoundrySessionGuard?.blocked) {
542+
new AdminDbViewer().start();
543+
}

admin/notes.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -538,4 +538,6 @@ class AdminNotesViewer {
538538
}
539539
}
540540

541-
new AdminNotesViewer().start();
541+
if (!window.GameFoundrySessionGuard?.blocked) {
542+
new AdminNotesViewer().start();
543+
}

admin/tools-progress.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,4 +138,6 @@ function renderToolsProgress() {
138138
}
139139
}
140140

141-
renderToolsProgress();
141+
if (!window.GameFoundrySessionGuard?.blocked) {
142+
renderToolsProgress();
143+
}

assets/theme-v2/js/gamefoundry-partials.js

Lines changed: 146 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
publish: "community/publish.html",
6262
support: "docs/support.html",
6363
reference: "docs/reference.html",
64+
login: "login.html",
6465
contact: "company/contact.html",
6566
vision: "company/vision.html",
6667
mission: "company/mission.html",
@@ -108,6 +109,7 @@
108109
]);
109110
const mockDbStorageKey = "gamefoundry.mockDb.v1";
110111
const mockDbSessionStorageKey = "gamefoundry.mockDb.sessionUser.v1";
112+
const mockDbSessionModeStorageKey = "gamefoundry.mockDb.sessionMode.v1";
111113

112114
const currentScript = document.currentScript || document.querySelector("script[src*='gamefoundry-partials.js']");
113115
const assetRoot = currentScript ? new URL("../", currentScript.src) : null;
@@ -193,6 +195,14 @@
193195
}
194196
}
195197

198+
function selectedSessionModeId() {
199+
try {
200+
return window.localStorage.getItem(mockDbSessionModeStorageKey) || "local";
201+
} catch {
202+
return "local";
203+
}
204+
}
205+
196206
function rolesForUser(state, userKey, fallbackRoleSlugs) {
197207
const rows = state?.tables?.user_roles || [];
198208
const roles = new Map((state?.tables?.roles || []).map(function (role) {
@@ -210,31 +220,52 @@
210220
}
211221

212222
function localDevLoginState() {
223+
if (selectedSessionModeId() === "dev") {
224+
return {
225+
authenticated: false,
226+
displayName: "Login",
227+
mode: "dev",
228+
roleSlugs: []
229+
};
230+
}
231+
213232
const session = localSessionUsers[selectedLocalSessionId()];
214233
if (!session) {
215234
return {
216235
authenticated: false,
217236
displayName: "Login",
237+
mode: "local",
218238
roleSlugs: []
219239
};
220240
}
221241

222242
const state = readMockDbState();
243+
if (!state) {
244+
return {
245+
authenticated: false,
246+
displayName: "Login",
247+
mode: "local",
248+
roleSlugs: []
249+
};
250+
}
251+
223252
const user = (state?.tables?.users || []).find(function (record) {
224253
return record.key === session.userKey && record.isActive !== false;
225254
});
226-
if (state && !user) {
255+
if (!user) {
227256
return {
228257
authenticated: false,
229258
displayName: "Login",
259+
mode: "local",
230260
roleSlugs: []
231261
};
232262
}
233263

234264
return {
235265
authenticated: true,
236266
displayName: user?.displayName || session.displayName,
237-
roleSlugs: rolesForUser(state, session.userKey, session.roleSlugs)
267+
mode: "local",
268+
roleSlugs: rolesForUser(state, session.userKey, [])
238269
};
239270
}
240271

@@ -272,6 +303,7 @@
272303
if (accountLink) {
273304
accountLink.textContent = canUseAccount ? loginState.displayName + " \u25BE" : "Login";
274305
accountLink.setAttribute("aria-label", canUseAccount ? "Account menu for " + loginState.displayName : "Login");
306+
accountLink.setAttribute("href", canUseAccount ? routeHref("account") : routeHref("login"));
275307
}
276308
if (accountMenu) {
277309
accountMenu.hidden = !canUseAccount;
@@ -283,6 +315,105 @@
283315
setMenuVisible(adminItem, canUseAdmin);
284316
}
285317

318+
function protectedPageRequirement(pagePath) {
319+
if (pagePath.indexOf("admin/") === 0) {
320+
return {
321+
role: "admin",
322+
title: "Admin role required",
323+
message: "Log in as Admin to open this Admin page."
324+
};
325+
}
326+
if (pagePath.indexOf("account/") === 0) {
327+
return {
328+
role: "user",
329+
title: "Login required",
330+
message: "Log in as a local user to open Account pages."
331+
};
332+
}
333+
return null;
334+
}
335+
336+
function canUseProtectedPage(requirement, loginState) {
337+
if (!requirement) {
338+
return true;
339+
}
340+
if (requirement.role === "admin") {
341+
return loginState.authenticated && loginState.roleSlugs.includes("admin");
342+
}
343+
if (requirement.role === "user") {
344+
return loginState.authenticated && loginState.roleSlugs.includes("user");
345+
}
346+
return false;
347+
}
348+
349+
function createAccessBlockedMain(requirement, pagePath, loginState) {
350+
const main = document.createElement("main");
351+
main.dataset.sessionAccessBlocked = requirement.role;
352+
353+
const titleSection = document.createElement("section");
354+
titleSection.className = "page-title";
355+
const titleContainer = document.createElement("div");
356+
titleContainer.className = "container";
357+
const kicker = document.createElement("div");
358+
kicker.className = "kicker";
359+
kicker.textContent = "Access";
360+
const title = document.createElement("h1");
361+
title.textContent = requirement.title;
362+
const lede = document.createElement("p");
363+
lede.className = "lede";
364+
lede.textContent = requirement.message;
365+
titleContainer.append(kicker, title, lede);
366+
titleSection.append(titleContainer);
367+
368+
const bodySection = document.createElement("section");
369+
bodySection.className = "section";
370+
const bodyContainer = document.createElement("div");
371+
bodyContainer.className = "container";
372+
const card = document.createElement("div");
373+
card.className = "card";
374+
const body = document.createElement("div");
375+
body.className = "card-body content-stack";
376+
const status = document.createElement("p");
377+
status.className = "status";
378+
status.setAttribute("role", "status");
379+
status.dataset.sessionAccessStatus = "";
380+
status.textContent = `Blocked ${pagePath}. Current session: ${loginState.displayName}.`;
381+
const link = document.createElement("a");
382+
link.className = "btn primary";
383+
link.href = routeHref("login") + "?returnTo=" + encodeURIComponent(pagePath);
384+
link.textContent = "Open Login";
385+
body.append(status, link);
386+
card.append(body);
387+
bodyContainer.append(card);
388+
bodySection.append(bodyContainer);
389+
390+
main.append(titleSection, bodySection);
391+
return main;
392+
}
393+
394+
function enforcePageProtection() {
395+
const pagePath = currentPagePath() || "index.html";
396+
const requirement = protectedPageRequirement(pagePath);
397+
const loginState = localDevLoginState();
398+
const allowed = canUseProtectedPage(requirement, loginState);
399+
window.GameFoundrySessionGuard = {
400+
blocked: !allowed,
401+
mode: loginState.mode,
402+
pagePath,
403+
requirement: requirement?.role || ""
404+
};
405+
if (!requirement || allowed) {
406+
return false;
407+
}
408+
409+
const existingMain = document.querySelector("main");
410+
if (existingMain) {
411+
existingMain.replaceWith(createAccessBlockedMain(requirement, pagePath, loginState));
412+
}
413+
document.title = `${requirement.title} - GameFoundryStudio`;
414+
return true;
415+
}
416+
286417
function markActiveNavigation(root) {
287418
const pagePath = currentPagePath() || "index.html";
288419
root.querySelectorAll("[data-nav-link]").forEach(function (link) {
@@ -351,14 +482,25 @@
351482
}
352483

353484
function refreshHeaderLoginState() {
485+
const header = document.querySelector("header.site-header");
486+
if (header) {
487+
enforcePageProtection();
488+
applyLocalDevLoginState(header);
489+
markActiveNavigation(header);
490+
}
491+
}
492+
493+
function refreshHeaderOnly() {
354494
const header = document.querySelector("header.site-header");
355495
if (header) {
356496
applyLocalDevLoginState(header);
357497
markActiveNavigation(header);
358498
}
359499
}
360500

501+
enforcePageProtection();
361502
document.addEventListener("DOMContentLoaded", function () {
503+
enforcePageProtection();
362504
const slots = Array.from(document.querySelectorAll("[data-partial]"));
363505
const tasks = slots.length ? slots.map(loadPartial) : [
364506
replaceExisting("header-nav", "header.site-header"),
@@ -369,5 +511,6 @@
369511
});
370512
});
371513
window.addEventListener("gamefoundry:mock-db-session-user-changed", refreshHeaderLoginState);
372-
window.addEventListener("gamefoundry:mock-db-changed", refreshHeaderLoginState);
514+
window.addEventListener("gamefoundry:mock-db-session-mode-changed", refreshHeaderLoginState);
515+
window.addEventListener("gamefoundry:mock-db-changed", refreshHeaderOnly);
373516
}());
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import {
2+
getMockDbSessionMode,
3+
getMockDbSessionModes,
4+
getMockDbSessionUser,
5+
getMockDbSessionUsers,
6+
getStandaloneMockDbTables,
7+
setMockDbSessionMode,
8+
setMockDbSessionUser,
9+
} from "../../../src/engine/persistence/mock-db-store.js";
10+
11+
const modeButtons = Array.from(document.querySelectorAll("[data-login-mode]"));
12+
const modeTitle = document.querySelector("[data-login-mode-title]");
13+
const modeDescription = document.querySelector("[data-login-mode-description]");
14+
const modeStatus = document.querySelector("[data-login-mode-status]");
15+
const userControls = document.querySelector("[data-login-user-controls]");
16+
const userStatus = document.querySelector("[data-login-user-status]");
17+
const continueLink = document.querySelector("[data-login-continue]");
18+
19+
function currentReturnTo() {
20+
const params = new URLSearchParams(window.location.search);
21+
const value = params.get("returnTo") || "";
22+
if (!value || value.startsWith("/") || value.includes("://") || value.includes("..")) {
23+
return "toolbox/index.html";
24+
}
25+
return value;
26+
}
27+
28+
function modeById(modeId) {
29+
return getMockDbSessionModes().find((mode) => mode.id === modeId) || getMockDbSessionMode();
30+
}
31+
32+
function updateContinueLink() {
33+
if (continueLink) {
34+
continueLink.href = currentReturnTo();
35+
}
36+
}
37+
38+
function setSelectedButton(button, selected) {
39+
button.classList.toggle("primary", selected);
40+
button.setAttribute("aria-pressed", String(selected));
41+
if (selected) {
42+
button.setAttribute("aria-current", "true");
43+
} else {
44+
button.removeAttribute("aria-current");
45+
}
46+
}
47+
48+
function renderModeButtons(mode) {
49+
modeButtons.forEach((button) => {
50+
setSelectedButton(button, button.dataset.loginMode === mode.id);
51+
});
52+
}
53+
54+
function renderUserButtons(mode) {
55+
if (!userControls) {
56+
return;
57+
}
58+
59+
userControls.replaceChildren();
60+
if (mode.id === "dev") {
61+
userControls.hidden = true;
62+
if (userStatus) {
63+
userStatus.textContent = "DEV mode uses read-only/demo JSON access. No users are selectable.";
64+
}
65+
return;
66+
}
67+
68+
getStandaloneMockDbTables();
69+
userControls.hidden = false;
70+
const sessionUser = getMockDbSessionUser();
71+
getMockDbSessionUsers().forEach((user) => {
72+
const button = document.createElement("button");
73+
button.className = "btn btn--compact";
74+
button.type = "button";
75+
button.textContent = user.label;
76+
button.dataset.loginUser = user.id;
77+
setSelectedButton(button, button.dataset.loginUser === sessionUser.id);
78+
userControls.append(button);
79+
});
80+
if (userStatus) {
81+
userStatus.textContent = sessionUser.userKey
82+
? `Selected local user: ${sessionUser.label}.`
83+
: "Guest is unauthenticated and is not stored in the users table.";
84+
}
85+
}
86+
87+
function render() {
88+
const mode = getMockDbSessionMode();
89+
renderModeButtons(mode);
90+
if (modeTitle) {
91+
modeTitle.textContent = mode.label;
92+
}
93+
if (modeDescription) {
94+
modeDescription.textContent = mode.description;
95+
}
96+
if (modeStatus) {
97+
modeStatus.textContent = `${mode.label} mode selected.`;
98+
}
99+
renderUserButtons(mode);
100+
updateContinueLink();
101+
}
102+
103+
modeButtons.forEach((button) => {
104+
button.addEventListener("click", () => {
105+
const mode = setMockDbSessionMode(button.dataset.loginMode || "local");
106+
if (mode.id === "local") {
107+
getStandaloneMockDbTables();
108+
}
109+
render();
110+
});
111+
});
112+
113+
userControls?.addEventListener("click", (event) => {
114+
const button = event.target.closest("[data-login-user]");
115+
if (!button) {
116+
return;
117+
}
118+
setMockDbSessionMode("local");
119+
getStandaloneMockDbTables();
120+
setMockDbSessionUser(button.dataset.loginUser || "guest");
121+
render();
122+
});
123+
124+
render();

docs_build/dev/reports/coverage_changed_js_guardrail.txt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@ Missing changed runtime JS files are WARN, not FAIL.
66
Source: Playwright/Chromium built-in V8 coverage from the active Playwright run.
77

88
Changed runtime JS files considered:
9-
(97%) toolbox/project-journey/project-journey-mock-repository.js - executed lines 1043/1043; executed functions 93/96
10-
(98%) toolbox/project-journey/project-journey.js - executed lines 1000/1000; executed functions 106/108
9+
(93%) src/engine/persistence/mock-db-store.js - executed lines 659/659; executed functions 65/70
1110

1211
Guardrail warnings:
1312
(100%) none - no changed runtime JS coverage warnings

0 commit comments

Comments
 (0)