Skip to content

Commit 8bdc9fe

Browse files
JohnONolanErisDS
andauthored
✨ Added frontend admin toolbar (#28058)
Staff members viewing the frontend of their Ghost site now see a floating toolbar with quick links back to the editor, settings, and analytics for the current page. The toolbar is staff-only and invisible to normal visitors — public pages remain fully cacheable. **Activation model** Admin "view site" links append `?admin=1`. Frontend middleware creates a short-lived HMAC-signed marker cookie on the site domain and redirects to the clean URL. The cookie contains no session data or PII — it only signals that staff tooling should load. On subsequent requests the middleware validates the marker and sets a response local that `ghost_head` checks before emitting the script tag. The browser bundle independently verifies the real staff session through the existing `/ghost/auth-frame/` bridge before rendering anything. This two-layer approach means the server never embeds staff-specific data in HTML, so CDN and theme caching are unaffected. **Suppression inside Admin** Admin's "view site" iframe passes `?admin=1&admin_toolbar=0`. The middleware seeds the marker cookie (so the toolbar works on normal frontend visits) but suppresses injection for that response. Suppression triggers on the explicit query parameter and on `Sec-Fetch-Dest: iframe`. Theme and announcement-bar previews, which use `fetch()` rather than iframes, append `?admin_toolbar=0` to their request URLs. The toolbar script also checks the parameter client-side as a safety net. **Package boundary** The toolbar lives in `apps/admin-toolbar` as a self-contained package with its own source, Vite build, tests, and UMD artifact. It uses Preact (~3 KB) instead of React (~40 KB) since it is a lightweight public-facing widget that only needs basic rendering — a rationale that could apply to other small public scripts where bundle size matters more than ecosystem compatibility. It renders inside Shadow DOM so theme CSS cannot affect it. In production the script is served from jsDelivr via the `adminToolbar` config in `defaults.json`, following the same CDN pattern as portal, comments-ui, search, and announcement-bar. In development the Docker setup proxies through Caddy to a local Vite preview server. --- Co-authored-by: Hannah Wolfe <github.erisds@gmail.com>
1 parent f9917cb commit 8bdc9fe

42 files changed

Lines changed: 6199 additions & 3649 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/admin-toolbar/LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2013-2026 Ghost Foundation
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

apps/admin-toolbar/README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Admin Toolbar
2+
3+
Frontend staff toolbar for Ghost sites. Uses Preact (~3KB) instead of React
4+
(~40KB) since this is a lightweight public-facing widget that only needs basic
5+
rendering and hooks — the same rationale applies to any future small public
6+
scripts where bundle size matters more than ecosystem compatibility.
7+
8+
## Development
9+
10+
```bash
11+
pnpm build # one-off build
12+
pnpm dev # build + preview with watch (started automatically by pnpm dev from root)
13+
pnpm test # build + run tests against UMD bundle
14+
```
15+
16+
## How it's served
17+
18+
In production, the script is loaded from jsDelivr via the `adminToolbar` config
19+
in `defaults.json`, following the same CDN pattern as portal, comments-ui, and
20+
the other public apps. In development, the Docker Dockerfile overrides the URL
21+
to proxy through Caddy to the local vite preview server on port 4176.
22+
23+
# Copyright & License
24+
25+
Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE).

apps/admin-toolbar/package.json

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
{
2+
"name": "@tryghost/admin-toolbar",
3+
"version": "0.1.0",
4+
"license": "MIT",
5+
"repository": "https://github.com/TryGhost/Ghost",
6+
"author": "Ghost Foundation",
7+
"files": [
8+
"src/",
9+
"umd/",
10+
"LICENSE",
11+
"README.md"
12+
],
13+
"publishConfig": {
14+
"access": "public",
15+
"registry": "https://registry.npmjs.org/"
16+
},
17+
"dependencies": {
18+
"preact": "catalog:"
19+
},
20+
"scripts": {
21+
"build": "pnpm exec vite build",
22+
"build:watch": "pnpm exec vite build --watch",
23+
"dev": "concurrently --kill-others --names preview,build \"pnpm exec vite preview -l silent\" \"pnpm build:watch\"",
24+
"lint": "pnpm exec eslint src --ext .js --cache",
25+
"test": "pnpm run build && pnpm exec mocha --exit --trace-warnings --timeout=60000 \"test/**/*.test.js\""
26+
},
27+
"devDependencies": {
28+
"concurrently": "catalog:",
29+
"eslint": "catalog:",
30+
"jsdom": "catalog:",
31+
"mocha": "catalog:",
32+
"vite": "catalog:"
33+
},
34+
"eslintConfig": {
35+
"ignorePatterns": [
36+
"umd/**/*.js"
37+
],
38+
"env": {
39+
"browser": true
40+
},
41+
"parserOptions": {
42+
"sourceType": "module",
43+
"ecmaVersion": 2022
44+
},
45+
"extends": [
46+
"plugin:ghost/browser"
47+
],
48+
"plugins": [
49+
"ghost"
50+
],
51+
"overrides": [
52+
{
53+
"files": [
54+
"test/**/*.js"
55+
],
56+
"env": {
57+
"mocha": true,
58+
"node": true
59+
},
60+
"parserOptions": {
61+
"sourceType": "script"
62+
}
63+
}
64+
],
65+
"rules": {
66+
"ghost/filenames/match-regex": [
67+
"error",
68+
"^[a-z0-9.-]+$",
69+
false
70+
]
71+
}
72+
}
73+
}

apps/admin-toolbar/src/actions.js

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import {adminHref, commentsHref} from './links';
2+
3+
export function getToolbarActions(config) {
4+
if (config.pageContext === 'home') {
5+
return getHomepageActions(config);
6+
}
7+
8+
if (config.resourceType === 'post' && config.resourceId) {
9+
return getPostActions(config);
10+
}
11+
12+
if (config.resourceType === 'tag' && config.resourceSlug) {
13+
return [{
14+
href: adminHref(config.adminUrl, `tags/${encodeURIComponent(config.resourceSlug)}`),
15+
icon: 'edit',
16+
label: 'Edit'
17+
}];
18+
}
19+
20+
if (config.resourceType && config.resourceId) {
21+
return [{
22+
href: adminHref(config.adminUrl, `editor/${config.resourceType}/${config.resourceId}`),
23+
icon: 'edit',
24+
label: 'Edit'
25+
}];
26+
}
27+
28+
return [];
29+
}
30+
31+
function getHomepageActions(config) {
32+
const actions = [];
33+
34+
if (config.siteAnalyticsEnabled) {
35+
actions.push({
36+
href: adminHref(config.adminUrl, 'analytics'),
37+
icon: 'siteAnalytics',
38+
label: 'Analytics'
39+
});
40+
}
41+
42+
if (config.activityPubEnabled) {
43+
actions.push({
44+
href: adminHref(config.adminUrl, 'activitypub'),
45+
icon: 'network',
46+
label: 'Network'
47+
});
48+
}
49+
50+
actions.push({
51+
href: adminHref(config.adminUrl, 'posts/'),
52+
icon: 'posts',
53+
label: 'Posts'
54+
});
55+
56+
if (config.membersEnabled) {
57+
actions.push({
58+
href: adminHref(config.adminUrl, 'members'),
59+
icon: 'members',
60+
label: 'Members'
61+
});
62+
}
63+
64+
actions.push({
65+
href: adminHref(config.adminUrl, 'settings'),
66+
icon: 'settings',
67+
label: 'Settings'
68+
});
69+
70+
return actions;
71+
}
72+
73+
function getPostActions(config) {
74+
const actions = [
75+
{
76+
href: adminHref(config.adminUrl, `posts/analytics/${config.resourceId}`),
77+
icon: 'analytics',
78+
label: 'Analytics'
79+
},
80+
{
81+
href: adminHref(config.adminUrl, `editor/${config.resourceType}/${config.resourceId}`),
82+
icon: 'edit',
83+
label: 'Edit'
84+
}
85+
];
86+
87+
if (config.commentsEnabled) {
88+
actions.push({
89+
href: commentsHref(config.adminUrl, config.resourceId),
90+
icon: 'comments',
91+
label: 'Comments'
92+
});
93+
}
94+
95+
return actions;
96+
}

apps/admin-toolbar/src/auth.js

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import {AUTH_TIMEOUT} from './constants';
2+
3+
export function createAuthFrame(adminUrl) {
4+
const frame = document.createElement('iframe');
5+
frame.dataset.frame = 'admin-auth';
6+
frame.src = `${adminUrl}auth-frame/`;
7+
frame.title = 'Ghost admin authentication';
8+
frame.tabIndex = -1;
9+
frame.style.cssText = 'display:none;width:0;height:0;border:0;';
10+
document.body.appendChild(frame);
11+
return frame;
12+
}
13+
14+
export function createAdminApi(adminUrl, frame) {
15+
let uid = 0;
16+
const handlers = {};
17+
const adminOrigin = new URL(adminUrl).origin;
18+
19+
window.addEventListener('message', function (event) {
20+
if (event.origin !== adminOrigin) {
21+
return;
22+
}
23+
24+
let data;
25+
try {
26+
data = JSON.parse(event.data);
27+
} catch {
28+
return;
29+
}
30+
31+
const handler = handlers[data.uid];
32+
if (!handler) {
33+
return;
34+
}
35+
36+
delete handlers[data.uid];
37+
handler(data.error, data.result);
38+
});
39+
40+
function call(action, args) {
41+
return new Promise((resolve, reject) => {
42+
uid += 1;
43+
const currentUid = uid;
44+
const timeout = window.setTimeout(() => {
45+
delete handlers[currentUid];
46+
reject(new Error('Admin authentication timed out'));
47+
}, AUTH_TIMEOUT);
48+
49+
handlers[currentUid] = (error, result) => {
50+
window.clearTimeout(timeout);
51+
if (error) {
52+
reject(new Error(error));
53+
} else {
54+
resolve(result);
55+
}
56+
};
57+
58+
frame.contentWindow?.postMessage(JSON.stringify({
59+
uid: currentUid,
60+
action,
61+
...args
62+
}), adminOrigin);
63+
});
64+
}
65+
66+
return {
67+
getUser: async () => {
68+
const result = await call('getUser');
69+
return result?.users?.[0] || null;
70+
}
71+
};
72+
}
73+
74+
export function canShowToolbar(user) {
75+
const allowedRoles = new Set(['owner', 'administrator', 'editor']);
76+
return (user?.roles || []).some(role => allowedRoles.has((role?.name || '').toLowerCase()));
77+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import {BODY_PADDING_VAR} from './constants';
2+
3+
let previousBodyPaddingBottom = null;
4+
5+
export function applyBodyOffset(height) {
6+
if (previousBodyPaddingBottom === null) {
7+
previousBodyPaddingBottom = document.body.style.paddingBottom || '';
8+
}
9+
10+
const offset = `${height + 24}px`;
11+
document.documentElement.style.setProperty(BODY_PADDING_VAR, offset);
12+
document.body.style.paddingBottom = `calc(var(${BODY_PADDING_VAR}) + env(safe-area-inset-bottom, 0px))`;
13+
}
14+
15+
export function clearBodyOffset() {
16+
document.documentElement.style.removeProperty(BODY_PADDING_VAR);
17+
document.body.style.paddingBottom = previousBodyPaddingBottom || '';
18+
previousBodyPaddingBottom = null;
19+
}

0 commit comments

Comments
 (0)