'use strict'; const DEFAULT_PIN = '1234'; const STORAGE_KEYS = { city: 'city_name', dept: 'dept_name', pin: 'portal_pin', anonymous: 'allow_anonymous', tmUrl: 'tm_model_url', gisGeoJsonUrl: 'gis_geojson_url', googleMapsJsKey: 'google_maps_js_api_key', gisWardProp: 'gis_ward_prop', gisDistrictProp: 'gis_district_prop', gisZoneProp: 'gis_zone_prop', weatherKey: 'weather_api_key', }; const DEFAULT_SETTINGS = { [STORAGE_KEYS.city]: '', [STORAGE_KEYS.dept]: '', [STORAGE_KEYS.pin]: DEFAULT_PIN, [STORAGE_KEYS.anonymous]: 'false', [STORAGE_KEYS.tmUrl]: 'https://teachablemachine.withgoogle.com/models/3HQTi9DMo/', [STORAGE_KEYS.gisGeoJsonUrl]: '', [STORAGE_KEYS.googleMapsJsKey]: '', [STORAGE_KEYS.gisWardProp]: 'ward', [STORAGE_KEYS.gisDistrictProp]: 'district', [STORAGE_KEYS.gisZoneProp]: 'zone', [STORAGE_KEYS.weatherKey]: '', }; const BOX_FIELD_IDS = { authMode: 'field-auth-mode', accessToken: 'field-access-token', refreshToken: 'field-refresh-token', clientId: 'field-client-id', clientSecret: 'field-client-secret', subjectType: 'field-subject-type', subjectId: 'field-subject-id', intakeFolderId: 'field-intake-folder-id', caseRootFolderId: 'field-case-root-folder-id', metadataScope: 'field-metadata-scope', metadataTemplateKey: 'field-metadata-template-key', }; function normalizeString(value) { return String(value ?? '').trim(); } function lsGet(key, fallback = '') { const value = localStorage.getItem(key); return value !== null ? value : fallback; } function lsSet(key, value) { localStorage.setItem(key, String(value)); } function getFirstElement(...ids) { for (const id of ids) { const el = document.getElementById(id); if (el) return el; } return null; } function showToast(message, type = 'success', duration = 3000) { const toast = document.getElementById('toast'); if (!toast) return; toast.textContent = message; toast.className = 'toast'; toast.classList.add(`toast--${type}`, 'toast--visible'); clearTimeout(toast._hideTimer); toast._hideTimer = setTimeout(() => { toast.classList.remove('toast--visible'); }, duration); } async function copyText(text) { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return; } const area = document.createElement('textarea'); area.value = text; area.setAttribute('readonly', 'true'); area.style.position = 'fixed'; area.style.opacity = '0'; document.body.appendChild(area); area.select(); document.execCommand('copy'); document.body.removeChild(area); } async function requestJson(path, options = {}) { const response = await fetch(path, { method: options.method || 'GET', headers: { 'Content-Type': 'application/json', ...(options.headers || {}), }, ...(options.body !== undefined ? { body: options.body } : {}), }); let payload = null; try { payload = await response.json(); } catch { payload = null; } if (!response.ok) { throw new Error(payload?.error || payload?.message || `Request failed with HTTP ${response.status}`); } return payload; } function getBoxFormData() { return { authMode: normalizeString(document.getElementById(BOX_FIELD_IDS.authMode)?.value || 'oauth_refresh'), accessToken: normalizeString(document.getElementById(BOX_FIELD_IDS.accessToken)?.value || ''), refreshToken: normalizeString(document.getElementById(BOX_FIELD_IDS.refreshToken)?.value || ''), clientId: normalizeString(document.getElementById(BOX_FIELD_IDS.clientId)?.value || ''), clientSecret: normalizeString(document.getElementById(BOX_FIELD_IDS.clientSecret)?.value || ''), subjectType: normalizeString(document.getElementById(BOX_FIELD_IDS.subjectType)?.value || 'enterprise'), subjectId: normalizeString(document.getElementById(BOX_FIELD_IDS.subjectId)?.value || ''), intakeFolderId: normalizeString(document.getElementById(BOX_FIELD_IDS.intakeFolderId)?.value || ''), caseRootFolderId: normalizeString(document.getElementById(BOX_FIELD_IDS.caseRootFolderId)?.value || ''), metadataScope: normalizeString(document.getElementById(BOX_FIELD_IDS.metadataScope)?.value || ''), metadataTemplateKey: normalizeString(document.getElementById(BOX_FIELD_IDS.metadataTemplateKey)?.value || ''), }; } function setSecretPlaceholder(inputId, preview, label) { const input = document.getElementById(inputId); if (!input) return; if (preview) { input.placeholder = `Saved on backend (${preview}). Enter a new ${label} to replace it.`; } else { input.placeholder = label; } } function setBoxSecretStatus(config = {}, status = {}) { const el = document.getElementById('box-secret-status'); if (!el) return; const notes = []; if (config.hasAccessToken) notes.push(`access token ${config.accessTokenPreview || '(saved)'}`); if (config.hasRefreshToken) notes.push(`refresh token ${config.refreshTokenPreview || '(saved)'}`); if (config.hasClientSecret) notes.push(`client secret ${config.clientSecretPreview || '(saved)'}`); if (!notes.length) { el.textContent = 'No Box credentials saved on the backend yet.'; return; } const mode = status.authMode ? `Mode: ${status.authMode}. ` : ''; el.textContent = `${mode}Backend secrets saved: ${notes.join(' | ')}`; } function applyBoxConfigToForm(config = {}, status = {}) { const fieldMap = { [BOX_FIELD_IDS.authMode]: config.authMode || status.authMode || 'oauth_refresh', [BOX_FIELD_IDS.clientId]: config.clientId || '', [BOX_FIELD_IDS.subjectType]: config.subjectType || 'enterprise', [BOX_FIELD_IDS.subjectId]: config.subjectId || '', [BOX_FIELD_IDS.intakeFolderId]: config.intakeFolderId || '', [BOX_FIELD_IDS.caseRootFolderId]: config.caseRootFolderId || '', [BOX_FIELD_IDS.metadataScope]: config.metadataScope || '', [BOX_FIELD_IDS.metadataTemplateKey]: config.metadataTemplateKey || '', }; for (const [id, value] of Object.entries(fieldMap)) { const el = document.getElementById(id); if (el) el.value = value; } // Secret fields stay blank after load so they remain server-side only. ['accessToken', 'refreshToken', 'clientSecret'].forEach((field) => { const el = document.getElementById(BOX_FIELD_IDS[field]); if (el) el.value = ''; }); setSecretPlaceholder(BOX_FIELD_IDS.accessToken, config.accessTokenPreview, 'access token'); setSecretPlaceholder(BOX_FIELD_IDS.refreshToken, config.refreshTokenPreview, 'refresh token'); setSecretPlaceholder(BOX_FIELD_IDS.clientSecret, config.clientSecretPreview, 'client secret'); setBoxSecretStatus(config, status); renderBoxWorkflowBlueprint(); } function normalizeBoxFieldHints() { const accessRow = document.querySelector(`label[for="${BOX_FIELD_IDS.accessToken}"]`)?.closest('.settings-row'); const intakeRow = document.querySelector(`label[for="${BOX_FIELD_IDS.intakeFolderId}"]`)?.closest('.settings-row'); const accessHint = accessRow?.querySelector('.field-hint'); const intakeHint = intakeRow?.querySelector('.field-hint'); if (accessHint) { accessHint.textContent = 'Temporary fallback only. Prefer OAuth refresh tokens or client credentials for anything beyond a quick test.'; } if (intakeHint) { intakeHint.textContent = 'Use the Box folder where the citizen portal should first upload the photo and metadata files.'; } } async function loadBoxConfig() { const payload = await requestJson('/api/box/config'); applyBoxConfigToForm(payload?.config || {}, payload?.status || {}); return payload; } async function saveBoxConfig(options = {}) { const payload = await requestJson('/api/box/config', { method: 'POST', body: JSON.stringify(getBoxFormData()), }); applyBoxConfigToForm(payload?.config || {}, payload?.status || {}); if (options.showSuccessToast) { showToast('Settings saved successfully', 'success'); } return payload; } async function resetBoxConfig() { const payload = await requestJson('/api/box/config', { method: 'POST', body: JSON.stringify({ reset: true }), }); applyBoxConfigToForm(payload?.config || {}, payload?.status || {}); return payload; } function renderBoxWorkflowBlueprint() { const usesEl = document.getElementById('box-workflow-uses'); const inputsEl = document.getElementById('box-workflow-inputs'); const stagesEl = document.getElementById('box-workflow-stages'); const metadataEl = document.getElementById('box-workflow-metadata'); if (!usesEl || !inputsEl || !stagesEl || !metadataEl) return; const folderId = normalizeString(document.getElementById(BOX_FIELD_IDS.intakeFolderId)?.value || ''); const blueprint = window.BoxWorkflow?.buildWorkflowBlueprint ? window.BoxWorkflow.buildWorkflowBlueprint({ folderId, folderName: 'Incoming Detections' }) : null; const folderNameEl = document.getElementById('box-workflow-folder-name'); const folderIdEl = document.getElementById('box-workflow-folder-id'); if (folderNameEl) folderNameEl.textContent = blueprint?.triggerFolderName || 'Incoming Detections'; if (folderIdEl) folderIdEl.textContent = blueprint?.triggerFolderId || 'Not set'; if (!blueprint) { usesEl.innerHTML = '
Workflow helper not loaded.
'; inputsEl.innerHTML = ''; stagesEl.innerHTML = ''; metadataEl.textContent = ''; return; } usesEl.innerHTML = blueprint.automationUses.map((item) => `
${item}
`).join(''); inputsEl.innerHTML = blueprint.inputFields.map((field) => `
${field.key} ${field.source}
${field.purpose}
`).join(''); stagesEl.innerHTML = blueprint.stages.map((stage) => `
${stage.title}
${stage.use}
Input: ${stage.input}
`).join(''); metadataEl.textContent = blueprint.metadataKeys.map((key) => `- ${key}`).join('\n'); } function initWorkflowBlueprint() { const copyInputsBtn = document.getElementById('btn-copy-workflow-inputs'); const copyBlueprintBtn = document.getElementById('btn-copy-workflow-blueprint'); if (copyInputsBtn) { copyInputsBtn.addEventListener('click', async () => { const fields = window.BoxWorkflow?.getWorkflowInputFields ? window.BoxWorkflow.getWorkflowInputFields() : []; try { await copyText(JSON.stringify(fields, null, 2)); showToast('Field schema copied.', 'success'); } catch (err) { showToast(`Copy failed: ${err.message}`, 'error'); } }); } if (copyBlueprintBtn) { copyBlueprintBtn.addEventListener('click', async () => { const folderId = normalizeString(document.getElementById(BOX_FIELD_IDS.intakeFolderId)?.value || ''); const blueprint = window.BoxWorkflow?.buildWorkflowBlueprint ? window.BoxWorkflow.buildWorkflowBlueprint({ folderId, folderName: 'Incoming Detections' }) : {}; try { await copyText(JSON.stringify(blueprint, null, 2)); showToast('Storage blueprint copied.', 'success'); } catch (err) { showToast(`Copy failed: ${err.message}`, 'error'); } }); } Object.values(BOX_FIELD_IDS).forEach((id) => { const el = document.getElementById(id); if (el) el.addEventListener('input', renderBoxWorkflowBlueprint); }); renderBoxWorkflowBlueprint(); } function initPinGate() { const overlay = document.getElementById('pin-overlay'); const pinInput = document.getElementById('pin-input'); const pinSubmit = document.getElementById('pin-submit'); const pinError = document.getElementById('pin-error'); const settingsContent = document.getElementById('settings-content'); if (!overlay) return; const storedPin = lsGet(STORAGE_KEYS.pin, DEFAULT_PIN); overlay.style.display = 'flex'; function attemptUnlock() { const entered = pinInput ? pinInput.value.trim() : ''; if (entered === storedPin) { overlay.style.display = 'none'; if (settingsContent) settingsContent.style.display = 'block'; if (pinError) pinError.textContent = ''; return; } if (pinError) pinError.textContent = 'Incorrect PIN. Please try again.'; if (pinInput) { pinInput.value = ''; pinInput.focus(); } } pinSubmit?.addEventListener('click', attemptUnlock); if (pinInput) { pinInput.addEventListener('keydown', (event) => { if (event.key === 'Enter') attemptUnlock(); }); pinInput.addEventListener('input', () => { pinInput.value = pinInput.value.replace(/\D/g, '').slice(0, 4); }); pinInput.focus(); } } function loadLocalSettings() { const fieldMap = { 'field-city': STORAGE_KEYS.city, 'field-dept': STORAGE_KEYS.dept, 'field-tm-url': STORAGE_KEYS.tmUrl, 'field-gis-geojson-url': STORAGE_KEYS.gisGeoJsonUrl, 'field-google-maps-key': STORAGE_KEYS.googleMapsJsKey, 'field-gis-ward-prop': STORAGE_KEYS.gisWardProp, 'field-gis-district-prop': STORAGE_KEYS.gisDistrictProp, 'field-gis-zone-prop': STORAGE_KEYS.gisZoneProp, 'field-weather-key': STORAGE_KEYS.weatherKey, }; for (const [id, key] of Object.entries(fieldMap)) { const el = document.getElementById(id); if (el) el.value = lsGet(key, DEFAULT_SETTINGS[key] ?? ''); } const anonEl = getFirstElement('field-anonymous', 'field-allow-anon'); if (anonEl) { const value = lsGet(STORAGE_KEYS.anonymous, DEFAULT_SETTINGS[STORAGE_KEYS.anonymous]); if (anonEl.type === 'checkbox') anonEl.checked = value === 'true'; else anonEl.value = value; } } function persistLocalSettings() { const fieldMap = { 'field-city': STORAGE_KEYS.city, 'field-dept': STORAGE_KEYS.dept, 'field-tm-url': STORAGE_KEYS.tmUrl, 'field-gis-geojson-url': STORAGE_KEYS.gisGeoJsonUrl, 'field-google-maps-key': STORAGE_KEYS.googleMapsJsKey, 'field-gis-ward-prop': STORAGE_KEYS.gisWardProp, 'field-gis-district-prop': STORAGE_KEYS.gisDistrictProp, 'field-gis-zone-prop': STORAGE_KEYS.gisZoneProp, 'field-weather-key': STORAGE_KEYS.weatherKey, }; for (const [id, key] of Object.entries(fieldMap)) { const el = document.getElementById(id); if (el) lsSet(key, el.value.trim()); } const anonEl = getFirstElement('field-anonymous', 'field-allow-anon'); if (anonEl) { const value = anonEl.type === 'checkbox' ? String(anonEl.checked) : anonEl.value; lsSet(STORAGE_KEYS.anonymous, value); } renderBoxWorkflowBlueprint(); } async function saveSettings() { persistLocalSettings(); await saveBoxConfig({ showSuccessToast: true }); } function initSettingsForm() { const form = document.getElementById('settings-form'); if (!form) return; form.addEventListener('submit', async (event) => { event.preventDefault(); try { await saveSettings(); } catch (err) { showToast(`Save failed: ${err.message}`, 'error', 5000); } }); } function initTestConnection() { const btn = document.getElementById('btn-test-connection'); const status = document.getElementById('connection-status'); if (!btn || !status) return; btn.addEventListener('click', async () => { status.style.display = 'block'; status.textContent = 'Saving Box repository settings and testing connection...'; status.className = 'connection-status connection-status--pending'; btn.disabled = true; try { await saveBoxConfig(); if (typeof testBoxConnection !== 'function') { throw new Error('testBoxConnection() is not available. Ensure box-upload.js is loaded.'); } const result = await testBoxConnection(); if (result?.ok) { const rootText = result.caseRootFolderName && result.caseRootFolderName !== result.folderName ? ` | Case root: ${result.caseRootFolderName}` : ''; status.textContent = `Connected successfully: ${result.folderName}${rootText}`; status.className = 'connection-status connection-status--success'; } else { status.textContent = result?.error || 'Connection failed.'; status.className = 'connection-status connection-status--error'; } } catch (err) { status.textContent = `Error: ${err.message}`; status.className = 'connection-status connection-status--error'; } finally { btn.disabled = false; } }); } function initPinChange() { const btn = document.getElementById('btn-change-pin'); const newPinEl = document.getElementById('field-new-pin'); const confirmEl = document.getElementById('field-confirm-pin'); if (!btn) return; btn.addEventListener('click', () => { const newPin = newPinEl ? newPinEl.value.trim() : ''; const confirmPin = confirmEl ? confirmEl.value.trim() : ''; if (!newPin) { showToast('Please enter a new PIN.', 'error'); return; } if (!/^\d{4}$/.test(newPin)) { showToast('PIN must be exactly 4 digits.', 'error'); return; } if (newPin !== confirmPin) { showToast('PINs do not match. Please try again.', 'error'); return; } lsSet(STORAGE_KEYS.pin, newPin); if (newPinEl) newPinEl.value = ''; if (confirmEl) confirmEl.value = ''; showToast('PIN updated successfully.', 'success'); }); [newPinEl, confirmEl].forEach((el) => { if (!el) return; el.addEventListener('input', () => { el.value = el.value.replace(/\D/g, '').slice(0, 4); }); }); } function buildExportPayload() { const local = {}; for (const key of Object.values(STORAGE_KEYS)) { local[key] = lsGet(key, DEFAULT_SETTINGS[key] ?? ''); } return { local, box: getBoxFormData(), }; } function initExportSettings() { const btn = document.getElementById('btn-export'); if (!btn) return; btn.addEventListener('click', () => { const json = JSON.stringify(buildExportPayload(), null, 2); const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = `pothole-portal-settings-${new Date().toISOString().slice(0, 10)}.json`; anchor.click(); URL.revokeObjectURL(url); showToast('Settings exported.', 'info'); }); } function initImportSettings() { const fileInput = document.getElementById('import-file'); const btn = document.getElementById('btn-import'); if (!fileInput) return; btn?.addEventListener('click', () => fileInput.click()); fileInput.addEventListener('change', () => { const file = fileInput.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = async (event) => { try { const parsed = JSON.parse(String(event.target?.result || '')); const local = parsed?.local && typeof parsed.local === 'object' ? parsed.local : parsed; const box = parsed?.box && typeof parsed.box === 'object' ? parsed.box : null; let imported = 0; for (const [key, value] of Object.entries(local || {})) { if (!Object.values(STORAGE_KEYS).includes(key)) continue; lsSet(key, value); imported += 1; } loadLocalSettings(); if (box) { const fieldMap = { authMode: BOX_FIELD_IDS.authMode, accessToken: BOX_FIELD_IDS.accessToken, refreshToken: BOX_FIELD_IDS.refreshToken, clientId: BOX_FIELD_IDS.clientId, clientSecret: BOX_FIELD_IDS.clientSecret, subjectType: BOX_FIELD_IDS.subjectType, subjectId: BOX_FIELD_IDS.subjectId, intakeFolderId: BOX_FIELD_IDS.intakeFolderId, caseRootFolderId: BOX_FIELD_IDS.caseRootFolderId, }; for (const [key, id] of Object.entries(fieldMap)) { const el = document.getElementById(id); if (el && box[key] != null) el.value = String(box[key]); } await saveBoxConfig(); } renderBoxWorkflowBlueprint(); showToast(`Settings imported (${imported} local key${imported !== 1 ? 's' : ''}).`, 'success'); } catch (err) { showToast(`Import failed: ${err.message}`, 'error', 5000); } finally { fileInput.value = ''; } }; reader.onerror = () => { showToast('Could not read file.', 'error'); fileInput.value = ''; }; reader.readAsText(file); }); } function initResetDefaults() { const btn = document.getElementById('btn-reset'); if (!btn) return; btn.addEventListener('click', async () => { const confirmed = window.confirm( 'Reset all settings to their defaults?\n\nThis will clear Box integration on the backend, city/department name, and reset the PIN to 1234.' ); if (!confirmed) return; for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) { lsSet(key, value); } loadLocalSettings(); try { await resetBoxConfig(); showToast('Settings reset to defaults.', 'info'); } catch (err) { showToast(`Reset failed: ${err.message}`, 'error', 5000); } }); } function initRevealButtons() { const revealPairs = [ ['btn-reveal-access-token', 'field-access-token'], ['btn-reveal-refresh-token', 'field-refresh-token'], ['btn-reveal-client-secret', 'field-client-secret'], ['btn-reveal-weather-key', 'field-weather-key'], ]; revealPairs.forEach(([buttonId, inputId]) => { const btn = document.getElementById(buttonId); const input = document.getElementById(inputId); if (!btn || !input) return; btn.addEventListener('click', () => { const isPassword = input.type === 'password'; input.type = isPassword ? 'text' : 'password'; btn.style.color = isPassword ? 'var(--color-blue)' : 'var(--color-muted)'; }); }); } async function initSettingsPage() { initPinGate(); const settingsContent = document.getElementById('settings-content'); if (settingsContent) settingsContent.style.display = 'none'; loadLocalSettings(); normalizeBoxFieldHints(); renderBoxWorkflowBlueprint(); initWorkflowBlueprint(); initSettingsForm(); initTestConnection(); initRevealButtons(); initPinChange(); initExportSettings(); initImportSettings(); initResetDefaults(); try { await loadBoxConfig(); } catch (err) { showToast(`Could not load Box settings: ${err.message}`, 'error', 5000); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { initSettingsPage().catch((err) => { console.error('[settings] init failed:', err); }); }); } else { initSettingsPage().catch((err) => { console.error('[settings] init failed:', err); }); }