const form = document.getElementById('device-settings-form'); const saveButton = document.getElementById('device-settings-save'); const message = document.getElementById('device-settings-message'); const deviceList = document.getElementById('device-list'); const deviceCount = document.getElementById('device-count'); const createDeviceButton = document.getElementById('create-device-button'); const newDeviceName = document.getElementById('new-device-name'); const rotateKeyButton = document.getElementById('rotate-key-button'); const requestSyncButton = document.getElementById('request-sync-button'); const deleteDeviceButton = document.getElementById('delete-device-button'); const copyCredentialsButton = document.getElementById('copy-credentials-button'); const selectedSummary = { name: document.getElementById('selected-device-name'), portalDefault: document.getElementById('selected-device-default'), lastSeen: document.getElementById('selected-device-last-seen'), lastSync: document.getElementById('selected-device-last-sync'), }; const credentialFields = { serverUrl: document.getElementById('credential-server-url'), deviceName: document.getElementById('credential-device-name'), apiKey: document.getElementById('credential-api-key'), }; const fields = { owner_name: document.getElementById('owner_name'), car_model: document.getElementById('car_model'), device_name: document.getElementById('device_name'), avatar_path: document.getElementById('avatar_path'), odometer_baseline: document.getElementById('odometer_baseline'), }; let devices = []; let selectedDeviceId = null; let initialState = null; let latestApiKey = ''; function showMessage(text, kind) { message.className = `alert alert-${kind}`; message.textContent = text; message.hidden = false; } function hideMessage() { message.hidden = true; } function formatDate(isoString) { if (!isoString) return '—'; return new Date(isoString).toLocaleString(); } function currentState() { return { owner_name: fields.owner_name.value.trim(), car_model: fields.car_model.value.trim(), device_name: fields.device_name.value.trim(), avatar_path: fields.avatar_path.value.trim(), odometer_baseline: Number(fields.odometer_baseline.value) || 0, }; } function setFormEnabled(enabled) { for (const field of Object.values(fields)) { field.disabled = !enabled; } rotateKeyButton.disabled = !enabled; requestSyncButton.disabled = !enabled; deleteDeviceButton.disabled = !enabled; updateDirtyState(); } function updateDirtyState() { if (!initialState || selectedDeviceId === null) { saveButton.disabled = true; return; } saveButton.disabled = JSON.stringify(currentState()) === JSON.stringify(initialState); } function renderDeviceList() { deviceCount.textContent = String(devices.length); if (!devices.length) { deviceList.innerHTML = '
No devices registered yet.
'; selectedDeviceId = null; setFormEnabled(false); return; } deviceList.innerHTML = devices.map(device => ` `).join(''); deviceList.querySelectorAll('[data-device-id]').forEach(button => { button.addEventListener('click', () => selectDevice(Number(button.dataset.deviceId))); }); } function populateDevice(device, config) { selectedSummary.name.textContent = device.name; selectedSummary.portalDefault.textContent = device.portal_default ? 'Yes' : 'No'; selectedSummary.lastSeen.textContent = formatDate(device.last_seen_at); selectedSummary.lastSync.textContent = device.last_config_sync_at ? `${formatDate(device.last_config_sync_at)} (v${device.last_config_sync_version ?? '—'})` : '—'; fields.owner_name.value = config.owner_name || ''; fields.car_model.value = config.car_model || ''; fields.device_name.value = config.device_name || ''; fields.avatar_path.value = config.avatar_path || ''; fields.odometer_baseline.value = (config.odometer_baseline || 0).toFixed(1); initialState = currentState(); credentialFields.deviceName.value = device.name; renderDeviceList(); setFormEnabled(true); } async function loadDevices(preferredDeviceId = null) { const response = await fetch('/api/v1/portal/devices'); const payload = await response.json(); if (!response.ok) { throw new Error(payload.error || 'Could not load devices.'); } devices = payload.devices || []; credentialFields.serverUrl.value = window.location.origin; const nextDevice = preferredDeviceId && devices.some(device => device.id === preferredDeviceId) ? preferredDeviceId : selectedDeviceId && devices.some(device => device.id === selectedDeviceId) ? selectedDeviceId : devices[0]?.id ?? null; selectedDeviceId = nextDevice; renderDeviceList(); if (selectedDeviceId !== null) { await loadSelectedDevice(); } } async function loadSelectedDevice() { if (selectedDeviceId === null) { return; } const response = await fetch(`/api/v1/portal/devices/${selectedDeviceId}/config`); const payload = await response.json(); if (!response.ok) { throw new Error(payload.error || 'Could not load device config.'); } const device = devices.find(candidate => candidate.id === selectedDeviceId) || payload.device; populateDevice(device, payload.config); } async function selectDevice(deviceId) { hideMessage(); selectedDeviceId = deviceId; latestApiKey = ''; credentialFields.apiKey.value = ''; copyCredentialsButton.disabled = true; renderDeviceList(); await loadSelectedDevice(); } async function createDevice() { hideMessage(); createDeviceButton.disabled = true; try { const response = await fetch('/api/v1/portal/devices', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: newDeviceName.value.trim() }), }); const payload = await response.json(); if (!response.ok) { throw new Error(payload.fields?.name || payload.error || 'Could not create device.'); } latestApiKey = payload.api_key; credentialFields.apiKey.value = payload.api_key; copyCredentialsButton.disabled = false; newDeviceName.value = ''; await loadDevices(payload.device.id); showMessage('Device created. Copy the credentials now; the raw API key will not be shown again.', 'success'); } catch (error) { showMessage(error.message, 'danger'); } finally { createDeviceButton.disabled = false; } } async function rotateKey() { if (selectedDeviceId === null) return; if (!window.confirm('Rotate the API key for this device? The old Pi config will stop authenticating until updated.')) return; hideMessage(); rotateKeyButton.disabled = true; try { const response = await fetch(`/api/v1/portal/devices/${selectedDeviceId}/rotate-key`, { method: 'POST', }); const payload = await response.json(); if (!response.ok) { throw new Error(payload.error || 'Could not rotate API key.'); } latestApiKey = payload.api_key; credentialFields.apiKey.value = payload.api_key; copyCredentialsButton.disabled = false; await loadDevices(selectedDeviceId); showMessage('API key rotated. Copy the new credentials to the Pi now.', 'warning'); } catch (error) { showMessage(error.message, 'danger'); } finally { rotateKeyButton.disabled = false; } } async function requestSync() { if (selectedDeviceId === null) return; hideMessage(); requestSyncButton.disabled = true; try { const response = await fetch(`/api/v1/portal/devices/${selectedDeviceId}/request-sync`, { method: 'POST', }); const payload = await response.json(); if (!response.ok) { throw new Error(payload.error || 'Could not request sync.'); } await loadDevices(selectedDeviceId); showMessage(payload.message, 'info'); } catch (error) { showMessage(error.message, 'danger'); } finally { requestSyncButton.disabled = false; } } async function deleteDevice() { if (selectedDeviceId === null) return; const device = devices.find(candidate => candidate.id === selectedDeviceId); if (!device) return; if (!window.confirm(`Delete device "${device.name}" and all of its server-side trips, tracking logs, and dashcam files?`)) { return; } hideMessage(); deleteDeviceButton.disabled = true; try { const response = await fetch(`/api/v1/portal/devices/${selectedDeviceId}`, { method: 'DELETE', }); const payload = await response.json(); if (!response.ok) { throw new Error(payload.error || 'Could not delete device.'); } latestApiKey = ''; credentialFields.apiKey.value = ''; copyCredentialsButton.disabled = true; selectedDeviceId = null; await loadDevices(); showMessage('Device deleted from the server.', 'success'); } catch (error) { showMessage(error.message, 'danger'); } finally { deleteDeviceButton.disabled = selectedDeviceId === null; } } async function saveConfig(event) { event.preventDefault(); if (selectedDeviceId === null) return; hideMessage(); saveButton.disabled = true; try { const response = await fetch(`/api/v1/portal/devices/${selectedDeviceId}/config`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(currentState()), }); const payload = await response.json(); if (!response.ok) { throw new Error(payload.error || 'Could not save device config.'); } await loadDevices(selectedDeviceId); showMessage('Device settings saved.', 'success'); } catch (error) { showMessage(error.message, 'danger'); updateDirtyState(); } } async function copyCredentials() { if (!latestApiKey || selectedDeviceId === null) return; const text = [ `YARIS_SERVER_URL=${window.location.origin}`, `YARIS_DEVICE_NAME=${credentialFields.deviceName.value}`, `YARIS_API_KEY=${latestApiKey}`, ].join('\n'); await navigator.clipboard.writeText(text); showMessage('Credentials copied. Paste them into the Pi Settings page.', 'success'); } for (const field of Object.values(fields)) { field.addEventListener('input', () => { hideMessage(); updateDirtyState(); }); } form.addEventListener('submit', saveConfig); createDeviceButton.addEventListener('click', createDevice); rotateKeyButton.addEventListener('click', rotateKey); requestSyncButton.addEventListener('click', requestSync); deleteDeviceButton.addEventListener('click', deleteDevice); copyCredentialsButton.addEventListener('click', () => { copyCredentials().catch(error => showMessage(`Could not copy credentials: ${error.message}`, 'danger')); }); loadDevices().catch(error => showMessage(error.message, 'danger'));