Add admin config screen (issue #14)
Deploy to Dev / Deploy & Smoke Test (push) Successful in 23s

Lets the photographer edit config/site.json from /dashboard instead
of SSHing in and hand-editing the file. Two tiers:
- Quick-edit form for the fields actually touched day to day (site
  title/tagline/hero, photographer bio, contact/social, Venmo
  username, session pricing, theme colors)
- Raw JSON textarea for everything else (portfolio categories,
  locations, session types, Immich settings) - the form's fields are
  a subset of this, not a separate source of truth

Backend: GET/PUT /api/admin/config, gated by the existing requireAdmin
middleware (OIDC admin session or legacy ADMIN_SECRET). PUT validates
the body is an object with the required top-level sections, writes
atomically (temp file + rename), and keeps one prior version as
site.json.bak before overwriting.

Required a docker-compose.yml change: the api service had no volume
mount for config/ at all before this (only portfolio/nginx did, and
read-only) - added a read-write mount so the API can actually write
the file the live site reads.
This commit is contained in:
2026-07-20 06:37:20 +00:00
parent 943fd9495f
commit 4ac34ca943
4 changed files with 269 additions and 0 deletions
+37
View File
@@ -12,6 +12,8 @@ const PORT = process.env.PORT || 3001;
const CONTRACTS_DIR = path.join(__dirname, 'contracts');
const SIGNED_DIR = path.join(__dirname, 'signed-contracts');
const CONFIG_PATH = path.join(__dirname, 'config', 'site.json');
const CONFIG_BACKUP_PATH = path.join(__dirname, 'config', 'site.json.bak');
app.use(cors({ origin: process.env.CORS_ORIGIN || '*', credentials: true }));
app.use(express.json({ limit: '10mb' }));
@@ -374,4 +376,39 @@ app.get('/api/admin/payments/export', requireAdmin, (req, res) => {
res.send(csv);
});
// Site config (issue #14) — lets the photographer edit config/site.json from
// the browser instead of SSHing in. Requires the api service to have a
// read-write mount for ./config (see docker-compose.yml); the portfolio/nginx
// service's own mount of the same host directory is read-only.
app.get('/api/admin/config', requireAdmin, (req, res) => {
try {
res.type('application/json').send(fs.readFileSync(CONFIG_PATH, 'utf8'));
} catch (e) {
res.status(500).json({ error: `Could not read config: ${e.message}` });
}
});
app.put('/api/admin/config', requireAdmin, (req, res) => {
const next = req.body;
if (!next || typeof next !== 'object' || Array.isArray(next)) {
return res.status(400).json({ error: 'Body must be a JSON object' });
}
for (const key of ['site', 'photographer', 'contact']) {
if (!next[key] || typeof next[key] !== 'object') {
return res.status(400).json({ error: `Missing or invalid required section: "${key}"` });
}
}
try {
// Keep one prior version so a bad save can be undone by hand - not a
// full history, just a safety net against fat-fingering the editor.
if (fs.existsSync(CONFIG_PATH)) fs.copyFileSync(CONFIG_PATH, CONFIG_BACKUP_PATH);
const tmpPath = `${CONFIG_PATH}.tmp`;
fs.writeFileSync(tmpPath, JSON.stringify(next, null, 2) + '\n');
fs.renameSync(tmpPath, CONFIG_PATH);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: `Could not write config: ${e.message}` });
}
});
app.listen(PORT, () => console.log(`API listening on port ${PORT}`));
+4
View File
@@ -39,6 +39,10 @@ services:
- ./api/data:/app/data
- ./api/signed-contracts:/app/signed-contracts
- ./api/contracts:/app/contracts:ro
# Read-write: the admin config screen (issue #14) edits site.json here.
# Shares the same host directory the portfolio/nginx service mounts
# read-only, so writes show up on the live site (subject to its 5-min cache).
- ./config:/app/config
networks:
- web
healthcheck:
+207
View File
@@ -226,6 +226,24 @@ tr.expanded td { background: var(--bg); }
font-family: inherit; font-size: .87rem; resize: vertical; min-height: 60px; background: var(--bg);
}
.notes-input:focus { outline: none; border-color: var(--primary); }
/* ── Config panel ─────────────────────────────────── */
.cfg-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 1rem; }
.cfg-field label { display: block; font-size: .78rem; color: var(--text-muted); margin-bottom: .3rem; }
.cfg-field input {
width: 100%; padding: .55rem .75rem; border: 1px solid var(--border); border-radius: 4px;
font-family: inherit; font-size: .87rem; color: var(--text); background: var(--bg); outline: none;
}
.cfg-field input:focus { border-color: var(--primary); }
.cfg-field input[type="color"] { padding: .25rem; height: 2.4rem; }
.cfg-json {
width: 100%; min-height: 360px; padding: .75rem; border: 1px solid var(--border); border-radius: 4px;
font-family: 'SF Mono', Consolas, monospace; font-size: .8rem; resize: vertical; background: var(--bg);
}
.cfg-json:focus { outline: none; border-color: var(--primary); }
.cfg-status { font-size: .82rem; margin-top: .6rem; display: none; }
.cfg-status.ok { color: var(--green); display: block; }
.cfg-status.err { color: var(--red); display: block; }
</style>
</head>
<body>
@@ -258,6 +276,7 @@ tr.expanded td { background: var(--bg); }
<a onclick="showPanel('calendar')" id="nav-calendar">&#9632; Calendar</a>
<a onclick="showPanel('bookings')" id="nav-bookings">&#9632; Bookings</a>
<a onclick="showPanel('payments')" id="nav-payments">&#9632; Payments <span class="badge" id="pending-badge" style="display:none"></span></a>
<a onclick="showPanel('config')" id="nav-config">&#9632; Config</a>
</nav>
<main>
<!-- Overview panel -->
@@ -333,6 +352,83 @@ tr.expanded td { background: var(--bg); }
</div>
<div id="payments-table-wrap"></div>
</div>
<!-- Config panel -->
<div class="panel" id="panel-config">
<h2 class="panel-title">Config</h2>
<div class="card" id="cfg-loading"><div class="loading">Loading…</div></div>
<div id="cfg-content" style="display:none">
<div class="card">
<h3>Site</h3>
<div class="cfg-grid">
<div class="cfg-field"><label>Title</label><input id="cfg-site-title"></div>
<div class="cfg-field"><label>Tagline</label><input id="cfg-site-tagline"></div>
<div class="cfg-field"><label>Hero image URL</label><input id="cfg-site-heroImage" placeholder="/images/hero.jpg"></div>
</div>
</div>
<div class="card">
<h3>Photographer</h3>
<div class="cfg-grid">
<div class="cfg-field"><label>Name</label><input id="cfg-photographer-name"></div>
<div class="cfg-field"><label>Profile image URL</label><input id="cfg-photographer-image"></div>
<div class="cfg-field" style="grid-column:1/-1"><label>Bio</label><input id="cfg-photographer-bio"></div>
</div>
</div>
<div class="card">
<h3>Contact &amp; Social</h3>
<div class="cfg-grid">
<div class="cfg-field"><label>Email</label><input id="cfg-contact-email" type="email"></div>
<div class="cfg-field"><label>Phone</label><input id="cfg-contact-phone"></div>
<div class="cfg-field"><label>Instagram</label><input id="cfg-social-instagram"></div>
<div class="cfg-field"><label>Facebook</label><input id="cfg-social-facebook"></div>
</div>
</div>
<div class="card">
<h3>Booking</h3>
<div class="cfg-grid">
<div class="cfg-field"><label>Venmo username</label><input id="cfg-booking-venmoUsername" placeholder="LisiLouPhoto"></div>
<div class="cfg-field"><label>Mini session price ($)</label><input id="cfg-booking-pricing-mini" type="number" min="0"></div>
<div class="cfg-field"><label>Full session price ($)</label><input id="cfg-booking-pricing-full" type="number" min="0"></div>
</div>
</div>
<div class="card">
<h3>Theme</h3>
<div class="cfg-grid">
<div class="cfg-field"><label>Primary color</label><input id="cfg-theme-primaryColor" type="color"></div>
<div class="cfg-field"><label>Accent color</label><input id="cfg-theme-accentColor" type="color"></div>
<div class="cfg-field"><label>Background color</label><input id="cfg-theme-backgroundColor" type="color"></div>
<div class="cfg-field"><label>Text color</label><input id="cfg-theme-textColor" type="color"></div>
</div>
</div>
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;">
<button class="btn btn-primary" onclick="saveConfigQuick()">Save Changes</button>
<span id="cfg-quick-status" class="cfg-status"></span>
</div>
</div>
<div class="card">
<h3>Advanced (full config JSON)</h3>
<p style="color:var(--text-muted);font-size:.82rem;margin-bottom:.75rem;">
Everything above lives here too — portfolio categories, locations, session types,
Immich settings, and anything else not covered by the form. Edit with care: this
replaces the entire file. The previous version is kept as a one-step backup.
</p>
<textarea class="cfg-json" id="cfg-raw-json" spellcheck="false"></textarea>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:.75rem;">
<button class="btn btn-outline" onclick="saveConfigRaw()">Save Full Config</button>
<span id="cfg-raw-status" class="cfg-status"></span>
</div>
</div>
</div>
</div>
</main>
</div>
</div>
@@ -476,6 +572,117 @@ function showPanel(name) {
if (name === 'calendar') renderCalendar();
if (name === 'bookings') loadBookings();
if (name === 'payments') loadPaymentsPanel();
if (name === 'config') loadConfigPanel();
}
// ── Config panel (issue #14) ─────────────────────────────────────────────────
let configDraft = null;
function cfgGet(obj, path) {
return path.split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj);
}
function cfgSet(obj, path, value) {
const keys = path.split('.');
const last = keys.pop();
const target = keys.reduce((o, k) => (o[k] ??= {}), obj);
target[last] = value;
}
const CFG_QUICK_FIELDS = [
['cfg-site-title', 'site.title'],
['cfg-site-tagline', 'site.tagline'],
['cfg-site-heroImage', 'site.heroImage'],
['cfg-photographer-name', 'photographer.name'],
['cfg-photographer-image', 'photographer.image'],
['cfg-photographer-bio', 'photographer.bio'],
['cfg-contact-email', 'contact.email'],
['cfg-contact-phone', 'contact.phone'],
['cfg-social-instagram', 'social.instagram'],
['cfg-social-facebook', 'social.facebook'],
['cfg-booking-venmoUsername', 'booking.venmoUsername'],
];
const CFG_QUICK_NUMBER_FIELDS = [
['cfg-booking-pricing-mini', 'booking.pricing.mini'],
['cfg-booking-pricing-full', 'booking.pricing.full'],
];
const CFG_QUICK_COLOR_FIELDS = [
['cfg-theme-primaryColor', 'theme.primaryColor'],
['cfg-theme-accentColor', 'theme.accentColor'],
['cfg-theme-backgroundColor', 'theme.backgroundColor'],
['cfg-theme-textColor', 'theme.textColor'],
];
async function loadConfigPanel() {
document.getElementById('cfg-loading').style.display = 'block';
document.getElementById('cfg-content').style.display = 'none';
try {
const r = await api('GET', '/api/admin/config');
if (!r.ok) throw new Error(r.data?.error || 'Failed to load config');
configDraft = r.data;
for (const [id, path] of CFG_QUICK_FIELDS) {
document.getElementById(id).value = cfgGet(configDraft, path) || '';
}
for (const [id, path] of CFG_QUICK_NUMBER_FIELDS) {
document.getElementById(id).value = cfgGet(configDraft, path) ?? '';
}
for (const [id, path] of CFG_QUICK_COLOR_FIELDS) {
document.getElementById(id).value = cfgGet(configDraft, path) || '#000000';
}
document.getElementById('cfg-raw-json').value = JSON.stringify(configDraft, null, 2);
document.getElementById('cfg-loading').style.display = 'none';
document.getElementById('cfg-content').style.display = 'block';
} catch (e) {
document.getElementById('cfg-loading').innerHTML =
`<div class="cfg-status err" style="display:block">Could not load config: ${e.message}</div>`;
}
}
function cfgShowStatus(id, ok, msg) {
const el = document.getElementById(id);
el.className = 'cfg-status ' + (ok ? 'ok' : 'err');
el.textContent = msg;
el.style.display = 'block';
if (ok) setTimeout(() => { el.style.display = 'none'; }, 3000);
}
async function saveConfigQuick() {
if (!configDraft) return;
for (const [id, path] of CFG_QUICK_FIELDS) {
cfgSet(configDraft, path, document.getElementById(id).value);
}
for (const [id, path] of CFG_QUICK_NUMBER_FIELDS) {
const v = document.getElementById(id).value;
cfgSet(configDraft, path, v === '' ? null : Number(v));
}
for (const [id, path] of CFG_QUICK_COLOR_FIELDS) {
cfgSet(configDraft, path, document.getElementById(id).value);
}
document.getElementById('cfg-raw-json').value = JSON.stringify(configDraft, null, 2);
await cfgPersist(configDraft, 'cfg-quick-status');
}
async function saveConfigRaw() {
let parsed;
try {
parsed = JSON.parse(document.getElementById('cfg-raw-json').value);
} catch (e) {
return cfgShowStatus('cfg-raw-status', false, `Invalid JSON: ${e.message}`);
}
configDraft = parsed;
await cfgPersist(parsed, 'cfg-raw-status');
await loadConfigPanel(); // re-sync the quick-edit fields from the saved result
}
async function cfgPersist(payload, statusId) {
try {
const r = await api('PUT', '/api/admin/config', payload);
if (!r.ok) throw new Error(r.data?.error || `Save failed (${r.status})`);
cfgShowStatus(statusId, true, 'Saved. Live site picks this up within ~5 minutes (nginx cache).');
} catch (e) {
cfgShowStatus(statusId, false, e.message);
}
}
// ── Overview ──────────────────────────────────────────────────────────────────
+21
View File
@@ -142,4 +142,25 @@ test.describe('Admin dashboard — panels', () => {
expect(download.suggestedFilename()).toMatch(/lisilou-bookings.*\.csv/);
});
test('Config panel loads current site.json into the form', async ({ page }) => {
await page.locator('#nav-config').click();
await expect(page.locator('#panel-config')).toBeVisible();
await expect(page.locator('#cfg-content')).toBeVisible({ timeout: 8_000 });
// Title field should be populated from real config, not left blank
await expect(page.locator('#cfg-site-title')).not.toHaveValue('');
// Raw JSON textarea should contain valid, non-trivial JSON
const raw = await page.locator('#cfg-raw-json').inputValue();
expect(() => JSON.parse(raw)).not.toThrow();
expect(JSON.parse(raw)).toHaveProperty('site');
});
test('Config panel round-trips a no-op save without error', async ({ page }) => {
// Saves the exact same content back - proves the PUT path works without
// mutating the real config the live site depends on.
await page.locator('#nav-config').click();
await expect(page.locator('#cfg-content')).toBeVisible({ timeout: 8_000 });
await page.locator('button:has-text("Save Full Config")').click();
await expect(page.locator('#cfg-raw-status')).toHaveClass(/ok/, { timeout: 8_000 });
});
});