Compare commits
5 Commits
c97f1f73ce
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 945ebdec57 | |||
| 4ac34ca943 | |||
| 943fd9495f | |||
| 1e1eab3d41 | |||
| 2f5607df82 |
@@ -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`)
|
||||||
|
|
||||||
|
|||||||
@@ -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}`));
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">■ Calendar</a>
|
<a onclick="showPanel('calendar')" id="nav-calendar">■ Calendar</a>
|
||||||
<a onclick="showPanel('bookings')" id="nav-bookings">■ Bookings</a>
|
<a onclick="showPanel('bookings')" id="nav-bookings">■ Bookings</a>
|
||||||
<a onclick="showPanel('payments')" id="nav-payments">■ Payments <span class="badge" id="pending-badge" style="display:none"></span></a>
|
<a onclick="showPanel('payments')" id="nav-payments">■ Payments <span class="badge" id="pending-badge" style="display:none"></span></a>
|
||||||
|
<a onclick="showPanel('config')" id="nav-config">■ 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 & 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 ──────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user