Compare commits

..
Author SHA1 Message Date
Waters ChurchandClaude Opus 5 f72170b4e1 The folding section reads as a heading first
Theme's title was coming out at the normal weight — <summary> does not
inherit an <h1>'s boldness, so sitting it under .admin-h1 got the size,
colour and spacing but not the type. It now matches the other section
headings exactly.

The caret moved to the right of the words. A marker in front pushes the
heading out of line with every other one on the page; behind it, the section
still reads as "Theme" first and the caret only says there is more.

Written as the pattern rather than as Theme's own styling: any section that
folds is a <details class="admin-fold"> headed by a
<summary class="admin-h1 admin-fold-title">, and the whole row is the target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:22:11 -04:00
Waters ChurchandClaude Opus 5 ea1eac1c28 Twelve themes, and the Theme section folds away
Seven more palettes: Midnight (indigo, violet), Moss (forest floor, lime),
Plum (aubergine, purple), Sepia (warm brown with a cool teal — Ember's
arrangement reversed), Blackout (true black for a dark room, where any glow
off the screen carries), Paper (the warm light one, next to Daylight's cool
white) and Iron, which has no colour of its own: its accent is near-white, so
the only hues on screen are a module's own and the red and amber that mean
something.

Each holds the line the first five set. Text on panel lands between 14.8:1
and 19.8:1, muted between 5.8:1 and 7.4:1, and every accent clears its own
theme's danger red and warning amber by more than Ember already does — the
narrowest of the new ones is Moss at 54 degrees of hue, against Ember's 18.

The Theme section now starts folded, wearing the same disclosure triangle as
a module's config groups. A building picks its palette once and then wants
the rest of the page, which was getting further down the screen with every
swatch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:16:47 -04:00
michael 82e6386c73 Merge pull request 'Shell 1.6.0: bars that tuck away, and every edge resizes' (#6) from claude/tile-chrome into main 2026-09-17 12:44:07 -04:00
michael f5eb611872 Merge pull request 'README: the Slack module joins the list of included modules' (#5) from claude/readme-slack-module into main 2026-09-17 12:43:31 -04:00
michael d52cfcc48e Merge pull request 'Slack 1.2.1: a clearer Send tile, and a send target you can see' (#4) from claude/slack-send-target into main 2026-09-17 12:42:48 -04:00
michael 7d46cfbd8f Merge pull request 'ProPresenter 1.1.0: a Now tile, and switches for everything on it' (#3) from claude/propresenter-now into main 2026-09-17 12:41:53 -04:00
michael 7a5b57dc56 Merge pull request 'Admin sits behind a 6-digit PIN' (#1) from claude/admin-pin into main 2026-09-17 12:40:31 -04:00
Waters ChurchandClaude Opus 5 c379025d30 README: the Slack module joins the list
Every shipped module had an entry under "Included modules" except Slack,
which wasn't mentioned anywhere in the README — so the one module that can
send as the campus was also the one nobody could read about.

Its entry now sits with the others, in their shape: what it does, the tiles
it offers in the + picker (one transcript per configured channel plus the
two single send surfaces), its four admin groups and its tile settings —
all taken from modules/slack/module.json and client.js.

The testing paragraph listed the ProdCom, ProPresenter and PCO mocks but
not the Slack one, though CLAUDE.md has documented it all along; it is now
there with the SLACK_API_BASE line it needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 09:10:03 -04:00
Waters ChurchandClaude Opus 5 f0be37796b Slack 1.2.1: a clearer Send tile, and a send target you can see
The line above the message box said "Sending as Apollo Beach → #ops" with a
Lock button beside it. The box's own placeholder already reads "Message
#ops…" and the title bar already has a Lock, so both went. Quick replies
keeps its line: it has no placeholder, so that is the only place its target
is named.

The bigger thing behind it: until a transcript was clicked, a send went to
the first channel in the admin list whether or not that channel was on the
dashboard — so a board showing only #ops would quietly aim at #general. The
target now falls to the first configured channel whose transcript is
actually up, which with a single transcript is that one. Only when the page
shows no transcript at all does it fall back to the first configured
channel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:58:57 -04:00
Waters ChurchandClaude Opus 5 52d700df3d ProPresenter 1.1.0: a Now tile, and switches for everything on it
The + picker now offers the module two ways. "Now / Next" is the card as it
was. "Now" is the live item on its own: the gradient comes off the card and
fills the tile edge to edge, the border, radius and box go, and the playlist
name and live dot float over the colour instead of sitting above it. A wall
of them reads as panels of colour rather than cards on a background.

Every part of the tile is now a switch in a "Show" group in the gear —
playlist, live status, the NOW and NEXT labels, slide count, progress bar,
the next list and its item tags. One or two words each, no descriptions. The
Now entry carries its own six, since the three Next ones have nothing to act
on there.

Two things the tile still insists on. Trouble overrides the switches: the
top row and the live chip come back the moment the feed drops, whatever is
switched off, because a tile that has stopped updating must never look like
one with nothing live. And at the Now tile's shortest sizes the floating top
row would sit on the title, so below the height it needs it goes — the
header dot and the title itself still say what is wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:39:55 -04:00
Waters ChurchandClaude Opus 5 7690ae1fad Merge main into the admin PIN branch
Both sides added to the same two places — the environment-variable list in
server.js and the settings table in the README — because the forge work and
the PIN work each documented a new setting. Everything from both is kept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 18:57:27 -04:00
Waters ChurchandClaude Opus 5 6dabd9d8a1 Admin sits behind a 6-digit PIN
The passcode machinery existed but could only be set by hand in a config
file, so in practice every install left admin open to anyone on the
network. It is now a PIN you set from the admin page itself.

- Six digits, stored as a scrypt hash in this machine's proddash.json —
  never in the clear, since that file gets copied between machines.
- The unlock card takes the PIN and submits itself on the sixth digit.
  Five wrong tries from one address locks it out for 30 seconds, and the
  right PIN is refused during the lockout.
- Changing or turning off the PIN asks for the current one, so a browser
  left open on the admin page cannot be used to take the machine over.
  Whoever sets it keeps their session rather than being bounced to the
  card they just built.
- This is the shell's own PIN and guards /admin and the admin API only.
  Dashboards stay open, and it is unrelated to a module's PIN — Slack's
  4-8 digit send PIN still guards sending, separately and independently.
- Ways back in if it is forgotten: PRODDASH_PASSCODE in the environment
  (which overrides the PIN), or delete adminPin from proddash.json and
  restart. Both are in the README and on the page itself.
- The older free-text adminPasscode still works, so an existing install
  keeps running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 10:46:41 -04:00
12 changed files with 701 additions and 59 deletions
+65 -11
View File
@@ -99,9 +99,18 @@ with this machine's shell settings — the same keys as the checked-in
| Key | Meaning |
| --- | --- |
| `port` | Port ProdDash serves on (default `24500`). The macOS launcher writes this one, so the port it is set to holds however ProdDash is started. |
| `adminPasscode` | If set, the admin page asks for this shared passcode. Leave `""` for none — it's a plain-HTTP LAN tool. |
| `adminPin` | The admin PIN, as a scrypt hash. **Set it in the admin page's Admin PIN section**, not by hand — 6 digits, and only the hash is stored. Absent means admin is open to anyone on the network. |
| `adminPasscode` | An older free-text passcode. Still honoured, so an existing install keeps working; new installs should use `adminPin`. |
| `forge` | Where updates and module installs are fetched from. Absent or `"github"` uses GitHub; a Gitea or Forgejo base URL such as `"http://10.3.8.90:3000"` uses a server of your own. Set it per machine in the data directory, alongside `repo`. |
| `theme` | The dashboard palette: `booth` (default), `harbor`, `graphite`, `ember` or `daylight`. The admin page's **Theme** section writes this one — see [Themes](#themes). |
| `theme` | The dashboard palette: `booth` (default), `harbor`, `graphite`, `ember`, `daylight`, `midnight`, `moss`, `plum`, `sepia`, `blackout`, `paper` or `iron`. The admin page's **Theme** section writes this one — see [Themes](#themes). |
**Locked out?** Delete `adminPin` from the data directory's `proddash.json`
and restart, or start the server with `PRODDASH_PASSCODE` set — that passcode
overrides the PIN and always gets you in.
Only `/admin` and the admin API are behind the PIN. Dashboards stay open, so a
wall display needs no PIN, and it is unrelated to a module's own PIN (Slack's
4–8 digit send PIN, say), which guards that module's actions only.
**Updating from your own git server.** ProdDash fetches one `tar.gz` of the
branch — nothing else — so any Gitea or Forgejo works. Point `forge` at its
@@ -131,12 +140,26 @@ ignored from then on.
The whole dashboard — every tile, on every browser — draws with one palette,
chosen in the admin page's **Theme** section and switched live: pick a swatch
and open dashboards change without a reload. Five presets ship: **Booth**
(the default dark, mint accent), **Harbor** (deep navy, cyan), **Graphite**
(neutral charcoal, electric blue), **Ember** (warm broadcast black, orange)
and **Daylight**, a light palette for a front-of-house desk in daytime. Each
keeps the status colours apart — the accent is never near the danger red or
the warning amber — and muted text readable in a dark booth. The choice is
and open dashboards change without a reload. The section starts folded —
a building picks its palette once — and holds twelve presets:
| | |
|---|---|
| **Booth** | the default dark, mint accent |
| **Harbor** | deep navy, cyan |
| **Graphite** | neutral charcoal, electric blue |
| **Ember** | warm broadcast black, orange |
| **Daylight** | a cool light palette, for a front-of-house desk in daytime |
| **Midnight** | indigo night, violet |
| **Moss** | forest floor, lime |
| **Plum** | aubergine, purple |
| **Sepia** | warm brown room, cool teal — Ember's arrangement reversed |
| **Blackout** | true black, cyan, for a dark room where any glow carries |
| **Paper** | the warm light one, indigo accent |
| **Iron** | no colour of its own: a near-white accent, so the only hues on screen are a module's own and the red and amber that mean something |
Each keeps the status colours apart — the accent is never near the danger red
or the warning amber — and muted text readable in a dark booth. The choice is
stored as `theme` in the data directory's `proddash.json` (table above), so
it holds across restarts and updates; a browser remembers the last theme it
saw and applies it before the server answers, so nothing flashes. Modules
@@ -247,7 +270,13 @@ below. Nothing is installed without someone clicking.
Tile settings: channel icons, direction and timestamps.
- **ProPresenter** — what's live and what's next, with slide progress,
driven by the shared `propresenter-core` client (ProPresenter 7/20).
The + picker offers two tiles: **Now / Next**, the full card, and **Now**,
the live item alone with the gradient filling the tile edge to edge — a
wall panel that says one thing.
Admin config: ProPresenter endpoint (host : port), password.
Tile settings: a **Show** group of switches for the playlist name, live
status, NOW and NEXT labels, slide count, progress bar, the next list and
its item tags, plus tidy titles.
- **Timers** — live ProPresenter timers (countdown, countdown-to-time,
elapsed) and LTC timecode as auto-sized cards. The + picker lists every
discovered timer (and the LTC card) as its own tile, or add "All timers"
@@ -281,6 +310,29 @@ below. Nothing is installed without someone clicking.
(gear menu): the top-bar text (placeholders such as
`{series} • {part} | {serviceType} • {date}`), colors, text size, and a
show/hide checklist for every detail.
- **Slack** — live transcripts of this location's Slack channels, plus
PIN-protected sending as the location. The + picker lists one transcript
tile per configured channel, titled `#channel`, and two send surfaces (one
of each per dashboard): **Send message**, a free-text box with the admin's
quick emojis one press away, and **Quick replies**, a grid of preset
buttons. Both post to the channel of whichever transcript was clicked last
— its bottom edge lights up in the accent color — and to the first
configured channel until one is clicked. Messages arrive with avatars,
names and times, Slack's inline formatting and the workspace's custom
emoji; a right-click on one opens the reaction menu, and text size and
jump-to-latest sit in the title bar. Nothing leaves the machine until the
PIN is entered: one PIN unlocks every Slack tile on that dashboard page
until the auto-lock idles out, a reload asks again, and the lock button on
a send tile's title bar ends it early.
Admin config is in four groups: **Slack account** (the location account's
User OAuth Token — `xoxp-…`, so messages are literally from the campus —
or a bot token plus "Send as"), **Channels** (the joined channels to
follow, one transcript tile each), **Transcript** (refresh interval,
messages kept, thread replies) and **Sending** (the switch on the group's
title row, a 4–8 digit PIN, auto-lock minutes, quick replies one per line
as `Label | Message` — add `| #channel` to bind one to a channel — and up
to eight quick emojis).
Tile settings: avatars, timestamps, names and channel notices.
- **Clock** — a big booth clock; also the smallest possible module and the
reference for module authors.
@@ -288,10 +340,12 @@ To develop modules against mocks instead of real gear:
`node tools/prodcom-mock.js` (ProdCom on `127.0.0.1:24480`),
`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter slides on
`127.0.0.1:1599`), `node tools/pp-timers-mock.js 1600` (ProPresenter
timers + timecode on `127.0.0.1:1600`) and `node tools/pco-mock.js`
timers + timecode on `127.0.0.1:1600`), `node tools/pco-mock.js`
(Planning Center Services on `127.0.0.1:24700` — run ProdDash with
`PCO_PLAN_API_BASE=http://127.0.0.1:24700`). See the testing section of the
[module guide](docs/MODULE-GUIDE.md).
`PCO_PLAN_API_BASE=http://127.0.0.1:24700`) and
`node tools/slack-mock.js 24716` (the Slack Web API on `127.0.0.1:24716` —
run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`). See the
testing section of the [module guide](docs/MODULE-GUIDE.md).
## Install as a desktop web app
+39 -9
View File
@@ -6,6 +6,12 @@
in /admin; the tile only renders. */
export default function create({ root, moduleApi }) {
/* "now" is the live item on its own, filling the tile; "full" is that plus
the rest of the playlist. The picker presets `view`; a tile placed before
this module had a picker has neither, and gets the full card. */
const view = String(moduleApi.instanceSettings.view || moduleApi.variant || 'full');
const nowOnly = view === 'now';
let state = null;
let feedOffline = false;
let stream = null;
@@ -33,11 +39,13 @@ export default function create({ root, moduleApi }) {
</div>`;
const screenEl = root.querySelector('.nn-screen');
if (nowOnly) screenEl.classList.add('nn-only-now');
const topRow = root.querySelector('.nn-top');
const playlistNameEl = root.querySelector('.nn-playlist');
const liveEl = root.querySelector('.nn-live');
const liveTextEl = root.querySelector('.nn-live-text');
const nowEl = root.querySelector('.nn-now');
const nowLabelEl = root.querySelector('.nn-label');
const nowTitleBox = root.querySelector('.nn-title-box');
let nowTitleEl = root.querySelector('.nn-title');
const nowMetaEl = root.querySelector('.nn-meta');
@@ -47,9 +55,11 @@ export default function create({ root, moduleApi }) {
const nextListEl = root.querySelector('.nn-next-list');
const nextMoreEl = root.querySelector('.nn-next-more');
/** A setting, defaulting to on — a Now tile's schema omits the Next ones. */
function prefs() {
return moduleApi.instanceSettings; // showPlaylist / showProgress / cleanTitles
return moduleApi.instanceSettings;
}
const on = (key) => prefs()[key] !== false;
/* ── title tidying (same rule as propresenter-core.cleanItemName) ─ */
@@ -65,7 +75,7 @@ export default function create({ root, moduleApi }) {
}
function displayName(raw) {
return prefs().cleanTitles ? cleanItemName(raw) : String(raw || '').trim();
return on('cleanTitles') ? cleanItemName(raw) : String(raw || '').trim();
}
function itemTypeTag(type) {
@@ -307,7 +317,9 @@ export default function create({ root, moduleApi }) {
const total = state.currentSlideTotal || activeItem?.slideCount || 0;
const current = state.currentSlide || 0;
if (total > 0 && current > 0) {
if (!on('showSlideCount')) {
nowMetaEl.textContent = '';
} else if (total > 0 && current > 0) {
const remaining = Math.max(0, total - current);
nowMetaEl.textContent = `Slide ${current} / ${total} · ${remaining} left`;
} else if (total > 0) {
@@ -318,7 +330,7 @@ export default function create({ root, moduleApi }) {
nowMetaEl.textContent = '';
}
if (prefs().showProgress && total > 0) {
if (on('showProgress') && total > 0) {
progressEl.hidden = false;
const pct = current > 0 ? Math.max(0, Math.min(100, (current / total) * 100)) : 0;
progressFillEl.style.width = `${pct}%`;
@@ -349,6 +361,10 @@ export default function create({ root, moduleApi }) {
nameEl.textContent = displayName(item.name) || '(untitled)';
row.appendChild(nameEl);
if (!on('showTags')) {
nextListEl.appendChild(row);
continue;
}
if (item.type === 'presentation' && Number.isInteger(item.slideCount)) {
const chip = document.createElement('div');
chip.className = 'nn-count-chip';
@@ -366,7 +382,7 @@ export default function create({ root, moduleApi }) {
nextListEl.appendChild(row);
}
nextLabelEl.hidden = upcoming.length === 0;
nextLabelEl.hidden = upcoming.length === 0 || !on('showNextLabel');
nextMoreEl.hidden = true;
markNextOverflow();
requestAnimationFrame(markNextOverflow);
@@ -374,12 +390,22 @@ export default function create({ root, moduleApi }) {
function render() {
screenEl.classList.toggle('is-offline', feedOffline);
const showTop = prefs().showPlaylist || feedOffline || !state?.reachable || !state?.enabled;
topRow.classList.toggle('hidden-row', !showTop);
playlistNameEl.textContent = prefs().showPlaylist ? state?.playlistName || '' : '';
// Trouble always shows, whatever is switched off: a tile that has stopped
// updating must never look like one with nothing live.
const trouble = feedOffline || !state?.reachable || !state?.enabled;
const wantPlaylist = on('showPlaylist');
const wantLive = on('showLive') || trouble;
topRow.classList.toggle('hidden-row', !wantPlaylist && !wantLive);
liveEl.hidden = !wantLive;
// Hidden, not just emptied: it is flex:1, so an emptied one still holds
// the row open and pushes the live dot off the right edge.
playlistNameEl.hidden = !wantPlaylist;
playlistNameEl.textContent = state?.playlistName || '';
nowLabelEl.hidden = !on('showNowLabel');
screenEl.classList.toggle('nn-no-next', nowOnly || !on('showNext'));
renderLive();
renderNow();
renderNext();
if (!nowOnly && on('showNext')) renderNext();
}
/* ── sizing: --unit scales the whole card to the tile ───────────── */
@@ -387,6 +413,10 @@ export default function create({ root, moduleApi }) {
function applyUnit() {
const unit = Math.max(6.5, Math.min(20, root.clientHeight * 0.032));
screenEl.style.setProperty('--unit', unit.toFixed(2) + 'px');
// On a Now tile the top row floats over the gradient, so at the shortest
// sizes it would sit on the title. Below the height it needs, it goes —
// the header dot and the title itself still report trouble.
screenEl.classList.toggle('nn-tight', nowOnly && root.clientHeight < unit * 9);
}
/* ── live feed ──────────────────────────────────────────────────── */
+15 -6
View File
@@ -1,8 +1,8 @@
{
"id": "propresenter-now-next",
"name": "ProPresenter",
"version": "1.0.1",
"proddash": ">=1.1.0",
"version": "1.1.0",
"proddash": ">=1.5.0",
"description": "What is live in ProPresenter and what comes next",
"permissions": [
{ "kind": "local-network", "reason": "Reaching ProPresenter on this network" }
@@ -20,13 +20,22 @@
},
"password": {
"type": "password",
"label": "API password (only if set in ProPresenter)",
"label": "Password",
"default": ""
}
},
"instanceSchema": {
"showPlaylist": { "type": "boolean", "label": "Show the playlist name", "default": true },
"showProgress": { "type": "boolean", "label": "Show the slide progress bar", "default": true },
"cleanTitles": { "type": "boolean", "label": "Tidy titles (hide [ Full ] tags and date codes)", "default": true }
"cleanTitles": { "type": "switch", "label": "Tidy titles", "default": true },
"showPlaylist": { "type": "switch", "label": "Playlist", "default": true, "group": "Show" },
"showLive": { "type": "switch", "label": "Live status", "default": true, "group": "Show" },
"showNowLabel": { "type": "switch", "label": "Now label", "default": true, "group": "Show" },
"showSlideCount": { "type": "switch", "label": "Slide count", "default": true, "group": "Show" },
"showProgress": { "type": "switch", "label": "Progress bar", "default": true, "group": "Show" },
"showNext": { "type": "switch", "label": "Next list", "default": true, "group": "Show" },
"showNextLabel": { "type": "switch", "label": "Next label", "default": true, "group": "Show" },
"showTags": { "type": "switch", "label": "Item tags", "default": true, "group": "Show" }
},
"instanceGroups": {
"Show": { "collapsed": false, "columns": 2 }
}
}
+37
View File
@@ -108,6 +108,43 @@ module.exports = {
};
},
/**
* Two ways to place this module. "Now / Next" is the full card — what is
* live, then the rest of the playlist. "Now" is the same live item alone,
* edge to edge with the gradient filling the tile: a wall display that says
* one thing. The Now entry offers only the settings it can act on, so its
* gear carries no Next switches.
*/
tiles() {
return [
{
id: 'full',
name: 'Now / Next',
description: 'What is live, and the rest of the playlist',
defaultSize: { w: 4, h: 5 },
minSize: { w: 3, h: 3 },
settings: { view: 'full' },
},
{
id: 'now',
name: 'Now',
description: 'Only what is live, filling the tile',
defaultSize: { w: 4, h: 2 },
minSize: { w: 2, h: 1 },
settings: { view: 'now' },
instanceSchema: {
cleanTitles: { type: 'switch', label: 'Tidy titles', default: true },
showPlaylist: { type: 'switch', label: 'Playlist', default: true, group: 'Show' },
showLive: { type: 'switch', label: 'Live status', default: true, group: 'Show' },
showNowLabel: { type: 'switch', label: 'Now label', default: true, group: 'Show' },
showSlideCount: { type: 'switch', label: 'Slide count', default: true, group: 'Show' },
showProgress: { type: 'switch', label: 'Progress bar', default: true, group: 'Show' },
},
instanceGroups: { Show: { collapsed: false, columns: 2 } },
},
];
},
routes() {
return {
'GET /state': (req, res) => {
+48
View File
@@ -21,6 +21,51 @@
justify-content: center;
}
/* NEXT switched off, or a Now tile: the list goes and the card takes the room. */
[data-module="propresenter-now-next"] .nn-screen.nn-no-next .nn-next-wrap { display: none; }
[data-module="propresenter-now-next"] .nn-screen.nn-no-next { justify-content: center; }
/* ── the "Now" tile ──
Only what is live, edge to edge: the gradient moves off the card and onto
the whole tile, the card loses its border, radius and box, and the title
sits in the middle of everything. Nothing is inset, so a wall of these
reads as panels of colour rather than cards on a background. */
[data-module="propresenter-now-next"] .nn-screen.nn-only-now {
gap: 0;
background: linear-gradient(
160deg,
color-mix(in srgb, var(--accent) 14%, transparent),
color-mix(in srgb, var(--bg) 70%, transparent)
);
}
[data-module="propresenter-now-next"] .nn-only-now .nn-now {
flex: 1;
height: auto;
min-height: 0;
justify-content: center;
border: none;
border-radius: 0;
background: none;
padding: 0;
}
/* The live dot and playlist ride over the gradient rather than above it. */
[data-module="propresenter-now-next"] .nn-only-now .nn-top {
position: absolute;
inset: var(--nn-pad) var(--nn-pad) auto;
pointer-events: none;
}
[data-module="propresenter-now-next"] .nn-only-now .nn-title-box { margin-top: 0; }
/* The overlay floats, so it takes no room — this keeps the title clear of it
and still centred, by holding back as much at the bottom as at the top. */
[data-module="propresenter-now-next"] .nn-only-now:not(.nn-tight) .nn-now {
padding: calc(var(--unit) * 1.4) 0;
}
/* Too short for the overlay: the title gets the whole panel to itself. */
[data-module="propresenter-now-next"] .nn-only-now.nn-tight .nn-top,
[data-module="propresenter-now-next"] .nn-only-now.nn-tight.is-offline .nn-top {
display: none !important;
}
/* ── top row ── */
[data-module="propresenter-now-next"] .nn-top {
@@ -59,6 +104,7 @@
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="propresenter-now-next"] .nn-playlist[hidden] { display: none; }
[data-module="propresenter-now-next"] .nn-live {
display: flex;
@@ -71,6 +117,7 @@
text-transform: uppercase;
color: var(--muted);
}
[data-module="propresenter-now-next"] .nn-live[hidden] { display: none; }
[data-module="propresenter-now-next"] .nn-live-dot {
width: calc(var(--unit) * 0.6);
@@ -122,6 +169,7 @@
text-transform: uppercase;
color: var(--accent);
}
[data-module="propresenter-now-next"] .nn-label[hidden] { display: none; }
[data-module="propresenter-now-next"] .nn-title-box {
flex: 1;
+23 -12
View File
@@ -141,13 +141,17 @@ const sendTarget = (() => {
/**
* Where a send goes, given the configured channels in admin order: the
* selection while it is still configured and its transcript is on the
* page; otherwise the first configured channel.
* page, else the first configured channel that IS on this page — with one
* transcript up, that one — and only if the page shows none, the first
* configured channel. A send must never quietly go to a channel the
* operator cannot see.
*/
resolve(configured) {
const list = Array.isArray(configured) ? configured : [];
if (!list.length) return '';
if (id && list.includes(id) && this.hasTranscript(id)) return id;
return list[0];
const onPage = list.find((c) => this.hasTranscript(c));
return onPage || list[0];
},
on(fn) {
bus.addEventListener('change', fn);
@@ -1100,7 +1104,7 @@ function createSendTile({ root, moduleApi, mode }) {
if (stopped || !state) return;
if (token) {
if (composeEls) {
composeEls.line.textContent = sendingLine();
if (composeEls.line) composeEls.line.textContent = sendingLine();
if (composeEls.input) composeEls.input.placeholder = placeholder();
}
} else renderGate();
@@ -1177,14 +1181,21 @@ function createSendTile({ root, moduleApi, mode }) {
let composeEls = null;
function buildCompose() {
compose.textContent = '';
const head = el('div', 'sl-compose-head');
const line = el('span', 'sl-sending', sendingLine());
const lockLink = el('button', 'sl-lock-link', 'Lock');
lockLink.type = 'button';
lockLink.title = 'Lock now';
lockLink.addEventListener('click', () => lock('Locked'));
head.append(line, lockLink);
compose.appendChild(head);
// Send message names the channel in the box's own placeholder and locks
// from the title bar, so a line above the field would only say both
// again. Quick replies has no placeholder — there it is the only place
// the target is named, so it stays.
let line = null;
if (mode !== 'send') {
const head = el('div', 'sl-compose-head');
line = el('span', 'sl-sending', sendingLine());
const lockLink = el('button', 'sl-lock-link', 'Lock');
lockLink.type = 'button';
lockLink.title = 'Lock now';
lockLink.addEventListener('click', () => lock('Locked'));
head.append(line, lockLink);
compose.appendChild(head);
}
const foot = el('div', 'sl-compose-foot');
const msg = el('div', 'sl-msg');
@@ -1462,7 +1473,7 @@ function createSendTile({ root, moduleApi, mode }) {
if (!s.sending?.enabled || !s.sending?.pinSet) return void lock('');
if (!composeEls) return void showCompose(); // the page was unlocked before this tile appeared
if (composeEls) {
composeEls.line.textContent = sendingLine();
if (composeEls.line) composeEls.line.textContent = sendingLine();
if (composeEls.input) composeEls.input.placeholder = placeholder();
}
} else renderGate();
+3 -3
View File
@@ -1,7 +1,7 @@
{
"id": "slack",
"name": "Slack",
"version": "1.2.0",
"version": "1.2.1",
"proddash": ">=1.4.0",
"description": "Live transcripts of this location's Slack channels, plus PIN-protected sending as the location",
"client": "client.js",
@@ -21,7 +21,7 @@
"help": "Create a Slack app in the workspace (api.slack.com/apps → Create New App → From scratch) and, under OAuth & Permissions → User Token Scopes, add channels:history, channels:read, groups:history, groups:read, chat:write, users:read, reactions:write (emoji reactions) and emoji:read (the workspace's custom emoji). Sign in to Slack as this location's account — the one named for the campus — open the app's OAuth & Permissions page and press Install to Workspace (or Reinstall to Workspace), then copy the User OAuth Token (xoxp-…) it shows and paste it here: messages from this machine are then literally from the location. A bot token (xoxb-…) works too; it posts as the app, with \"Send as\" naming the location."
},
"Channels": {
"help": "Every channel picked here becomes its own transcript tile in the + picker, titled with the channel's name. The Send message and Quick replies tiles post to whichever transcript was clicked last — its bottom edge lights up in the accent colour — and to the first channel here until one is clicked. Only channels the account has joined are listed; join one in Slack first. Each channel is read on its own schedule, all of them together kept inside Slack's rate limit."
"help": "Every channel picked here becomes its own transcript tile in the + picker, titled with the channel's name. The Send message and Quick replies tiles post to whichever transcript was clicked last — its bottom edge lights up in the accent colour — and until one is clicked, to the first channel here whose transcript is on that dashboard: with a single transcript up, that one. A send never goes to a channel the dashboard is not showing. Only channels the account has joined are listed; join one in Slack first. Each channel is read on its own schedule, all of them together kept inside Slack's rate limit."
},
"Sending": {
"toggle": "sendEnabled",
@@ -49,7 +49,7 @@
"default": [],
"group": "Channels",
"optionsRoute": "/channels",
"help": "One transcript tile per channel; the first one is where sends go until a transcript is clicked. Only channels the account has joined are listed — join one in Slack first."
"help": "One transcript tile per channel; until a transcript is clicked, sends go to the first channel here that the dashboard is actually showing. Only channels the account has joined are listed — join one in Slack first."
},
"pollSeconds": {
"type": "number",
+76
View File
@@ -451,6 +451,36 @@ a.btn { text-decoration: none; }
/* one swatch per preset: a miniature dashboard (page, a tile on it, the
accent as a status dot) and the name; the current one wears the accent */
/* ── a collapsible admin section ─────────────────────────────────────
The pattern for any section of this page that folds. The markup is a
<details class="admin-fold"> holding the section's contents, headed by a
<summary class="admin-h1 admin-fold-title">: the title keeps the same
type as every other section heading — a <summary> would otherwise come
out at the normal weight — and the caret follows the words, so a folded
section still reads as a heading first. The whole row is the hit target. */
details.admin-fold > summary.admin-fold-title {
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
gap: 8px;
font-weight: 700; /* matches h1.admin-h1; summary defaults to normal */
user-select: none;
}
details.admin-fold > summary.admin-fold-title::-webkit-details-marker { display: none; }
/* after the title, not before it — the heading reads first, the caret says
there is more behind it */
details.admin-fold > summary.admin-fold-title::after {
content: "";
width: 0; height: 0;
border-left: 6px solid var(--muted);
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
transition: transform 0.15s;
}
details.admin-fold[open] > summary.admin-fold-title::after { transform: rotate(90deg); }
details.admin-fold > summary.admin-fold-title:hover { color: var(--accent); }
.theme-swatches {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(116px, 1fr));
@@ -512,3 +542,49 @@ a.btn { text-decoration: none; }
color: var(--accent);
}
#theme-status { margin-top: 4px; }
/* ── the admin PIN ── */
/* Digits, evenly spaced, so six of them read as a PIN rather than a word. */
.pin-input {
font-variant-numeric: tabular-nums;
letter-spacing: 0.35em;
text-align: center;
max-width: 9em;
}
.pin-form {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px;
}
.pin-field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: var(--muted);
}
.pin-field[hidden] { display: none; }
.pin-field input {
padding: 8px 10px;
font-size: 14px;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 7px;
}
.pin-field input:focus { outline: none; border-color: var(--accent); }
.pin-actions {
display: flex;
gap: 8px;
margin-left: auto;
}
.pin-note { margin-top: 12px; }
.pin-note code {
font-size: 0.92em;
padding: 1px 5px;
border-radius: 4px;
background: var(--bg);
border: 1px solid var(--border);
}
+92 -2
View File
@@ -2,8 +2,10 @@
Everything here is server-wide: module enable/disable and admin config
(written to config/modules.json, module re-inited on save), plus named
layout management. Guarded by the shared passcode when one is set. */
layout management. Behind the admin PIN when one is set — a shell setting of
its own, nothing to do with a module's PIN (Slack's send PIN, say). */
const securitySection = document.getElementById('security-section');
const loginCard = document.getElementById('login-card');
const loginStatus = document.getElementById('login-status');
const passcodeInput = document.getElementById('passcode');
@@ -62,20 +64,30 @@ function showLogin() {
layoutsSection.hidden = true;
updateCard.hidden = true;
themeSection.hidden = true;
securitySection.hidden = true;
passcodeInput.value = '';
passcodeInput.focus();
}
let loggingIn = false;
async function login() {
if (loggingIn) return;
loggingIn = true;
loginStatus.textContent = '';
loginStatus.className = 'admin-status';
try {
await api('/api/admin/login', { method: 'POST', body: JSON.stringify({ passcode: passcodeInput.value }) });
await api('/api/admin/login', { method: 'POST', body: JSON.stringify({ pin: passcodeInput.value }) });
passcodeInput.value = '';
loginCard.hidden = true;
await loadState();
} catch (e) {
passcodeInput.value = '';
loginStatus.textContent = e.message;
loginStatus.className = 'admin-status error';
passcodeInput.focus();
} finally {
loggingIn = false;
}
}
@@ -83,6 +95,83 @@ document.getElementById('login-btn').addEventListener('click', login);
passcodeInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') login();
});
// Six digits is the whole PIN, so there is nothing left to press.
passcodeInput.addEventListener('input', () => {
passcodeInput.value = passcodeInput.value.replace(/\D/g, '').slice(0, 6);
if (passcodeInput.value.length === 6) login();
});
/* ── the admin PIN ───────────────────────────────────────────────────── */
const pinCurrentField = document.getElementById('pin-current-field');
const pinCurrent = document.getElementById('pin-current');
const pinNew = document.getElementById('pin-new');
const pinRepeat = document.getElementById('pin-repeat');
const pinSaveBtn = document.getElementById('pin-save');
const pinClearBtn = document.getElementById('pin-clear');
const pinStatus = document.getElementById('pin-status');
const pinStateEl = document.getElementById('pin-state');
for (const input of [pinCurrent, pinNew, pinRepeat]) {
input.addEventListener('input', () => {
input.value = input.value.replace(/\D/g, '').slice(0, 6);
});
}
/** Paint the section for "no PIN yet" or "one is set". */
function renderPin(state) {
const set = Boolean(state.pinSet);
const fromEnv = Boolean(state.pinFromEnv);
securitySection.hidden = false;
pinCurrentField.hidden = !set;
pinClearBtn.hidden = !set;
document.getElementById('pin-new-label').textContent = set ? 'New PIN' : 'PIN';
pinSaveBtn.textContent = set ? 'Change PIN' : 'Set PIN';
pinStateEl.textContent = fromEnv
? 'A passcode is set in this server’s environment (PRODDASH_PASSCODE) and takes precedence. Anything set here applies once that is removed.'
: set
? 'Admin is locked. This page asks for the PIN, and so does every admin request.'
: 'Admin is open to anyone on the network. Set a 6-digit PIN to lock it.';
if (state.dataDir) document.getElementById('pin-file').textContent = state.dataDir + '/proddash.json';
}
function pinMessage(text, bad = false) {
pinStatus.textContent = text;
pinStatus.className = 'admin-status' + (bad ? ' error' : ' ok');
}
async function savePin(clearing = false) {
pinStatus.textContent = '';
pinStatus.className = 'admin-status';
const current = pinCurrent.value;
const next = clearing ? '' : pinNew.value;
if (!clearing) {
if (!/^\d{6}$/.test(next)) return pinMessage('The PIN must be exactly 6 digits.', true);
if (next !== pinRepeat.value) return pinMessage('The two PINs are not the same.', true);
}
if (!pinCurrentField.hidden && !/^\d{6}$/.test(current)) {
return pinMessage('Enter the current PIN to change it.', true);
}
pinSaveBtn.disabled = true;
pinClearBtn.disabled = true;
try {
const body = await api('/api/admin/pin', { method: 'POST', body: JSON.stringify({ pin: next, current }) });
for (const i of [pinCurrent, pinNew, pinRepeat]) i.value = '';
pinMessage(body.pinSet ? 'PIN saved. It is asked for from the next visit.' : 'PIN removed — admin is open again.');
await loadState();
} catch (e) {
pinMessage(e.message, true);
} finally {
pinSaveBtn.disabled = false;
pinClearBtn.disabled = false;
}
}
pinSaveBtn.addEventListener('click', () => savePin(false));
pinClearBtn.addEventListener('click', () => {
if (confirm('Turn the admin PIN off? Anyone on the network could then change this server’s settings.')) savePin(true);
});
pinRepeat.addEventListener('keydown', (e) => { if (e.key === 'Enter') savePin(false); });
/* ── module cards ───────────────────────────────────────────────────── */
@@ -856,6 +945,7 @@ async function loadState() {
for (const mod of state.modules || []) {
modulesList.appendChild(buildModuleCard(mod));
}
renderPin(state);
loadTheme();
loadLayouts();
loadCatalog(false);
+41 -5
View File
@@ -18,10 +18,11 @@
<main id="admin-main">
<section class="admin-card" id="login-card" hidden>
<h2>Admin passcode</h2>
<h2>Admin PIN</h2>
<p class="admin-copy">This page changes the server-wide configuration for every dashboard in the building.</p>
<div class="login-row">
<input type="password" id="passcode" placeholder="Passcode" autocomplete="current-password">
<input type="password" id="passcode" class="pin-input" inputmode="numeric" autocomplete="off"
maxlength="6" placeholder="••••••" aria-label="Admin PIN">
<button class="btn primary" id="login-btn">Unlock</button>
</div>
<div class="admin-status" id="login-status"></div>
@@ -40,9 +41,11 @@
</section>
<section id="theme-section" hidden>
<h1 class="admin-h1">Theme</h1>
<div class="theme-swatches" id="theme-swatches" role="radiogroup" aria-label="Theme"></div>
<div class="admin-status" id="theme-status"></div>
<details class="admin-fold">
<summary class="admin-h1 admin-fold-title">Theme</summary>
<div class="theme-swatches" id="theme-swatches" role="radiogroup" aria-label="Theme"></div>
<div class="admin-status" id="theme-status"></div>
</details>
</section>
<section id="modules-section" hidden>
@@ -65,6 +68,39 @@
<h1 class="admin-h1">Saved layouts</h1>
<div id="layouts-list"></div>
</section>
<section id="security-section" hidden>
<h1 class="admin-h1">Admin PIN</h1>
<p class="admin-copy" id="pin-state"></p>
<div class="admin-card">
<div class="pin-form">
<label class="pin-field" id="pin-current-field" hidden>
<span>Current PIN</span>
<input type="password" id="pin-current" class="pin-input" inputmode="numeric"
autocomplete="off" maxlength="6" placeholder="••••••">
</label>
<label class="pin-field">
<span id="pin-new-label">New PIN</span>
<input type="password" id="pin-new" class="pin-input" inputmode="numeric"
autocomplete="off" maxlength="6" placeholder="6 digits">
</label>
<label class="pin-field">
<span>Repeat</span>
<input type="password" id="pin-repeat" class="pin-input" inputmode="numeric"
autocomplete="off" maxlength="6" placeholder="6 digits">
</label>
<div class="pin-actions">
<button class="btn primary" id="pin-save">Set PIN</button>
<button class="btn" id="pin-clear" hidden>Turn off</button>
</div>
</div>
<p class="admin-copy pin-note">Only this page and the admin API ask for it — dashboards stay
open, and it is nothing to do with a module's own PIN, such as Slack's send PIN. Forgotten?
Remove <code>adminPin</code> from <code id="pin-file">proddash.json</code> in the settings
folder and restart.</p>
<div class="admin-status" id="pin-status"></div>
</div>
</section>
</main>
<div id="toast" hidden></div>
+101 -2
View File
@@ -941,8 +941,10 @@ body.fullscreen #grid { padding-bottom: 0; }
every shell element follows, and a colour a tile is configured with (set
on the tile itself) still wins. The :root block at the top of this file is
"booth", the default; the list of ids and names lives in server.js
(GET /api/theme). All four are booth-grade: high contrast, the accent
nowhere near the danger red or the warning amber, muted still readable. */
(GET /api/theme). Every one is booth-grade: high contrast, the accent
nowhere near the danger red or the warning amber, muted still readable.
Each sets --accent-text too when the default (a near-black green, for
booth's mint) would look wrong against its own accent. */
/* Harbor — deep navy, cyan accent */
html[data-theme="harbor"] {
@@ -993,3 +995,100 @@ html[data-theme="daylight"] {
--danger: #d1242f;
--warn: #b26a00;
}
/* Midnight — indigo night, violet accent */
html[data-theme="midnight"] {
--bg: #0a0b18;
--panel: #14162b;
--border: #272a4a;
--text: #e8e9fb;
--muted: #9a9cc8;
--accent: #9d8cff;
--accent-text: #12103a;
--danger: #ff5a72;
--warn: #f5b93a;
}
/* Moss — forest floor, lime accent (warn pushed orange so the two greens
and the amber never read as one another) */
html[data-theme="moss"] {
--bg: #08120c;
--panel: #101e15;
--border: #1f3728;
--text: #e9f4ea;
--muted: #8fb09a;
--accent: #8fdb3f;
--accent-text: #15250a;
--danger: #ff5a52;
--warn: #ff9f1c;
}
/* Plum — aubergine, purple accent */
html[data-theme="plum"] {
--bg: #110a17;
--panel: #1d1228;
--border: #332042;
--text: #f2e9f8;
--muted: #b195c2;
--accent: #c084fc;
--accent-text: #260a37;
--danger: #ff4d4f;
--warn: #f7b53b;
}
/* Sepia — warm brown room, cool teal accent: the opposite arrangement to
Ember, which is warm throughout */
html[data-theme="sepia"] {
--bg: #12100b;
--panel: #1d1913;
--border: #33291d;
--text: #f4ede0;
--muted: #b3a48b;
--accent: #5eead4;
--accent-text: #06231e;
--danger: #ff5a52;
--warn: #ffc247;
}
/* Blackout — true black for a dark room, where any glow off the screen
carries; everything else is as bright as it can be against it */
html[data-theme="blackout"] {
--bg: #000000;
--panel: #0a0a0a;
--border: #1f1f1f;
--text: #ffffff;
--muted: #9e9e9e;
--accent: #00e5ff;
--accent-text: #00212a;
--danger: #ff3b30;
--warn: #ffcc00;
}
/* Paper — the warm light one, next to Daylight's cool white. Danger and warn
darken, as they must on a light background, and the indigo accent takes
white text. */
html[data-theme="paper"] {
--bg: #f5f1e8;
--panel: #fffdf8;
--border: #ddd5c4;
--text: #1c1a15;
--muted: #6b6455;
--accent: #4f46e5;
--accent-text: #ffffff;
--danger: #c8102e;
--warn: #9a6700;
}
/* Iron — no colour of its own: the accent is near-white, so the only hues on
screen are a module's own and the red/amber that mean something */
html[data-theme="iron"] {
--bg: #0c0c0d;
--panel: #161617;
--border: #2b2b2d;
--text: #f2f2f3;
--muted: #9a9a9d;
--accent: #d8d8dc;
--accent-text: #131314;
--danger: #ff4d4f;
--warn: #ffb224;
}
+161 -9
View File
@@ -12,7 +12,8 @@
* Configure via config/proddash.json (defaults, in the repo), an optional
* proddash.json in the data directory (this machine's overrides), or env vars:
* PORT (default: value in proddash.json, else 24500)
* PRODDASH_PASSCODE (overrides adminPasscode in proddash.json)
* PRODDASH_PASSCODE (a passcode that overrides the stored admin PIN —
* also the way back in if the PIN is ever forgotten)
* PRODDASH_FORGE (overrides `forge`: "github", or a Gitea/Forgejo base
* URL such as http://10.3.8.90:3000)
* PRODDASH_DATA_DIR (where module config + layouts are kept; see below)
@@ -711,6 +712,13 @@ const THEMES = [
{ id: 'graphite', name: 'Graphite', colors: { bg: '#0f1012', panel: '#17191d', accent: '#6cb4ff' } },
{ id: 'ember', name: 'Ember', colors: { bg: '#120c0a', panel: '#1d1411', accent: '#ff8f3a' } },
{ id: 'daylight', name: 'Daylight', colors: { bg: '#eef1f5', panel: '#ffffff', accent: '#0b8a5f' } },
{ id: 'midnight', name: 'Midnight', colors: { bg: '#0a0b18', panel: '#14162b', accent: '#9d8cff' } },
{ id: 'moss', name: 'Moss', colors: { bg: '#08120c', panel: '#101e15', accent: '#8fdb3f' } },
{ id: 'plum', name: 'Plum', colors: { bg: '#110a17', panel: '#1d1228', accent: '#c084fc' } },
{ id: 'sepia', name: 'Sepia', colors: { bg: '#12100b', panel: '#1d1913', accent: '#5eead4' } },
{ id: 'blackout', name: 'Blackout', colors: { bg: '#000000', panel: '#0a0a0a', accent: '#00e5ff' } },
{ id: 'paper', name: 'Paper', colors: { bg: '#f5f1e8', panel: '#fffdf8', accent: '#4f46e5' } },
{ id: 'iron', name: 'Iron', colors: { bg: '#0c0c0d', panel: '#161617', accent: '#d8d8dc' } },
];
const DEFAULT_THEME = THEMES[0].id;
@@ -1170,12 +1178,109 @@ function restartServer(reason) {
/* ── admin API ──────────────────────────────────────────────────────── */
/* Admin is behind a 6-digit PIN, set in the admin page and kept in this
machine's proddash.json as a scrypt hash — never in the clear, because that
file gets copied between machines. A dashboard needs no PIN; only /admin
and the admin API do. Two ways back in if it is ever forgotten: set
PRODDASH_PASSCODE in the environment, or delete "adminPin" from
proddash.json in the data directory and restart. `adminPasscode` (the older
free-text setting) still works so an existing install keeps running. */
const PIN_RE = /^\d{6}$/;
const PIN_MAX_WRONG = 5;
const PIN_LOCKOUT_MS = 30000;
const ADMIN_PASSCODE = String(process.env.PRODDASH_PASSCODE || shellConfig.adminPasscode || '');
/** Session token: regenerating it every boot is fine — admins just re-enter the passcode. */
/** Session token: regenerating it every boot is fine — admins just re-enter the PIN. */
const adminToken = crypto.randomBytes(24).toString('hex');
/** The stored PIN, as "scrypt$<salt>$<hash>"; '' when admin is open. */
let adminPinHash = String(shellConfig.adminPin || '');
const pinAttempts = new Map(); // ip → { count, lockedUntil }
function hashPin(pin, salt = crypto.randomBytes(16).toString('hex')) {
return `scrypt$${salt}$${crypto.scryptSync(String(pin), salt, 32).toString('hex')}`;
}
/** Constant-time check of a PIN against the stored hash. */
function pinMatches(given, stored) {
const parts = String(stored || '').split('$');
if (parts.length !== 3 || parts[0] !== 'scrypt') return false;
const expected = Buffer.from(parts[2], 'hex');
const actual = crypto.scryptSync(String(given), parts[1], expected.length);
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
}
/** Is a credential required at all? */
function adminLocked() {
return Boolean(ADMIN_PASSCODE || adminPinHash);
}
/** True when `given` is the credential in force (the env passcode, or the PIN). */
function credentialOk(given) {
const s = String(given ?? '');
if (!s) return false;
if (ADMIN_PASSCODE) {
const a = Buffer.from(s);
const b = Buffer.from(ADMIN_PASSCODE);
if (a.length === b.length && crypto.timingSafeEqual(a, b)) return true;
}
return adminPinHash ? pinMatches(s, adminPinHash) : false;
}
function clientIp(req) {
return String(req.socket?.remoteAddress || 'unknown');
}
/** Seconds of lockout left for this address, or 0. */
function lockoutLeft(ip) {
const a = pinAttempts.get(ip);
if (!a || !a.lockedUntil) return 0;
const left = a.lockedUntil - Date.now();
if (left <= 0) {
pinAttempts.delete(ip);
return 0;
}
return Math.ceil(left / 1000);
}
function noteWrongPin(ip) {
const a = pinAttempts.get(ip) || { count: 0, lockedUntil: 0 };
a.count += 1;
if (a.count >= PIN_MAX_WRONG) {
a.count = 0;
a.lockedUntil = Date.now() + PIN_LOCKOUT_MS;
console.warn(`[proddash] ${PIN_MAX_WRONG} wrong admin PINs from ${ip} — locked out for ${PIN_LOCKOUT_MS / 1000} s`);
}
pinAttempts.set(ip, a);
}
/**
* Store a new PIN (6 digits), or '' to open admin up again. Keeps every other
* key in this machine's proddash.json. Throws an httpError the route reports.
*/
function setAdminPin(pin) {
const next = String(pin ?? '');
if (next && !PIN_RE.test(next)) throw httpError(400, 'The PIN must be exactly 6 digits.');
const file = path.join(DATA_DIR, 'proddash.json');
let existing = {};
if (fs.existsSync(file)) {
existing = readJson(file, null);
if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
throw httpError(500, `${file} is not a JSON object — fix it by hand before changing the PIN.`);
}
}
const hash = next ? hashPin(next) : '';
const out = { ...existing };
if (hash) out.adminPin = hash;
else delete out.adminPin;
writeJson(file, out);
adminPinHash = hash;
shellConfig.adminPin = hash;
pinAttempts.clear();
console.log(`[proddash] admin PIN ${hash ? 'set' : 'removed — admin is open again'}`);
return { pinSet: Boolean(hash) };
}
function isAuthed(req) {
if (!ADMIN_PASSCODE) return true;
if (!adminLocked()) return true;
const cookies = String(req.headers.cookie || '');
return cookies.split(';').some((c) => c.trim() === 'proddash_admin=' + adminToken);
}
@@ -1183,7 +1288,7 @@ function isAuthed(req) {
/** Guard a state-changing admin route. Returns true when the request was refused. */
function refuseAdminWrite(req, res) {
if (!isAuthed(req)) {
sendJson(res, 401, { error: 'Enter the admin passcode first.', authRequired: true });
sendJson(res, 401, { error: 'Enter the admin PIN first.', authRequired: true });
return true;
}
// Requiring application/json forces a CORS preflight that a hostile page
@@ -1268,10 +1373,19 @@ async function handleAdminApi(req, res, urlPath) {
} catch {
return sendJson(res, 400, { error: 'Malformed request.' });
}
if (!ADMIN_PASSCODE) return sendJson(res, 200, { ok: true, authRequired: false });
if (String(body.passcode || '') !== ADMIN_PASSCODE) {
return sendJson(res, 403, { error: 'Wrong passcode.' });
if (!adminLocked()) return sendJson(res, 200, { ok: true, authRequired: false });
const ip = clientIp(req);
const left = lockoutLeft(ip);
if (left) return sendJson(res, 429, { error: `Too many wrong PINs — try again in ${left} s.`, retryAfter: left });
// `passcode` is what the older admin page sent; `pin` is what this one does.
if (!credentialOk(body.pin ?? body.passcode)) {
noteWrongPin(ip);
const now = lockoutLeft(ip);
return sendJson(res, 403, now
? { error: `Too many wrong PINs — try again in ${now} s.`, retryAfter: now }
: { error: 'Wrong PIN.' });
}
pinAttempts.delete(ip);
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Set-Cookie': `proddash_admin=${adminToken}; Path=/; SameSite=Lax; HttpOnly`,
@@ -1280,12 +1394,50 @@ async function handleAdminApi(req, res, urlPath) {
return res.end(JSON.stringify({ ok: true }));
}
if (urlPath === '/api/admin/pin' && req.method === 'POST') {
if (refuseAdminWrite(req, res)) return;
let body = {};
try {
body = JSON.parse((await readBody(req)) || '{}');
} catch {
return sendJson(res, 400, { error: 'Malformed request.' });
}
// Changing a credential re-asserts it, so a browser left open on the
// admin page can't be used to take the machine over.
if (adminPinHash) {
const ip = clientIp(req);
const left = lockoutLeft(ip);
if (left) return sendJson(res, 429, { error: `Too many wrong PINs — try again in ${left} s.`, retryAfter: left });
if (!pinMatches(String(body.current ?? ''), adminPinHash)) {
noteWrongPin(ip);
return sendJson(res, 403, { error: 'That is not the current PIN.' });
}
pinAttempts.delete(ip);
}
try {
const result = setAdminPin(body.pin);
// Whoever just set it has proved they are allowed, so hand them the
// session rather than bouncing them to the unlock card they just built.
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Set-Cookie': `proddash_admin=${adminToken}; Path=/; SameSite=Lax; HttpOnly`,
'Cache-Control': 'no-store',
});
return res.end(JSON.stringify(result));
} catch (err) {
return sendJson(res, err.status || 500, { error: err.message || 'Could not save the PIN.' });
}
}
if (urlPath === '/api/admin/state' && req.method === 'GET') {
if (!isAuthed(req)) {
return sendJson(res, 401, { authRequired: true, authed: false });
}
return sendJson(res, 200, {
authRequired: Boolean(ADMIN_PASSCODE),
authRequired: adminLocked(),
// The env passcode can't be changed from here; the page says so.
pinSet: Boolean(adminPinHash),
pinFromEnv: Boolean(ADMIN_PASSCODE),
authed: true,
version: SHELL_VERSION,
dataDir: DATA_DIR,
@@ -1481,7 +1633,7 @@ async function handleLayoutsApi(req, res, urlPath) {
}
if (!m[2] && req.method === 'DELETE') {
if (!isAuthed(req)) return sendJson(res, 401, { error: 'Enter the admin passcode first.', authRequired: true });
if (!isAuthed(req)) return sendJson(res, 401, { error: 'Enter the admin PIN first.', authRequired: true });
try {
fs.unlinkSync(layoutPath(name));
} catch {