diff --git a/frontend/js/api.js b/frontend/js/api.js
index 990a616..e275d30 100644
--- a/frontend/js/api.js
+++ b/frontend/js/api.js
@@ -36,6 +36,10 @@ export const api = {
// no restart. Server enforces via the SNAT-safe identity chain (deviceSocket).
blockDevice: (id) => request(`/devices/${id}/block`, { method: 'POST' }),
unblockDevice: (id) => request(`/devices/${id}/unblock`, { method: 'POST' }),
+ // #150: fingerprint-keyed settings snapshots of previously-removed devices (this workspace),
+ // 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 }) }),
// #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 91eeff6..ad0be0a 100644
--- a/frontend/js/i18n/en.js
+++ b/frontend/js/i18n/en.js
@@ -301,6 +301,26 @@ export default {
'device.debug.toggle': 'Debug logging (live)',
'device.debug.hint': 'Streams player/zone logs from this device in real time. Turns off on its own when the device reconnects.',
'device.form.save_settings': 'Save Settings',
+ // #150 re-adopt: restore a removed device's saved settings onto this one
+ 'device.readopt.button': 'Restore from removed device…',
+ 'device.readopt.button_hint': "Apply a previously-removed device's saved settings onto this one (for a re-paired screen whose fingerprint changed)",
+ 'device.readopt.title': 'Restore settings from a removed device',
+ 'device.readopt.help': 'Pick a previously-removed device to copy its saved settings onto “{name}”. This overwrites the current settings.',
+ 'device.readopt.empty': 'No previously-removed devices in this workspace.',
+ 'device.readopt.unnamed': 'Unnamed device',
+ 'device.readopt.blocked': 'Blocked',
+ 'device.readopt.summary_orientation': 'Orientation',
+ 'device.readopt.summary_timezone': 'Timezone',
+ 'device.readopt.summary_playlist': 'Playlist',
+ 'device.readopt.playlist_none': 'none',
+ 'device.readopt.playlist_removed': '(playlist since deleted)',
+ 'device.readopt.last_seen': 'Last seen',
+ 'device.readopt.removed': 'Removed',
+ 'device.readopt.apply': 'Apply',
+ 'device.readopt.confirm': 'Apply saved settings from “{source}” onto “{target}”? This overwrites the current settings.',
+ 'device.readopt.confirm_blocked': 'This device was BLOCKED. Applying it will re-block the target device — it will immediately refuse to connect and the screen will go dark. Continue?',
+ 'device.readopt.success': 'Settings restored ({orientation})',
+ 'device.readopt.error': 'Could not restore settings',
// Control buttons
'device.ctl.reboot_device': 'Reboot Device',
'device.ctl.screen_off': 'Screen Off',
diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js
index cc5ef1c..4e2088a 100644
--- a/frontend/js/views/device-detail.js
+++ b/frontend/js/views/device-detail.js
@@ -370,6 +370,7 @@ async function loadDevice(deviceId, activeTab = null) {
+
@@ -644,7 +645,100 @@ function showDevicePreview(device) {
});
}
-async function setupActions(device) {
+async // #150 re-adopt fallback: browse the workspace's previously-removed device snapshots and
+// apply one onto THIS (usually blank, just-re-paired) device. Primary restore is the silent
+// fingerprint-match on re-pair; this is for factory-reset / new-hardware / changed-fingerprint.
+const ORIENT_LABELS = {
+ 'landscape': 'device.form.orientation.landscape',
+ 'portrait': 'device.form.orientation.portrait',
+ 'landscape-flipped': 'device.form.orientation.landscape_flipped',
+ 'portrait-flipped': 'device.form.orientation.portrait_flipped',
+};
+const orientLabel = (o) => t(ORIENT_LABELS[o] || ORIENT_LABELS.landscape);
+const fmtTs = (ts) => (ts ? new Date(ts * 1000).toLocaleString() : '—');
+
+async function showReAdoptModal(device) {
+ let snapshots, playlists;
+ try {
+ [snapshots, playlists] = await Promise.all([
+ api.getRemovedDevices(),
+ api.getPlaylists().catch(() => []), // best-effort: only used to label the restored playlist
+ ]);
+ } catch (err) { showToast(err.message || t('device.readopt.error'), 'error'); return; }
+
+ const plById = new Map((playlists || []).map(p => [p.id, p.name]));
+ const playlistLabel = (s) => !s.playlist_id
+ ? t('device.readopt.playlist_none')
+ : (plById.get(s.playlist_id) || t('device.readopt.playlist_removed'));
+
+ const rowsHtml = (snapshots || []).map((s, i) => {
+ const blockedBadge = s.blocked
+ ? `${t('device.readopt.blocked')}`
+ : '';
+ // Fingerprint is the key but not an operator-facing identifier — truncated + on-hover only.
+ const fpShort = (s.fingerprint || '').slice(0, 8);
+ return `
+
`;
+ document.body.appendChild(overlay);
+ const close = () => overlay.remove();
+ overlay.querySelector('#readoptClose').onclick = close;
+ overlay.onclick = (e) => { if (e.target === overlay) close(); };
+
+ overlay.querySelectorAll('.readopt-apply').forEach((btn) => {
+ btn.addEventListener('click', async () => {
+ const s = snapshots[parseInt(btn.dataset.i, 10)];
+ let msg = t('device.readopt.confirm', { source: s.device_name || t('device.readopt.unnamed'), target: device.name || '' });
+ if (s.blocked) msg += '\n\n⚠ ' + t('device.readopt.confirm_blocked'); // explicit: target will go dark
+ if (!confirm(msg)) return;
+ btn.disabled = true;
+ try {
+ await api.reAdoptDevice(device.id, s.fingerprint);
+ showToast(t('device.readopt.success', { orientation: orientLabel(s.orientation) }), 'success');
+ close();
+ loadDevice(device.id); // refresh so restored orientation/name/etc show immediately
+ } catch (err) {
+ // Server messages: 404 no snapshot, 403 cross-workspace, 400 bad request.
+ showToast(err.message || t('device.readopt.error'), 'error');
+ btn.disabled = false;
+ }
+ });
+ });
+}
+
+function setupActions(device) {
// #104 Preview button
document.getElementById('devicePreviewBtn')?.addEventListener('click', () => showDevicePreview(device));
@@ -706,6 +800,10 @@ async function setupActions(device) {
}
});
+ // #150 re-adopt: apply a previously-removed device's saved settings onto THIS device (the
+ // fallback for when the fingerprint changed and automatic restore couldn't fire).
+ document.getElementById('reAdoptBtn')?.addEventListener('click', () => showReAdoptModal(device));
+
// Publish / Discard from device detail
const devicePublishBtn = document.getElementById('devicePublishBtn');
if (devicePublishBtn && device.playlist_id) {
diff --git a/server/db/database.js b/server/db/database.js
index 7f0ffaa..f7c2d50 100644
--- a/server/db/database.js
+++ b/server/db/database.js
@@ -251,6 +251,26 @@ const migrations = [
// register gate on its next reconnect (no restart). Hand-settable by direct SQLite:
// UPDATE devices SET blocked = 1 WHERE id = ''; (0 to unblock)
"ALTER TABLE devices ADD COLUMN blocked INTEGER NOT NULL DEFAULT 0",
+ // #150: fingerprint-keyed device settings that SURVIVE device-row deletion, so a
+ // delete + re-pair (MDM churn) restores orientation/name/playlist/etc for the SAME
+ // physical device instead of silently resetting to defaults. NO FK to devices -> it
+ // survives the delete cascade. workspace_id/device_name/last_seen/removed_at form the
+ // human-readable index the operator "re-adopt" flow browses when the fingerprint changed.
+ `CREATE TABLE IF NOT EXISTS device_settings (
+ fingerprint TEXT PRIMARY KEY,
+ workspace_id TEXT,
+ device_name TEXT,
+ orientation TEXT,
+ timezone TEXT,
+ notes TEXT,
+ default_content_id TEXT,
+ layout_id TEXT,
+ playlist_id TEXT,
+ blocked INTEGER,
+ team_id TEXT,
+ last_seen INTEGER,
+ removed_at INTEGER
+ )`,
];
// Apply each ALTER idempotently. A "duplicate column name" / "already exists"
// error means the column is already present (expected on a migrated DB) - benign.
diff --git a/server/lib/device-settings.js b/server/lib/device-settings.js
new file mode 100644
index 0000000..134fae8
--- /dev/null
+++ b/server/lib/device-settings.js
@@ -0,0 +1,124 @@
+'use strict';
+// #150 — fingerprint-keyed device settings that survive device-row deletion.
+//
+// Delete + re-pair (Bold's MDM churn) mints a BRAND-NEW device row whose INSERT omits every
+// per-device setting, so orientation/name/playlist/etc silently reset to defaults. This module
+// snapshots a device's settings (keyed by its durable hardware/canvas fingerprint) at DELETE
+// time, and re-applies them on the next re-pair for the SAME fingerprint — automatically and
+// silently. The same apply path also backs the operator "re-adopt" action for the case where
+// the fingerprint changed (factory reset / new hardware), see routes/devices.js.
+//
+// The table has NO FK to devices, so device deletion can't cascade it away. On workspace/user/
+// org deletion the rows ARE purged (purgeWorkspaces) so settings can never bleed across tenants.
+const { db } = require('../db/database');
+
+const ORIENTATIONS = new Set(['landscape', 'portrait', 'landscape-flipped', 'portrait-flipped']);
+const validOrientation = (o) => (ORIENTATIONS.has(o) ? o : 'landscape');
+
+// The devices-row columns we preserve/restore (approved scope: orientation, name, timezone,
+// notes, default_content_id, layout_id, playlist_id, blocked, team_id). sort_order is out of
+// scope by decision; wall membership (video_wall_devices grid geometry) is a deferred follow-up.
+const _selDevice = db.prepare(
+ `SELECT name, orientation, timezone, notes, default_content_id, layout_id, playlist_id,
+ blocked, team_id, workspace_id, last_heartbeat
+ FROM devices WHERE id = ?`
+);
+const _fpForDevice = db.prepare(
+ 'SELECT fingerprint FROM device_fingerprints WHERE device_id = ? ORDER BY last_seen DESC LIMIT 1'
+);
+const _upsert = db.prepare(`
+ INSERT INTO device_settings
+ (fingerprint, workspace_id, device_name, orientation, timezone, notes, default_content_id,
+ layout_id, playlist_id, blocked, team_id, last_seen, removed_at)
+ VALUES
+ (@fingerprint, @workspace_id, @device_name, @orientation, @timezone, @notes, @default_content_id,
+ @layout_id, @playlist_id, @blocked, @team_id, @last_seen, @removed_at)
+ ON CONFLICT(fingerprint) DO UPDATE SET
+ workspace_id=excluded.workspace_id, device_name=excluded.device_name, orientation=excluded.orientation,
+ timezone=excluded.timezone, notes=excluded.notes, default_content_id=excluded.default_content_id,
+ layout_id=excluded.layout_id, playlist_id=excluded.playlist_id, blocked=excluded.blocked,
+ team_id=excluded.team_id, last_seen=excluded.last_seen, removed_at=excluded.removed_at
+`);
+
+// Snapshot a device's current settings keyed by its fingerprint, called BEFORE the row is
+// deleted. No-op (returns null) if the device has no fingerprint link yet — a never-fully-
+// provisioned device has no durable key and no user settings worth preserving. UPSERT keyed on
+// fingerprint => repeated delete/re-pair cycles update one row, never duplicate.
+function snapshot(deviceId, now = Math.floor(Date.now() / 1000)) {
+ const d = _selDevice.get(deviceId);
+ if (!d) return null;
+ const fpRow = _fpForDevice.get(deviceId);
+ if (!fpRow || !fpRow.fingerprint) return null;
+ _upsert.run({
+ fingerprint: fpRow.fingerprint,
+ workspace_id: d.workspace_id || null,
+ device_name: d.name || null,
+ orientation: validOrientation(d.orientation),
+ timezone: d.timezone || null,
+ notes: d.notes || null,
+ default_content_id: d.default_content_id || null,
+ layout_id: d.layout_id || null,
+ playlist_id: d.playlist_id || null,
+ blocked: d.blocked ? 1 : 0,
+ team_id: d.team_id || null,
+ last_seen: d.last_heartbeat || now,
+ removed_at: now,
+ });
+ return fpRow.fingerprint;
+}
+
+// Apply saved settings for `fingerprint` onto `deviceId`. Backs BOTH the automatic re-pair
+// restore and the operator re-adopt. Orientation is enum-validated (invalid stored value ->
+// landscape). FK settings (playlist/layout/default_content) are existence-guarded so a
+// since-deleted target is skipped rather than written as a dangling id. Returns the applied
+// snapshot row, or null if there was nothing to apply.
+function applyToDevice(deviceId, fingerprint) {
+ const s = db.prepare('SELECT * FROM device_settings WHERE fingerprint = ?').get(fingerprint);
+ if (!s) return null;
+ const sets = [], vals = [];
+ const put = (col, val) => { sets.push(`${col} = ?`); vals.push(val); };
+
+ put('orientation', validOrientation(s.orientation));
+ if (s.device_name != null) put('name', s.device_name);
+ if (s.timezone != null) put('timezone', s.timezone);
+ if (s.notes != null) put('notes', s.notes);
+ put('blocked', s.blocked ? 1 : 0); // security: a blocked device stays blocked across re-pair
+ if (s.team_id != null) put('team_id', s.team_id);
+ // FK-existence guards — only restore if the referenced row still exists.
+ if (s.playlist_id && db.prepare('SELECT 1 FROM playlists WHERE id = ?').get(s.playlist_id)) put('playlist_id', s.playlist_id);
+ if (s.layout_id && db.prepare('SELECT 1 FROM layouts WHERE id = ?').get(s.layout_id)) put('layout_id', s.layout_id);
+ if (s.default_content_id && db.prepare('SELECT 1 FROM content WHERE id = ?').get(s.default_content_id)) put('default_content_id', s.default_content_id);
+ // TODO #150 follow-up: wall membership (video_wall_devices grid geometry) is NOT restored —
+ // it lives in a separate CASCADE-deleted table with grid positions. Deferred; note in release.
+
+ vals.push(deviceId);
+ db.prepare(`UPDATE devices SET ${sets.join(', ')}, updated_at = strftime('%s','now') WHERE id = ?`).run(...vals);
+ return s;
+}
+
+// The "previously removed devices" browser — snapshots for the given workspace(s).
+function listRemoved(workspaceIds) {
+ const ids = (Array.isArray(workspaceIds) ? workspaceIds : [workspaceIds]).filter(Boolean);
+ if (!ids.length) return [];
+ const ph = ids.map(() => '?').join(',');
+ return db.prepare(
+ `SELECT fingerprint, workspace_id, device_name, orientation, playlist_id, layout_id,
+ timezone, blocked, last_seen, removed_at
+ FROM device_settings WHERE workspace_id IN (${ph}) ORDER BY removed_at DESC`
+ ).all(...ids);
+}
+
+function getByFingerprint(fingerprint) {
+ return db.prepare('SELECT * FROM device_settings WHERE fingerprint = ?').get(fingerprint);
+}
+
+// Purge snapshots for whole workspaces (workspace/user/org deletion). Runs on the caller's
+// db handle (user-deletion runs inside a transaction). Prevents cross-tenant settings bleed.
+function purgeWorkspaces(dbConn, workspaceIds) {
+ const ids = (workspaceIds || []).filter(Boolean);
+ if (!ids.length) return 0;
+ const ph = ids.map(() => '?').join(',');
+ return (dbConn || db).prepare(`DELETE FROM device_settings WHERE workspace_id IN (${ph})`).run(...ids).changes;
+}
+
+module.exports = { snapshot, applyToDevice, listRemoved, getByFingerprint, purgeWorkspaces, validOrientation, ORIENTATIONS };
diff --git a/server/lib/user-deletion.js b/server/lib/user-deletion.js
index 83c82bb..1c4e105 100644
--- a/server/lib/user-deletion.js
+++ b/server/lib/user-deletion.js
@@ -76,6 +76,11 @@ function purgeWorkspaces(db, wsIds, have) {
}
}
for (const t of WORKSPACE_SCOPED) if (have.has(t)) db.prepare(`DELETE FROM ${t} WHERE workspace_id IN (${wph})`).run(...wsIds);
+ // #150: purge fingerprint-keyed device settings for these workspaces. device_settings has
+ // NO FK to devices (so it survives device deletion by design), which means it is NOT caught
+ // by this cascade either — purge it explicitly so saved settings can never bleed onto a
+ // different tenant if the same physical device (same fingerprint) later pairs elsewhere.
+ if (have.has('device_settings')) db.prepare(`DELETE FROM device_settings WHERE workspace_id IN (${wph})`).run(...wsIds);
if (have.has('activity_log')) db.prepare(`UPDATE activity_log SET workspace_id = NULL WHERE workspace_id IN (${wph})`).run(...wsIds);
db.prepare(`DELETE FROM workspaces WHERE id IN (${wph})`).run(...wsIds); // cascades workspace_members/invites
}
diff --git a/server/routes/devices.js b/server/routes/devices.js
index 8945a15..9a4d393 100644
--- a/server/routes/devices.js
+++ b/server/routes/devices.js
@@ -7,6 +7,7 @@ const { PLATFORM_ROLES, ELEVATED_ROLES, isPlatformStaff } = require('../middlewa
const { accessContext } = require('../lib/tenancy');
const { stripDeviceSecrets } = require('../lib/device-sanitize');
const { layoutZones, orphanCountsByDevice } = require('../lib/zone-validate');
+const deviceSettings = require('../lib/device-settings'); // #150 delete+re-pair settings preservation
// List devices in the caller's current workspace.
// Phase 2.2a: filter by workspace_id instead of user_id. The caller's current
@@ -85,6 +86,14 @@ router.get('/unassigned', (req, res) => {
res.json(devices);
});
+// #150: "previously removed devices" — fingerprint-keyed settings snapshots for the caller's
+// current workspace, for the operator re-adopt flow (changed-fingerprint case). MUST be
+// declared before GET '/:id' or Express matches 'removed' as an :id. Read-scoped to workspace.
+router.get('/removed', (req, res) => {
+ if (!req.workspaceId) return res.json([]);
+ res.json(deviceSettings.listRemoved(req.workspaceId));
+});
+
// Get single device with telemetry history
router.get('/:id', (req, res) => {
const device = db.prepare('SELECT d.*, u.email as owner_email, u.name as owner_name FROM devices d LEFT JOIN users u ON d.user_id = u.id WHERE d.id = ?').get(req.params.id);
@@ -202,6 +211,11 @@ router.put('/:id', (req, res) => {
if (!device) return;
const { name, notes, timezone, orientation, default_content_id, layout_id } = req.body;
+ // #150: validate orientation against the known enum (previously accepted any string, which
+ // let a bad value reach the player -> unknown rotation falls back to landscape silently).
+ if (orientation !== undefined && !deviceSettings.ORIENTATIONS.has(orientation)) {
+ return res.status(400).json({ error: `Invalid orientation. Allowed: ${[...deviceSettings.ORIENTATIONS].join(', ')}` });
+ }
// Whitelist allowed fields to prevent SQL injection via field names
const ALLOWED_FIELDS = ['name', 'notes', 'timezone', 'orientation', 'default_content_id'];
const updates = [];
@@ -253,11 +267,36 @@ router.post('/:id/unblock', (req, res) => {
res.json({ success: true, id: req.params.id, blocked: false });
});
+// #150: re-adopt — apply a removed device's saved settings onto device :id. For the case the
+// fingerprint did NOT auto-match (factory reset / new hardware), so the automatic re-pair
+// restore couldn't fire. Auth: caller can write device :id (checkDeviceOwnership) AND the
+// snapshot belongs to the SAME workspace as the device (no cross-tenant apply).
+router.post('/:id/re-adopt', (req, res) => {
+ const device = checkDeviceOwnership(req, res);
+ if (!device) return;
+ const { fingerprint } = req.body || {};
+ if (!fingerprint) return res.status(400).json({ error: 'fingerprint required' });
+ const snap = deviceSettings.getByFingerprint(fingerprint);
+ if (!snap) return res.status(404).json({ error: 'No saved settings for that fingerprint' });
+ if (snap.workspace_id !== device.workspace_id) {
+ return res.status(403).json({ error: 'Saved settings belong to a different workspace' });
+ }
+ deviceSettings.applyToDevice(req.params.id, fingerprint);
+ const updated = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
+ console.log(`[#150] re-adopted settings (fp ${fingerprint.slice(0, 8)}…) onto device ${req.params.id} by user ${req.user.id}`);
+ res.json(stripDeviceSecrets(updated));
+});
+
// Delete device
router.delete('/:id', (req, res) => {
const device = checkDeviceOwnership(req, res);
if (!device) return;
+ // #150: snapshot this device's settings (keyed by its fingerprint) BEFORE the row dies,
+ // so a re-pair of the SAME physical device restores orientation/name/playlist/etc instead
+ // of silently resetting to defaults. No-op if the device has no fingerprint link yet.
+ try { deviceSettings.snapshot(req.params.id); } catch (e) { console.warn(`[#150] settings snapshot failed for ${req.params.id}: ${e.message}`); }
+
// Clean up related data (playlist is NOT deleted — may be shared with other devices)
db.prepare('DELETE FROM schedules WHERE device_id = ?').run(req.params.id);
db.prepare('DELETE FROM screenshots WHERE device_id = ?').run(req.params.id);
diff --git a/server/ws/deviceSocket.js b/server/ws/deviceSocket.js
index f7733dc..8332c86 100644
--- a/server/ws/deviceSocket.js
+++ b/server/ws/deviceSocket.js
@@ -15,6 +15,7 @@ const sessionSettle = require('../lib/session-settle'); // #148 patch2: evicti
const { resolveIdentity } = require('../lib/device-identity');
const logCoalescer = require('../lib/log-coalescer');
const loopLag = require('../services/loop-lag');
+const deviceSettings = require('../lib/device-settings'); // #150 delete+re-pair settings restore
// Debounce window for marking a device offline on socket disconnect. Brief
// flap (Wi-Fi blip, Engine.IO ping miss, server-side eviction-then-reconnect)
@@ -628,6 +629,20 @@ module.exports = function setupDeviceSocket(io) {
currentDeviceId = id;
authenticated = true;
+ // #150: relink the fingerprint to the NEW device row (the fingerprint block above
+ // leaves device_id NULL on a post-delete re-pair) so the settings key is reliable,
+ // then restore any settings this physical device had at its last deletion —
+ // orientation/name/playlist/etc come back automatically instead of resetting. Runs
+ // BEFORE the dashboard:device-added emit below so that emit carries restored values.
+ if (fingerprint) {
+ try {
+ db.prepare("INSERT INTO device_fingerprints (fingerprint, device_id, last_seen) VALUES (?, ?, strftime('%s','now')) ON CONFLICT(fingerprint) DO UPDATE SET device_id = excluded.device_id, last_seen = excluded.last_seen")
+ .run(fingerprint, id);
+ const restored = deviceSettings.applyToDevice(id, fingerprint);
+ if (restored) console.log(`[#150] restored saved settings for re-paired device ${id} (fp ${fingerprint.slice(0, 8)}…)`);
+ } catch (e) { console.warn(`[#150] settings restore failed for ${id}: ${e.message}`); }
+ }
+
heartbeat.registerConnection(id, socket.id);
socket.join(id);
socket.emit('device:registered', { device_id: id, device_token: newToken, status: 'provisioning' });