Compare commits

...

5 Commits

Author SHA1 Message Date
jhodgkin 945ebdec57 Fix config panel field name: photographer.profileImage, not .image
Deploy to Dev / Deploy & Smoke Test (push) Successful in 23s
Deploy to Prod / Deploy & Smoke Test (prod) (push) Successful in 23s
Caught by testing against the real dev config after the initial
commit - the quick-edit form was writing to a nonexistent key instead
of the one the rest of the app actually reads.
2026-07-20 06:38:34 +00:00
jhodgkin 4ac34ca943 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.
2026-07-20 06:37:20 +00:00
jhodgkin 943fd9495f Update docs: Authentik OIDC setup is complete and verified
Deploy to Dev / Deploy & Smoke Test (push) Successful in 23s
Reflects tonight's work: scripts/authentik-setup.sh actually run for
the first time (three bugs found/fixed), OIDC login and client
self-registration verified end-to-end on both dev and prod. Closes
the loop on issues #10/#12/#17.
2026-07-20 06:33:28 +00:00
jhodgkin 1e1eab3d41 Fix authentik-setup.sh: managed__iexact filter silently ignored
Deploy to Dev / Deploy & Smoke Test (push) Successful in 22s
This is the actual production-breaking bug: the scope-mapping lookup
used ?managed__iexact=..., which this Authentik version doesn't
support as a filter - it silently returns the whole unfiltered list
instead of erroring, so d['results'][0]['pk'] always grabbed the same
first item (the "Proxy outpost" ak_proxy scope, not openid/profile/
email). Every run attached that single wrong scope to the provider,
which meant every real login would fail at the userinfo step with a
403 ("Scope mismatch", token had zero of the required openid scope).

Fixed by filtering on scope_name (the field that actually works) and
added a hard failure if a lookup ever comes up empty, instead of
silently proceeding with a broken scope list.

Manually verified against the live dev-lisilou.jerodrigged.com OIDC
login: full authorization-code+PKCE round trip against a disposable
test user, confirmed valid session cookie with correct admin group
claim. Also PATCHed the already-created provider's property_mappings
directly on auth.jerodrigged.com so dev doesn't need to wait for a
re-run to get the fix.
2026-07-20 06:26:33 +00:00
jhodgkin 2f5607df82 Fix authentik-setup.sh: broken jget stdin handling, wrong stage path
Deploy to Dev / Deploy & Smoke Test (push) Successful in 24s
Two bugs found running this against the live Authentik instance for
the first time:

- jget's <<<"$1" here-string always overrode stdin, so `api ... | jget
  - "expr"` never actually read curl's piped output - it fed the
  literal string "-" to json.load() instead, and the abandoned pipe
  made curl fail with "Failed writing body". Fixed by branching on
  $1 == "-" to read the real stdin in that case.
- The prompt stage lives at /stages/prompt/stages/, not /stages/prompt/
  (that path is prompt *fields*). Wrong path 404'd.

Also switched `python` -> `python3` throughout for portability.

Verified idempotent end-to-end against auth.jerodrigged.com: provider,
application, lisilou-admin group, and the full enrollment flow
(prompt -> write -> login stages, bound and set as the brand's
enrollment flow) all created successfully, second run reports
everything as already existing.
2026-07-20 06:15:41 +00:00
7 changed files with 311 additions and 25 deletions
+4 -2
View File
@@ -195,9 +195,11 @@ local dev (`docker compose up -d` alone still builds from source as before).
| 7 | Confirm & submit | — | Done — summary + POST /api/bookings | | 7 | Confirm & submit | — | Done — summary + POST /api/bookings |
Also done: n8n webhooks (#8), admin dashboard at `/dashboard` (#11), Authentik OIDC Also done: n8n webhooks (#8), admin dashboard at `/dashboard` (#11), Authentik OIDC
login (#10, needs Authentik-side setup), client portal at `/my-bookings` (#13). login (#10) and client self-registration enrollment (#12) - both live and verified
on dev and prod as of 2026-07-20, client portal at `/my-bookings` (#13).
Auth lives in `api/auth.js` (zero-dep OIDC + HMAC cookie sessions); admin routes Auth lives in `api/auth.js` (zero-dep OIDC + HMAC cookie sessions); admin routes
accept an OIDC admin session or the legacy `ADMIN_SECRET` bearer. accept an OIDC admin session or the legacy `ADMIN_SECRET` bearer. One shared
Authentik provider/application serves both environments - see `scripts/authentik-setup.sh`.
### Booking JS Functions (in `src/index.html`) ### Booking JS Functions (in `src/index.html`)
+37
View File
@@ -12,6 +12,8 @@ const PORT = process.env.PORT || 3001;
const CONTRACTS_DIR = path.join(__dirname, 'contracts'); const CONTRACTS_DIR = path.join(__dirname, 'contracts');
const SIGNED_DIR = path.join(__dirname, 'signed-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(cors({ origin: process.env.CORS_ORIGIN || '*', credentials: true }));
app.use(express.json({ limit: '10mb' })); app.use(express.json({ limit: '10mb' }));
@@ -374,4 +376,39 @@ app.get('/api/admin/payments/export', requireAdmin, (req, res) => {
res.send(csv); 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}`)); app.listen(PORT, () => console.log(`API listening on port ${PORT}`));
+4
View File
@@ -39,6 +39,10 @@ services:
- ./api/data:/app/data - ./api/data:/app/data
- ./api/signed-contracts:/app/signed-contracts - ./api/signed-contracts:/app/signed-contracts
- ./api/contracts:/app/contracts:ro - ./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: networks:
- web - web
healthcheck: healthcheck:
+19 -14
View File
@@ -13,24 +13,29 @@ git.jerodrigged.com/user/settings/applications with `read:user`, `write:issue`,
`write:repository` and set it as `GITEA_TOKEN` (user env var) on this machine. `write:repository` and set it as `GITEA_TOKEN` (user env var) on this machine.
### B2. Production (CT111) api/.env is a blank template ### B2. Production (CT111) api/.env is a blank template
The GitHub deploy now auto-creates `api/.env` from `.env.example`, which is why the Prod moved off the GitHub deploy path this session (see B4) — `api/.env` now
new stack runs — but every secret is empty. Until real values are set on CT111 at lives on CT111 and is deployed to via `git push origin main:prod`, no longer
`/opt/lisilou-portfolio/api/.env`: auto-templated by GitHub Actions.
**Update 2026-07-20:** `OIDC_*` and `SESSION_SECRET` are now filled in and
verified working (see #12/#17). Still outstanding:
- admin dashboard login is disabled (no `ADMIN_SECRET`) - admin dashboard login is disabled (no `ADMIN_SECRET`)
- **new bookings send no n8n notification — real clients could book silently** - **new bookings send no n8n notification — real clients could book silently**
- calendar availability shows all dates free (no Google creds) - calendar availability shows all dates free (no Google creds)
- SSO/client portal sign-in is inactive (no `OIDC_*` / `SESSION_SECRET`) - `SITE_URL` still points at CT114's LAN IP (`192.168.1.192:8080`) instead of
Copy working values from CT114:`/opt/…/api/.env` (adjust `SITE_URL` / `CORS_ORIGIN` `https://lisilou.jerodrigged.com`; `CORS_ORIGIN` is still `localhost:8080`
to https://lisilou.jerodrigged.com), then `docker compose restart api`. Deliberately left alone tonight — these need real values from Jerod (an n8n
webhook URL, Google service account, a chosen admin passphrase), not something
to fill with placeholders. `docker compose up -d api` (not `restart` — it
doesn't reload `env_file` changes) after editing.
### B3. Authentik provider + groups (issues #10/#12) ### ~~B3. Authentik provider + groups (issues #10/#12)~~ RESOLVED 2026-07-20
Code for OIDC login and the client portal is deployed and tested, but Authentik `scripts/authentik-setup.sh` was run for real against auth.jerodrigged.com (it
needs one-time admin setup (see header comment in `api/auth.js`): never had been before). Three bugs found and fixed along the way — see the
1. OAuth2/OpenID provider (confidential; redirect URIs for both dev and prod closing comments on #12 and #17 for the full writeup. Provider, application,
`/api/auth/callback`), application "LisiLou Portfolio" `lisilou-admin` group, and the client enrollment flow are all live; `api/.env`
2. `lisilou-admin` group with Elysse (grants dashboard access) is filled on both dev and prod; verified end-to-end via a real authorization-
3. Enrollment flow for client self-registration (#12) — Authentik-side config only code+PKCE round trip on both hosts.
4. Fill `OIDC_*` + `SESSION_SECRET` in both instances' `api/.env`
### B4. Decide the deploy topology (dev vs prod) ### B4. Decide the deploy topology (dev vs prod)
Discovered overnight: `lisilou.jerodrigged.com` → NPM (CT102) → **CT111**, deployed Discovered overnight: `lisilou.jerodrigged.com` → NPM (CT102) → **CT111**, deployed
+19 -9
View File
@@ -39,7 +39,13 @@ api() { # method path [json-body]
fi fi
} }
jget() { python -c "import sys,json;d=json.load(sys.stdin);print(eval(sys.argv[1]))" "$2" <<<"$1"; } jget() { # jget <json-string|-> <python-expr> ('-' reads JSON from stdin, e.g. a pipe)
if [ "$1" = "-" ]; then
python3 -c "import sys,json;d=json.load(sys.stdin);print(eval(sys.argv[1]))" "$2"
else
python3 -c "import sys,json;d=json.load(sys.stdin);print(eval(sys.argv[1]))" "$2" <<<"$1"
fi
}
echo "── Checking API access…" echo "── Checking API access…"
VERSION=$(api GET /admin/version/ | jget - "d['version_current']" 2>/dev/null || true) VERSION=$(api GET /admin/version/ | jget - "d['version_current']" 2>/dev/null || true)
@@ -50,11 +56,15 @@ echo " Authentik $VERSION"
AUTHZ_FLOW=$(api GET "/flows/instances/?slug=default-provider-authorization-implicit-consent" | jget - "d['results'][0]['pk']") AUTHZ_FLOW=$(api GET "/flows/instances/?slug=default-provider-authorization-implicit-consent" | jget - "d['results'][0]['pk']")
INVALIDATION_FLOW=$(api GET "/flows/instances/?slug=default-provider-invalidation-flow" | jget - "d['results'][0]['pk']" 2>/dev/null || echo "") INVALIDATION_FLOW=$(api GET "/flows/instances/?slug=default-provider-invalidation-flow" | jget - "d['results'][0]['pk']" 2>/dev/null || echo "")
SCOPES=$(api GET "/propertymappings/provider/scope/?managed__iexact=goauthentik.io/providers/oauth2/scope-openid" | jget - "d['results'][0]['pk']") SCOPES=""
for s in profile email; do for s in openid profile email; do
SCOPES="$SCOPES,$(api GET "/propertymappings/provider/scope/?managed__iexact=goauthentik.io/providers/oauth2/scope-$s" | jget - "d['results'][0]['pk']")" # NOTE: managed__iexact is silently ignored by this endpoint (returns the
# unfiltered list) - scope_name is the field that actually filters.
PK=$(api GET "/propertymappings/provider/scope/?scope_name=$s" | jget - "d['results'][0]['pk']")
[ -n "$PK" ] || { echo " FATAL: no scope mapping found for scope_name=$s"; exit 1; }
SCOPES="${SCOPES:+$SCOPES,}$PK"
done done
SCOPES_JSON=$(python -c "import sys;print(__import__('json').dumps(sys.argv[1].split(',')))" "$SCOPES") SCOPES_JSON=$(python3 -c "import sys;print(__import__('json').dumps(sys.argv[1].split(',')))" "$SCOPES")
# ── 2. OAuth2 provider ──────────────────────────────────────────────────────── # ── 2. OAuth2 provider ────────────────────────────────────────────────────────
echo "── Provider…" echo "── Provider…"
@@ -63,7 +73,7 @@ if [ "$EXISTING" -gt 0 ]; then
PROVIDER_PK=$(api GET "/providers/oauth2/?name=$CLIENT_ID" | jget - "d['results'][0]['pk']") PROVIDER_PK=$(api GET "/providers/oauth2/?name=$CLIENT_ID" | jget - "d['results'][0]['pk']")
echo " exists (pk=$PROVIDER_PK)" echo " exists (pk=$PROVIDER_PK)"
else else
BODY=$(python - "$AUTHZ_FLOW" "$INVALIDATION_FLOW" "$SCOPES_JSON" <<'PY' BODY=$(python3 - "$AUTHZ_FLOW" "$INVALIDATION_FLOW" "$SCOPES_JSON" <<'PY'
import json, sys import json, sys
authz, inval, scopes = sys.argv[1], sys.argv[2], json.loads(sys.argv[3]) authz, inval, scopes = sys.argv[1], sys.argv[2], json.loads(sys.argv[3])
p = { p = {
@@ -85,7 +95,7 @@ PY
) )
RESP=$(api POST /providers/oauth2/ "$BODY" 2>&1) || { RESP=$(api POST /providers/oauth2/ "$BODY" 2>&1) || {
# Older Authentik (<2024.2) wants redirect_uris as a newline-joined string # Older Authentik (<2024.2) wants redirect_uris as a newline-joined string
BODY=$(python -c " BODY=$(python3 -c "
import json,sys import json,sys
p=json.loads(sys.argv[1]); p['redirect_uris']='\n'.join(u['url'] for u in p['redirect_uris']); print(json.dumps(p))" "$BODY") p=json.loads(sys.argv[1]); p['redirect_uris']='\n'.join(u['url'] for u in p['redirect_uris']); print(json.dumps(p))" "$BODY")
RESP=$(api POST /providers/oauth2/ "$BODY") RESP=$(api POST /providers/oauth2/ "$BODY")
@@ -155,7 +165,7 @@ else
make_field password "Password" password 3 "" make_field password "Password" password 3 ""
make_field password_repeat "Confirm password" password 4 "" make_field password_repeat "Confirm password" password 4 ""
PROMPT_STAGE=$(api POST /stages/prompt/ "{ PROMPT_STAGE=$(api POST /stages/prompt/stages/ "{
\"name\": \"lisilou-enrollment-prompt\", \"name\": \"lisilou-enrollment-prompt\",
\"fields\": [\"${FIELD_PKS[username]}\",\"${FIELD_PKS[name]}\",\"${FIELD_PKS[email]}\",\"${FIELD_PKS[password]}\",\"${FIELD_PKS[password_repeat]}\"] \"fields\": [\"${FIELD_PKS[username]}\",\"${FIELD_PKS[name]}\",\"${FIELD_PKS[email]}\",\"${FIELD_PKS[password]}\",\"${FIELD_PKS[password_repeat]}\"]
}" | jget - "d['pk']") }" | jget - "d['pk']")
@@ -191,7 +201,7 @@ OIDC_CLIENT_ID=$CLIENT_ID
OIDC_CLIENT_SECRET=$CLIENT_SECRET OIDC_CLIENT_SECRET=$CLIENT_SECRET
OIDC_REDIRECT_URI=$PROD_REDIRECT OIDC_REDIRECT_URI=$PROD_REDIRECT
OIDC_ADMIN_GROUP=lisilou-admin OIDC_ADMIN_GROUP=lisilou-admin
SESSION_SECRET=$(openssl rand -hex 32 2>/dev/null || python -c "import secrets;print(secrets.token_hex(32))") SESSION_SECRET=$(openssl rand -hex 32 2>/dev/null || python3 -c "import secrets;print(secrets.token_hex(32))")
Then: docker compose restart api Then: docker compose restart api
EOF EOF
+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); font-family: inherit; font-size: .87rem; resize: vertical; min-height: 60px; background: var(--bg);
} }
.notes-input:focus { outline: none; border-color: var(--primary); } .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> </style>
</head> </head>
<body> <body>
@@ -258,6 +276,7 @@ tr.expanded td { background: var(--bg); }
<a onclick="showPanel('calendar')" id="nav-calendar">&#9632; Calendar</a> <a onclick="showPanel('calendar')" id="nav-calendar">&#9632; Calendar</a>
<a onclick="showPanel('bookings')" id="nav-bookings">&#9632; Bookings</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('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> </nav>
<main> <main>
<!-- Overview panel --> <!-- Overview panel -->
@@ -333,6 +352,83 @@ tr.expanded td { background: var(--bg); }
</div> </div>
<div id="payments-table-wrap"></div> <div id="payments-table-wrap"></div>
</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> </main>
</div> </div>
</div> </div>
@@ -476,6 +572,117 @@ function showPanel(name) {
if (name === 'calendar') renderCalendar(); if (name === 'calendar') renderCalendar();
if (name === 'bookings') loadBookings(); if (name === 'bookings') loadBookings();
if (name === 'payments') loadPaymentsPanel(); 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.profileImage'],
['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 ────────────────────────────────────────────────────────────────── // ── Overview ──────────────────────────────────────────────────────────────────
+21
View File
@@ -142,4 +142,25 @@ test.describe('Admin dashboard — panels', () => {
expect(download.suggestedFilename()).toMatch(/lisilou-bookings.*\.csv/); 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 });
});
}); });