diff --git a/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt b/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt
index f0e7cdf..92766bc 100644
--- a/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt
+++ b/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt
@@ -432,6 +432,18 @@ class WebSocketService : Service() {
handler.post { try { onPaired?.invoke(id, name) } catch (e: Throwable) { Log.e("WebSocketService", "onPaired cb: ${e.message}") } }
}
+ // A PIN set or rotated from the dashboard takes effect NOW, not at the next pairing.
+ // Without this an operator who rotated a leaked PIN would believe they had revoked
+ // access while the old one still opened the menu — worse than not offering it.
+ safeOn("device:settings-pin") { args ->
+ val data = args.getOrNull(0) as? org.json.JSONObject ?: return@safeOn
+ val pin = data.optString("settings_pin", "")
+ if (pin.isNotEmpty()) {
+ config.settingsPin = pin
+ Log.i("WebSocketService", "Settings PIN updated from dashboard") // never log the PIN
+ }
+ }
+
safeOn("device:playlist-update") { args ->
val data = args.firstOrNull() as? JSONObject ?: run {
Log.w("WebSocketService", "playlist-update with non-JSONObject payload: ${args.firstOrNull()}")
diff --git a/frontend/js/api.js b/frontend/js/api.js
index 9fb8b82..285fb93 100644
--- a/frontend/js/api.js
+++ b/frontend/js/api.js
@@ -41,6 +41,7 @@ export const api = {
// and the re-adopt action that applies a snapshot onto a newly-paired device.
getRemovedDevices: () => request('/devices/removed'),
reAdoptDevice: (id, fingerprint) => request(`/devices/${id}/re-adopt`, { method: 'POST', body: JSON.stringify({ fingerprint }) }),
+ setDevicePin: (id, body) => request(`/devices/${id}/settings-pin`, { method: 'POST', body: JSON.stringify(body) }),
// #109 PiP overlay: push/clear a floating overlay on a device or group. `id` may be a
// device id OR a group id (the server resolves + expands). Needs full scope (no-op for JWT).
diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js
index 22ecbe0..9e30533 100644
--- a/frontend/js/i18n/en.js
+++ b/frontend/js/i18n/en.js
@@ -491,6 +491,13 @@ export default {
'device.info.uptime': 'Uptime',
'device.info.android_version': 'Android Version',
'device.info.app_version': 'App Version',
+ 'device.pin.rotate': 'Rotate',
+ 'device.pin.set': 'Set…',
+ 'device.pin.rotate_confirm': 'Generate a new settings PIN for this display? The current PIN stops working immediately.',
+ 'device.pin.set_prompt': 'New 6-digit settings PIN',
+ 'device.pin.updated_live': 'PIN updated — the display has it now',
+ 'device.pin.updated_offline': 'PIN saved — the display will pick it up when it reconnects',
+ 'device.pin.failed': 'Could not update the PIN',
'device.info.settings_pin': 'Settings PIN',
'device.info.settings_pin_hint': 'On-device settings menu (2× Back)',
'device.info.screen_resolution': 'Screen Resolution',
diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js
index 8ee9b96..655636b 100644
--- a/frontend/js/views/device-detail.js
+++ b/frontend/js/views/device-detail.js
@@ -410,6 +410,10 @@ async function loadDevice(deviceId, activeTab = null) {
${t('device.info.settings_pin')}
${device.settings_pin || '--'}
${t('device.info.settings_pin_hint')}
+
+
+
+
` : ''}
@@ -967,6 +971,31 @@ async function showReAdoptModal(device) {
function setupActions(device) {
// #104 Preview button
+ // PIN rotate / set. The response says whether the panel took it LIVE: an offline display
+ // applies it on its next reconnect, and an operator rotating a leaked PIN needs to know
+ // which of those happened rather than assuming access is already revoked.
+ async function applyPin(body, confirmMsg) {
+ if (confirmMsg && !confirm(confirmMsg)) return;
+ try {
+ const r = await api.setDevicePin(device.id, body);
+ device.settings_pin = r.settings_pin;
+ const el = document.querySelector('#rotatePinBtn')?.closest('.info-card')?.querySelector('.info-card-value');
+ if (el) el.textContent = r.settings_pin;
+ showToast(r.delivered ? t('device.pin.updated_live') : t('device.pin.updated_offline'), 'success');
+ } catch (e) {
+ showToast(e?.message || t('device.pin.failed'), 'error');
+ }
+ }
+
+ document.getElementById('rotatePinBtn')?.addEventListener('click', () =>
+ applyPin({ rotate: true }, t('device.pin.rotate_confirm')));
+
+ document.getElementById('setPinBtn')?.addEventListener('click', () => {
+ const pin = prompt(t('device.pin.set_prompt'));
+ if (pin === null) return;
+ applyPin({ pin });
+ });
+
document.getElementById('devicePreviewBtn')?.addEventListener('click', () => showDevicePreview(device));
// Screenshot button
diff --git a/server/lib/settings-pin.js b/server/lib/settings-pin.js
new file mode 100644
index 0000000..0b3b495
--- /dev/null
+++ b/server/lib/settings-pin.js
@@ -0,0 +1,61 @@
+'use strict';
+
+const crypto = require('crypto');
+
+/*
+ * The PIN that gates the on-device settings menu (two taps of Back, then a PIN).
+ *
+ * It was generated once at pairing and never changed. On a fleet that makes it a shared secret
+ * with no expiry: anyone who watches it typed once — an installer, a contractor, someone filming
+ * a screen — keeps it for the life of the panel, and the only way to take it back was to unpair
+ * and re-pair every affected display. A customer asked whether it rotates. It did not.
+ *
+ * So: settable and rotatable from the dashboard, pushed to the panel live.
+ *
+ * Kept pure and separate because the VALIDATION is the security-relevant part and deserves tests
+ * that do not need a fleet: a PIN that can be set to "0000" or to an empty string is a gate that
+ * is not there.
+ */
+
+// Six digits, matching what the Android menu prompts for. Not configurable: a length that varies
+// per device is a support burden, and the keypad on a signage panel is often a remote control.
+const PIN_LENGTH = 6;
+
+/*
+ * Sequences and repeats are the PINs people actually pick, and the ones an onlooker guesses first.
+ * Rejected on explicit SET; never produced by the generator.
+ */
+const WEAK = new Set(['000000', '111111', '222222', '333333', '444444', '555555', '666666',
+ '777777', '888888', '999999', '123456', '654321', '012345', '543210']);
+
+/**
+ * Generate a fresh PIN.
+ *
+ * Uses crypto.randomInt, not Math.random: this is a credential. The previous generator used
+ * SQLite's random() at provisioning time, which is fine, but a rotation the operator asked for
+ * because a PIN leaked must not be predictable from any other value.
+ */
+function generatePin() {
+ for (let attempt = 0; attempt < 20; attempt++) {
+ const pin = String(crypto.randomInt(0, 1000000)).padStart(PIN_LENGTH, '0');
+ if (!WEAK.has(pin)) return pin;
+ }
+ // Exhausting 20 draws against a 14-entry blocklist is essentially impossible; if it somehow
+ // happens, a non-weak constant beats returning something weak or throwing during provisioning.
+ return '481920';
+}
+
+/**
+ * Validate an operator-supplied PIN.
+ * @returns {{ok: true, pin: string} | {ok: false, error: string}}
+ */
+function validatePin(input) {
+ if (input === null || input === undefined) return { ok: false, error: 'PIN is required' };
+ const pin = String(input).trim();
+ if (!/^[0-9]+$/.test(pin)) return { ok: false, error: 'PIN must be digits only' };
+ if (pin.length !== PIN_LENGTH) return { ok: false, error: `PIN must be ${PIN_LENGTH} digits` };
+ if (WEAK.has(pin)) return { ok: false, error: 'PIN is too easily guessed' };
+ return { ok: true, pin };
+}
+
+module.exports = { generatePin, validatePin, PIN_LENGTH, WEAK };
diff --git a/server/routes/devices.js b/server/routes/devices.js
index faf455a..88ca81e 100644
--- a/server/routes/devices.js
+++ b/server/routes/devices.js
@@ -316,6 +316,56 @@ router.put('/:id', (req, res) => {
// scoped by checkDeviceOwnership. OUTAGE PROCEDURE (dashboard down): set it by hand via
// direct SQLite — `UPDATE devices SET blocked = 1 WHERE id = '';` (0 to
// unblock) — same column, same next-register effect.
+/*
+ * Set or rotate the on-device settings PIN.
+ *
+ * Body: { pin: "123456" } to set explicitly, or { rotate: true } for a fresh random one.
+ *
+ * Was provisioned once at pairing and never changeable, which made it a shared secret with no
+ * expiry: anyone who watched it typed kept it for the life of the panel, and revoking it meant
+ * unpairing and re-pairing. Now it can be rotated the moment an installer leaves.
+ *
+ * Pushed to the panel immediately over its socket. Without that the new PIN would only take effect
+ * at the next pairing — so the operator would believe they had revoked access while the old PIN
+ * still opened the menu, which is worse than not offering the feature.
+ */
+router.post('/:id/settings-pin', (req, res) => {
+ const device = checkDeviceOwnership(req, res);
+ if (!device) return;
+
+ const pinLib = require('../lib/settings-pin');
+ let pin;
+ if (req.body && req.body.rotate) {
+ pin = pinLib.generatePin();
+ } else {
+ const v = pinLib.validatePin(req.body && req.body.pin);
+ if (!v.ok) return res.status(400).json({ error: v.error });
+ pin = v.pin;
+ }
+
+ db.prepare("UPDATE devices SET settings_pin = ?, updated_at = strftime('%s','now') WHERE id = ?")
+ .run(pin, req.params.id);
+
+ // Live push. A panel that is offline picks it up on its next pair/reconnect; the response says
+ // which happened so the dashboard can tell the operator whether it is in force yet.
+ let delivered = false;
+ try {
+ const io = req.app.get('io');
+ if (io) {
+ const ns = io.of('/device');
+ const room = ns.adapter.rooms.get(req.params.id);
+ if (room && room.size > 0) {
+ ns.to(req.params.id).emit('device:settings-pin', { settings_pin: pin });
+ delivered = true;
+ }
+ }
+ } catch (e) { console.warn(`[settings-pin] push failed: ${e.message}`); }
+
+ // Deliberately NOT logging the PIN itself.
+ console.log(`[settings-pin] device ${req.params.id} pin ${req.body && req.body.rotate ? 'rotated' : 'set'} by user ${req.user.id} (delivered=${delivered})`);
+ res.json({ success: true, settings_pin: pin, delivered });
+});
+
router.post('/:id/block', (req, res) => {
const device = checkDeviceOwnership(req, res);
if (!device) return;
diff --git a/server/test/settings-pin.test.js b/server/test/settings-pin.test.js
new file mode 100644
index 0000000..7072150
--- /dev/null
+++ b/server/test/settings-pin.test.js
@@ -0,0 +1,80 @@
+'use strict';
+
+// The on-device settings PIN was generated once at pairing and never changed.
+//
+// On a fleet that makes it a shared secret with no expiry: anyone who sees it typed once — an
+// installer, a contractor, someone filming a screen — keeps it for the life of the panel, and the
+// only way to take it back was to unpair and re-pair every display. A customer asked whether it
+// rotates, which is the right question to ask.
+//
+// The validation is the security-relevant half: a PIN that can be set to "0000" or left empty is a
+// gate that is not there. These tests exist so a future "let operators pick any PIN they like"
+// change has to argue with them first.
+
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const { generatePin, validatePin, PIN_LENGTH, WEAK } = require('../lib/settings-pin');
+
+test('a generated PIN is exactly the length the on-device prompt expects', () => {
+ for (let i = 0; i < 50; i++) {
+ const p = generatePin();
+ assert.equal(p.length, PIN_LENGTH);
+ assert.match(p, /^[0-9]+$/);
+ }
+});
+
+test('generated PINs keep their leading zeros', () => {
+ // Generated as a number and padded: dropping the pad would emit 5-digit PINs ~10% of the time,
+ // which the device prompt then refuses.
+ const seen = new Set();
+ for (let i = 0; i < 400; i++) seen.add(generatePin().length);
+ assert.deepEqual([...seen], [PIN_LENGTH]);
+});
+
+test('the generator never emits a PIN it would refuse on input', () => {
+ for (let i = 0; i < 500; i++) assert.ok(!WEAK.has(generatePin()));
+});
+
+test('generated PINs are not all the same value', () => {
+ const seen = new Set();
+ for (let i = 0; i < 50; i++) seen.add(generatePin());
+ assert.ok(seen.size > 40, `expected variety, got ${seen.size} distinct in 50`);
+});
+
+test('THE GATE: obvious PINs are refused on an explicit set', () => {
+ for (const weak of ['000000', '111111', '123456', '654321']) {
+ const r = validatePin(weak);
+ assert.equal(r.ok, false, `${weak} must be refused`);
+ }
+});
+
+test('wrong length is refused — a 4-digit PIN would not open the 6-digit prompt', () => {
+ assert.equal(validatePin('1234').ok, false);
+ assert.equal(validatePin('12345678').ok, false);
+});
+
+test('non-digits are refused, including the ones that look like digits', () => {
+ assert.equal(validatePin('12 456').ok, false);
+ assert.equal(validatePin('abcdef').ok, false);
+ assert.equal(validatePin('12.456').ok, false);
+ assert.equal(validatePin('-12345').ok, false);
+});
+
+test('empty and missing are refused rather than silently clearing the gate', () => {
+ assert.equal(validatePin('').ok, false);
+ assert.equal(validatePin(null).ok, false);
+ assert.equal(validatePin(undefined).ok, false);
+ assert.equal(validatePin(' ').ok, false);
+});
+
+test('a good PIN is accepted and returned trimmed', () => {
+ const r = validatePin(' 204815 ');
+ assert.equal(r.ok, true);
+ assert.equal(r.pin, '204815');
+});
+
+test('a numeric PIN is accepted — the API may hand us a number, not a string', () => {
+ const r = validatePin(204815);
+ assert.equal(r.ok, true);
+ assert.equal(r.pin, '204815');
+});