Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12f0b20e5f | ||
|
|
82e6386c73 | ||
|
|
f5eb611872 | ||
|
|
d52cfcc48e | ||
|
|
7d46cfbd8f | ||
|
|
7a5b57dc56 | ||
|
|
c379025d30 | ||
|
|
f0be37796b | ||
|
|
52d700df3d | ||
|
|
7690ae1fad | ||
|
|
6dabd9d8a1 |
@@ -32,6 +32,12 @@ title-bar controls). The rules that matter most:
|
||||
and bump the module's `version` on every change.
|
||||
- Browsers never call upstream services directly; the module's server routes
|
||||
proxy everything (no CORS, secrets stay server-side).
|
||||
- **Keep the interface clean** (guide rules 5-11): no `help` and no label
|
||||
over three words in a tile's gear; server-side settings in `configSchema`
|
||||
only; a tile setting changes that tile alone; hide-toggles ungrouped when
|
||||
there are four or fewer, in a folded **Show** group when there are five or
|
||||
more; title-bar controls are icons with tooltips, never labels, and never
|
||||
repeat something the gear already offers.
|
||||
- Style with the shell's CSS variables only, scoped under
|
||||
`[data-module="<id>"]`; survive upstream loss (reconnect + `setStatus`).
|
||||
- Verify against the guide's testing section and resilience checklist. Mocks
|
||||
|
||||
@@ -99,10 +99,19 @@ 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). |
|
||||
|
||||
**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
|
||||
base URL and `repo` at the owner/repo there:
|
||||
@@ -247,7 +256,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 +296,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 +326,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
|
||||
|
||||
|
||||
+62
-16
@@ -34,6 +34,46 @@ people running the booth depend on them.
|
||||
The admin page refuses to install a module whose requirement the running
|
||||
shell doesn't meet, and it offers updates by comparing versions.
|
||||
|
||||
### Keeping the interface clean
|
||||
|
||||
A dashboard is read across a booth, at a glance, by someone doing three
|
||||
other things. These rules are what keep every tile looking like one product
|
||||
instead of six. They are as binding as the four above.
|
||||
|
||||
5. **No explanatory text in a tile's gear.** No `help` on an
|
||||
`instanceSchema` field or an `instanceGroups` entry. Labels are **three
|
||||
words at most, one or two preferred** — "Slide count", "Item tags",
|
||||
"Running clock". A control that cannot be understood in three words is
|
||||
not a display choice; it belongs in `configSchema`, where `help` is
|
||||
welcome and expected. *One exception:* a `template` field may keep a
|
||||
single `help` line naming its placeholders, which nobody can guess.
|
||||
6. **Server-side settings are admin-only.** Anything the module's server
|
||||
acts on — a host, a credential, what to fetch, how often to poll — is
|
||||
`configSchema`. `instanceSchema` is display choices and nothing else:
|
||||
what this tile shows, and how big.
|
||||
7. **A tile setting changes that tile and nothing else.** Never another
|
||||
tile, never the module as a whole, never the server. If two tiles must
|
||||
agree on something, it is either admin config or page state the shell
|
||||
owns — not a setting each tile keeps its own copy of.
|
||||
8. **Offer toggles for the elements you draw.** Every tile should let the
|
||||
operator switch off the parts it does not need. **One to four toggles:**
|
||||
give them no `group` at all, so they sit unlabelled at the top of the
|
||||
gear. **Five or more:** one group named **Show**, `collapsed: true`,
|
||||
`columns: 2`. Nothing else goes in that group — colours, formats and
|
||||
text belong in groups of their own.
|
||||
9. **Title-bar controls are icons.** No `label` on `addButton` or
|
||||
`addMenu`; the tooltip carries the words, and `docs` above covers drawing
|
||||
the icon. A small state indicator may ride alongside an icon — ProdCom's
|
||||
channel button shows a count while a filter is on — but a control is
|
||||
never named in the bar.
|
||||
10. **If a control needs words to explain it, it is not a title-bar
|
||||
control.** Put it in the gear. The bar is for actions whose icon and
|
||||
tooltip say everything: refresh, clear, jump to latest, bigger text.
|
||||
11. **One control per action.** Whatever has a dedicated title-bar button is
|
||||
not offered again in the gear, and the reverse. Text size is A− / A+ up
|
||||
there, never a "Text size" field; a timestamps button in the bar means
|
||||
no timestamps switch below it.
|
||||
|
||||
## The shape of a module
|
||||
|
||||
A module is one folder dropped into `modules/`:
|
||||
@@ -177,14 +217,18 @@ the group name:
|
||||
|
||||
`help` on a field puts one line of guidance under its control — allowed
|
||||
placeholders, where a value comes from. Keep labels short and put the
|
||||
explanation there.
|
||||
explanation there. **This is for `configSchema` only**: a tile's gear
|
||||
carries no explanations at all (rule 5), so a setting that needs one is
|
||||
telling you it belongs in admin.
|
||||
|
||||
`group` and `help` work on `instanceSchema` fields too, and a manifest
|
||||
`group` works on `instanceSchema` fields too, and a manifest
|
||||
`instanceGroups` map plays the `configGroups` role for the tile's gear
|
||||
popover (`collapsed: true | false`, `columns`, `help`, `toggle`). That is how
|
||||
a module with a long show/hide checklist keeps the popover usable — see
|
||||
`pco-plan`. Display choices belong there, per tile; admin config is for
|
||||
connections. The popover itself is a small window beside the tile — sized to
|
||||
popover (`collapsed: true | false`, `columns`, `toggle` — but not `help`,
|
||||
per rule 5). That is how a module with a long show/hide checklist keeps the
|
||||
popover usable: five or more hide-toggles go in one **Show** group, folded
|
||||
by default, and fewer than five go in no group at all — see `pco-plan` for
|
||||
the long form and `slack` for the short one. Display choices belong there,
|
||||
per tile; admin config is for connections. The popover itself is a small window beside the tile — sized to
|
||||
its content, moved by the slim bar on top, closed by clicking anywhere else
|
||||
or pressing Escape — so a tile never has to be big enough to hold its own
|
||||
settings, and a change can be watched as it lands. Under everything you
|
||||
@@ -354,17 +398,19 @@ menu.setLabel('Channels (2/4) ▾'); // update the button text any time
|
||||
```
|
||||
|
||||
Everything you add is removed automatically when your instance stops, so a
|
||||
remount never duplicates controls. Keep them compact (icon buttons, short
|
||||
labels): the title bar is 32 px tall and shared with the tile's name. Draw
|
||||
icons as inline SVG rather than characters — a glyph such as ✕ or + takes
|
||||
its size from whatever font each browser picks; text-size buttons are a
|
||||
drawn − and +.
|
||||
remount never duplicates controls. **Icons only, no labels** (rule 9): the
|
||||
title bar is 32 px tall and shared with the tile's name, and the tooltip
|
||||
says what the icon means. Draw icons as inline SVG rather than characters —
|
||||
a glyph such as ✕ or + takes its size from whatever font each browser
|
||||
picks; text-size buttons are a drawn − and +. A small state indicator may
|
||||
ride alongside an icon (ProdCom's channel button carries a count while a
|
||||
filter is on); a name may not.
|
||||
|
||||
**One control per action.** Whatever has a dedicated header button is not
|
||||
offered again in the gear popover: text size is A− / A+ up here (persisted
|
||||
through `saveInstanceSettings({ textSize })`, read back with a sane
|
||||
fallback), never a "Text size" field in `instanceSchema`; a timestamps or
|
||||
newest-first toggle in the header means no such switch in the gear. The
|
||||
**One control per action** (rule 11). Whatever has a dedicated header button
|
||||
is not offered again in the gear popover: text size is − / + up here
|
||||
(persisted through `saveInstanceSettings({ textSize })`, read back with a
|
||||
sane fallback), never a "Text size" field in `instanceSchema`; a timestamps
|
||||
or newest-first toggle in the header means no such switch in the gear. The
|
||||
gear is for display choices that have no button of their own.
|
||||
|
||||
One more thing to know: the whole title bar folds away when the dashboard
|
||||
|
||||
+245
-49
@@ -1,15 +1,23 @@
|
||||
{
|
||||
"id": "pco-plan",
|
||||
"name": "PCO Plan",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Order of service from Planning Center Services",
|
||||
"provides": ["timers"],
|
||||
"provides": [
|
||||
"timers"
|
||||
],
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
"style": "style.css",
|
||||
"minSize": { "w": 3, "h": 3 },
|
||||
"defaultSize": { "w": 5, "h": 6 },
|
||||
"minSize": {
|
||||
"w": 3,
|
||||
"h": 3
|
||||
},
|
||||
"defaultSize": {
|
||||
"w": 5,
|
||||
"h": 6
|
||||
},
|
||||
"configGroups": {
|
||||
"Planning Center credentials": {
|
||||
"collapsed": "whenSet",
|
||||
@@ -48,7 +56,12 @@
|
||||
"type": "select",
|
||||
"label": "Plan",
|
||||
"default": "",
|
||||
"options": [{ "value": "", "label": "Automatic — today's plan, else the next upcoming" }],
|
||||
"options": [
|
||||
{
|
||||
"value": "",
|
||||
"label": "Automatic — today's plan, else the next upcoming"
|
||||
}
|
||||
],
|
||||
"optionsRoute": "/plans",
|
||||
"optionsDependsOn": "serviceTypeId"
|
||||
},
|
||||
@@ -82,8 +95,14 @@
|
||||
"group": "Service timing",
|
||||
"showWhen": "useDocumentTriggers",
|
||||
"options": [
|
||||
{ "value": "schedule", "label": "Scheduled service time" },
|
||||
{ "value": "document", "label": "ProPresenter document" }
|
||||
{
|
||||
"value": "schedule",
|
||||
"label": "Scheduled service time"
|
||||
},
|
||||
{
|
||||
"value": "document",
|
||||
"label": "ProPresenter document"
|
||||
}
|
||||
]
|
||||
},
|
||||
"serviceStartDocument": {
|
||||
@@ -124,37 +143,94 @@
|
||||
"Item notes": {
|
||||
"toggle": "showNotes"
|
||||
},
|
||||
"Show / hide": {
|
||||
"Show": {
|
||||
"collapsed": true,
|
||||
"columns": 3,
|
||||
"help": "Switch off anything this tile should not show."
|
||||
"columns": 2
|
||||
}
|
||||
},
|
||||
"instanceSchema": {
|
||||
"autoScroll": { "type": "boolean", "label": "Keep the current item in view", "default": true },
|
||||
"autoScroll": {
|
||||
"type": "boolean",
|
||||
"label": "Auto-scroll",
|
||||
"default": true
|
||||
},
|
||||
"headerTemplate": {
|
||||
"type": "template",
|
||||
"label": "Top bar text",
|
||||
"default": "{series} • {part} | {serviceType} • {date}",
|
||||
"group": "Top bar",
|
||||
"variables": [
|
||||
{ "value": "{series}", "label": "Series" },
|
||||
{ "value": "{part}", "label": "Part / plan title" },
|
||||
{ "value": "{serviceType}", "label": "Service type" },
|
||||
{ "value": "{date}", "label": "Date" },
|
||||
{ "value": "{shortDate}", "label": "Short date" },
|
||||
{ "value": "{time}", "label": "Service time" },
|
||||
{ "value": "{length}", "label": "Planned length" },
|
||||
{ "value": "{items}", "label": "Item count" }
|
||||
{
|
||||
"value": "{series}",
|
||||
"label": "Series"
|
||||
},
|
||||
{
|
||||
"value": "{part}",
|
||||
"label": "Part / plan title"
|
||||
},
|
||||
{
|
||||
"value": "{serviceType}",
|
||||
"label": "Service type"
|
||||
},
|
||||
{
|
||||
"value": "{date}",
|
||||
"label": "Date"
|
||||
},
|
||||
{
|
||||
"value": "{shortDate}",
|
||||
"label": "Short date"
|
||||
},
|
||||
{
|
||||
"value": "{time}",
|
||||
"label": "Service time"
|
||||
},
|
||||
{
|
||||
"value": "{length}",
|
||||
"label": "Planned length"
|
||||
},
|
||||
{
|
||||
"value": "{items}",
|
||||
"label": "Item count"
|
||||
}
|
||||
],
|
||||
"help": "Use • between items and | to start a second line."
|
||||
},
|
||||
"keyColor": { "type": "color", "label": "Song key", "default": "#4ea1ff", "group": "Colors" },
|
||||
"headerColor": { "type": "color", "label": "Section headers", "default": "#2ee59a", "group": "Colors" },
|
||||
"currentColor": { "type": "color", "label": "Current item", "default": "#2ee59a", "group": "Colors" },
|
||||
"headerGradient": { "type": "boolean", "label": "Fade header color to the right", "default": true, "group": "Colors" },
|
||||
"tintByType": { "type": "boolean", "label": "Tint items by type (song / media / other)", "default": false, "group": "Colors" },
|
||||
"showNotes": { "type": "switch", "label": "Item notes", "default": true, "group": "Item notes" },
|
||||
"keyColor": {
|
||||
"type": "color",
|
||||
"label": "Song key",
|
||||
"default": "#4ea1ff",
|
||||
"group": "Colors"
|
||||
},
|
||||
"headerColor": {
|
||||
"type": "color",
|
||||
"label": "Section headers",
|
||||
"default": "#2ee59a",
|
||||
"group": "Colors"
|
||||
},
|
||||
"currentColor": {
|
||||
"type": "color",
|
||||
"label": "Current item",
|
||||
"default": "#2ee59a",
|
||||
"group": "Colors"
|
||||
},
|
||||
"headerGradient": {
|
||||
"type": "boolean",
|
||||
"label": "Header fade",
|
||||
"default": true,
|
||||
"group": "Colors"
|
||||
},
|
||||
"tintByType": {
|
||||
"type": "boolean",
|
||||
"label": "Tint by type",
|
||||
"default": false,
|
||||
"group": "Colors"
|
||||
},
|
||||
"showNotes": {
|
||||
"type": "switch",
|
||||
"label": "Item notes",
|
||||
"default": true,
|
||||
"group": "Item notes"
|
||||
},
|
||||
"noteCategories": {
|
||||
"type": "multiselect",
|
||||
"label": "Categories",
|
||||
@@ -163,29 +239,149 @@
|
||||
"columns": 3,
|
||||
"allByDefault": true
|
||||
},
|
||||
"showTopBar": { "type": "boolean", "label": "Top bar", "default": true, "group": "Show / hide" },
|
||||
"showSeries": { "type": "boolean", "label": "Series name", "default": true, "group": "Show / hide" },
|
||||
"showPart": { "type": "boolean", "label": "Part # / plan title", "default": true, "group": "Show / hide" },
|
||||
"showServiceType": { "type": "boolean", "label": "Service type", "default": true, "group": "Show / hide" },
|
||||
"showDate": { "type": "boolean", "label": "Plan date", "default": true, "group": "Show / hide" },
|
||||
"showServiceTime": { "type": "boolean", "label": "Service time", "default": true, "group": "Show / hide" },
|
||||
"showTotalLength": { "type": "boolean", "label": "Planned total length", "default": true, "group": "Show / hide" },
|
||||
"showRunningClock": { "type": "boolean", "label": "Running clock (ahead / behind)", "default": true, "group": "Show / hide" },
|
||||
"showCountdown": { "type": "boolean", "label": "Countdown to service start", "default": true, "group": "Show / hide" },
|
||||
"showHeaders": { "type": "boolean", "label": "Section headers", "default": true, "group": "Show / hide" },
|
||||
"showItemNumbers": { "type": "boolean", "label": "Item numbers", "default": false, "group": "Show / hide" },
|
||||
"showStartTimes": { "type": "boolean", "label": "Scheduled start times", "default": true, "group": "Show / hide" },
|
||||
"showLength": { "type": "boolean", "label": "Item length", "default": true, "group": "Show / hide" },
|
||||
"showItemType": { "type": "boolean", "label": "Item type badge", "default": true, "group": "Show / hide" },
|
||||
"showKey": { "type": "boolean", "label": "Song key", "default": true, "group": "Show / hide" },
|
||||
"showArrangement": { "type": "boolean", "label": "Song arrangement", "default": false, "group": "Show / hide" },
|
||||
"showSongAuthor": { "type": "boolean", "label": "Song author / CCLI", "default": false, "group": "Show / hide" },
|
||||
"showDescription": { "type": "boolean", "label": "Item description", "default": true, "group": "Show / hide" },
|
||||
"showPreService": { "type": "boolean", "label": "Pre-service items", "default": true, "group": "Show / hide" },
|
||||
"showPostService": { "type": "boolean", "label": "Post-service items", "default": true, "group": "Show / hide" },
|
||||
"showNowIndicator": { "type": "boolean", "label": "Now indicator", "default": true, "group": "Show / hide" },
|
||||
"showItemRuntime": { "type": "boolean", "label": "Item runtime (elapsed vs planned)", "default": true, "group": "Show / hide" },
|
||||
"dimCompleted": { "type": "boolean", "label": "Dim completed items", "default": true, "group": "Show / hide" },
|
||||
"showLiveFooter": { "type": "boolean", "label": "ProPresenter match line", "default": true, "group": "Show / hide" }
|
||||
"showTopBar": {
|
||||
"type": "boolean",
|
||||
"label": "Top bar",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showSeries": {
|
||||
"type": "boolean",
|
||||
"label": "Series name",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showPart": {
|
||||
"type": "boolean",
|
||||
"label": "Part / title",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showServiceType": {
|
||||
"type": "boolean",
|
||||
"label": "Service type",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showDate": {
|
||||
"type": "boolean",
|
||||
"label": "Plan date",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showServiceTime": {
|
||||
"type": "boolean",
|
||||
"label": "Service time",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showTotalLength": {
|
||||
"type": "boolean",
|
||||
"label": "Total length",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showRunningClock": {
|
||||
"type": "boolean",
|
||||
"label": "Running clock",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showCountdown": {
|
||||
"type": "boolean",
|
||||
"label": "Countdown",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showHeaders": {
|
||||
"type": "boolean",
|
||||
"label": "Section headers",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showItemNumbers": {
|
||||
"type": "boolean",
|
||||
"label": "Item numbers",
|
||||
"default": false,
|
||||
"group": "Show"
|
||||
},
|
||||
"showStartTimes": {
|
||||
"type": "boolean",
|
||||
"label": "Start times",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showLength": {
|
||||
"type": "boolean",
|
||||
"label": "Item length",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showItemType": {
|
||||
"type": "boolean",
|
||||
"label": "Type badge",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showKey": {
|
||||
"type": "boolean",
|
||||
"label": "Song key",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showArrangement": {
|
||||
"type": "boolean",
|
||||
"label": "Arrangement",
|
||||
"default": false,
|
||||
"group": "Show"
|
||||
},
|
||||
"showSongAuthor": {
|
||||
"type": "boolean",
|
||||
"label": "Author / CCLI",
|
||||
"default": false,
|
||||
"group": "Show"
|
||||
},
|
||||
"showDescription": {
|
||||
"type": "boolean",
|
||||
"label": "Item description",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showPreService": {
|
||||
"type": "boolean",
|
||||
"label": "Pre-service",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showPostService": {
|
||||
"type": "boolean",
|
||||
"label": "Post-service",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showNowIndicator": {
|
||||
"type": "boolean",
|
||||
"label": "Now indicator",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showItemRuntime": {
|
||||
"type": "boolean",
|
||||
"label": "Item runtime",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"dimCompleted": {
|
||||
"type": "boolean",
|
||||
"label": "Dim completed",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showLiveFooter": {
|
||||
"type": "boolean",
|
||||
"label": "Match line",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "prodcom-transcript",
|
||||
"name": "ProdCom",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.2",
|
||||
"proddash": ">=1.1.0",
|
||||
"description": "Live ProdCom transcript",
|
||||
"permissions": [
|
||||
@@ -40,7 +40,7 @@
|
||||
"instanceSchema": {
|
||||
"showAvatars": {
|
||||
"type": "boolean",
|
||||
"label": "Show channel icons",
|
||||
"label": "Channel icons",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────── */
|
||||
|
||||
@@ -1,32 +1,100 @@
|
||||
{
|
||||
"id": "propresenter-now-next",
|
||||
"name": "ProPresenter",
|
||||
"version": "1.0.1",
|
||||
"proddash": ">=1.1.0",
|
||||
"version": "1.1.1",
|
||||
"proddash": ">=1.5.0",
|
||||
"description": "What is live in ProPresenter and what comes next",
|
||||
"permissions": [
|
||||
{ "kind": "local-network", "reason": "Reaching ProPresenter on this network" }
|
||||
{
|
||||
"kind": "local-network",
|
||||
"reason": "Reaching ProPresenter on this network"
|
||||
}
|
||||
],
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
"style": "style.css",
|
||||
"minSize": { "w": 3, "h": 3 },
|
||||
"defaultSize": { "w": 4, "h": 5 },
|
||||
"minSize": {
|
||||
"w": 3,
|
||||
"h": 3
|
||||
},
|
||||
"defaultSize": {
|
||||
"w": 4,
|
||||
"h": 5
|
||||
},
|
||||
"configSchema": {
|
||||
"propresenter": {
|
||||
"type": "endpoint",
|
||||
"label": "ProPresenter",
|
||||
"default": { "host": "", "port": 1025 }
|
||||
"default": {
|
||||
"host": "",
|
||||
"port": 1025
|
||||
}
|
||||
},
|
||||
"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": true,
|
||||
"columns": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: true, columns: 2 } },
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
routes() {
|
||||
return {
|
||||
'GET /state': (req, res) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
Per-tile choices: the gear popover's static switches (whole sources,
|
||||
headings, status lines, the clock's format, the state colours) and the
|
||||
"Timers ▾" header menu, which hides any single timer — keyed `source:id`
|
||||
timers header menu, which hides any single timer — keyed `source:id`
|
||||
and saved with the layout via saveInstanceSettings. The header can be
|
||||
hidden by the user, so the static switches stay the primary controls.
|
||||
A solo tile's gear carries only the settings its one card can use (the
|
||||
@@ -196,6 +196,10 @@ function defaultStatus(timer, value, tone) {
|
||||
|
||||
/* ── the tile ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/* The header menu's icon: a stopwatch, for the list of timers behind it.
|
||||
Title-bar controls carry no words (module guide, rule 9). */
|
||||
const TIMERS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 2h4"/><path d="M12 14V9"/><circle cx="12" cy="14" r="8"/></svg>';
|
||||
|
||||
export default function create({ root, moduleApi }) {
|
||||
let state = null;
|
||||
let feedOffline = false;
|
||||
@@ -227,7 +231,7 @@ export default function create({ root, moduleApi }) {
|
||||
}
|
||||
|
||||
/* Which picker entry this tile was added as (the module's tiles() list):
|
||||
'' or 'all' = every timer, selectable via the Timers ▾ menu;
|
||||
'' or 'all' = every timer, selectable via the timers menu;
|
||||
'solo:<source>:<id>' = exactly one card, no menu, no headings. The
|
||||
earlier entries 'timer:<uuid>' and 'ltc' map onto the new keys. */
|
||||
const variant = String(moduleApi.variant || '');
|
||||
@@ -283,8 +287,8 @@ export default function create({ root, moduleApi }) {
|
||||
|
||||
let menuEl = null; // the open menu's element (filled on each open)
|
||||
const itemsMenu = solo ? null : moduleApi.header.addMenu({
|
||||
label: 'Timers ▾',
|
||||
title: 'Choose timers',
|
||||
icon: TIMERS_SVG,
|
||||
title: 'Timers',
|
||||
build(menu) {
|
||||
// the shell hands over an emptied element on every open
|
||||
menu.classList.add('tm-menu');
|
||||
@@ -292,6 +296,12 @@ export default function create({ root, moduleApi }) {
|
||||
rebuildMenu(true);
|
||||
},
|
||||
});
|
||||
// The icon carries the name; a count rides alongside it while some timers
|
||||
// are hidden, so the bar says "filtered" without saying it in words.
|
||||
const itemsCountEl = document.createElement('span');
|
||||
itemsCountEl.className = 'tm-count';
|
||||
itemsCountEl.hidden = true;
|
||||
if (itemsMenu) itemsMenu.button.appendChild(itemsCountEl);
|
||||
|
||||
function menuRows() {
|
||||
const rows = [];
|
||||
@@ -319,7 +329,9 @@ export default function create({ root, moduleApi }) {
|
||||
if (!itemsMenu) return;
|
||||
const rows = menuRows();
|
||||
const visible = rows.filter((r) => !hidden.has(r.key)).length;
|
||||
itemsMenu.setLabel(visible === rows.length ? 'Timers ▾' : `Timers (${visible}/${rows.length}) ▾`);
|
||||
const filtering = visible !== rows.length;
|
||||
itemsCountEl.textContent = filtering ? `${visible}/${rows.length}` : '';
|
||||
itemsCountEl.hidden = !filtering;
|
||||
}
|
||||
|
||||
let menuSignature = '';
|
||||
@@ -779,7 +791,7 @@ export default function create({ root, moduleApi }) {
|
||||
let hint = '';
|
||||
const groups = visibleGroups();
|
||||
if (state && !state.sources.length) hint = 'Every source is switched off — turn one on in Admin.';
|
||||
else if (state && !groups.length) hint = 'Nothing selected — switch a source on in this tile’s settings, or pick timers in the Timers ▾ menu.';
|
||||
else if (state && !groups.length) hint = 'Nothing selected — switch a source on in this tile’s settings, or pick timers in the title bar’s timers menu.';
|
||||
hintEl.hidden = !hint;
|
||||
hintEl.textContent = hint;
|
||||
groupsEl.hidden = Boolean(hint);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "propresenter-timers",
|
||||
"name": "Timers",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Timers, Clocks, and LTC From configured Modules",
|
||||
"provides": [
|
||||
@@ -112,7 +112,7 @@
|
||||
},
|
||||
"instanceGroups": {
|
||||
"Show": {
|
||||
"collapsed": false,
|
||||
"collapsed": true,
|
||||
"columns": 2
|
||||
},
|
||||
"Clock": {
|
||||
@@ -121,8 +121,7 @@
|
||||
},
|
||||
"Colours": {
|
||||
"collapsed": true,
|
||||
"columns": 2,
|
||||
"help": "The time's colour per state. Left at the default, a colour follows the dashboard theme; one you change here wins over it."
|
||||
"columns": 2
|
||||
}
|
||||
},
|
||||
"instanceSchema": {
|
||||
|
||||
@@ -321,7 +321,7 @@
|
||||
|
||||
@keyframes tm-nosignal-blink { 50% { opacity: 0.35; } }
|
||||
|
||||
/* ── the "Timers ▾" menu (container styled/positioned by the shell) ── */
|
||||
/* ── the timers menu (container styled/positioned by the shell) ── */
|
||||
|
||||
[data-module="propresenter-timers"] .tm-menu-item {
|
||||
display: flex;
|
||||
@@ -383,3 +383,11 @@
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* the count beside the title-bar timers icon, while some are hidden */
|
||||
[data-module="propresenter-timers"] .tm-count {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
[data-module="propresenter-timers"] .tm-count[hidden] { display: none; }
|
||||
|
||||
+23
-12
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -512,3 +512,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
@@ -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);
|
||||
|
||||
+36
-2
@@ -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>
|
||||
@@ -65,6 +66,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>
|
||||
|
||||
@@ -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)
|
||||
@@ -1170,12 +1171,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 +1281,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 +1366,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 +1387,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 +1626,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 {
|
||||
|
||||
Reference in New Issue
Block a user