'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);
});
}