Compare commits

..

13 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
jhodgkin c97f1f73ce Prune old image generations after each prod deploy
Deploy to Dev / Deploy & Smoke Test (push) Successful in 22s
Deploy to Prod / Deploy & Smoke Test (prod) (push) Successful in 42s
Each promotion pulls a uniquely SHA-tagged image, so without cleanup
old generations accumulate indefinitely. CT111's disk is small (was
5.9GB, just grown to 8.8GB) and filled up mid-pull on the first real
promotion attempt. Prune dangling images after up -d swaps traffic to
the new containers, once their predecessors are no longer referenced.
2026-07-20 05:30:06 +00:00
jhodgkin 016256cd2f Add registry-based prod promotion, separate from auto dev deploy
Deploy to Dev / Deploy & Smoke Test (push) Successful in 20s
Deploy to Prod / Deploy & Smoke Test (prod) (push) Failing after 30s
Dev keeps auto-deploying on every push to main, same as before, but now
also publishes each build to git.jerodrigged.com's container registry
tagged by short commit SHA (best-effort - never blocks the dev deploy
if REGISTRY_TOKEN isn't set yet).

Prod deploys only on an intentional `git push origin main:prod`, and
only ever pulls a pre-built SHA-tagged image - it never rebuilds from
source. This guarantees prod runs the exact artifact dev already
validated, and makes promoting an untested commit fail loudly (pull of
a nonexistent tag) instead of silently rebuilding something new.

Needs new secrets before the prod path works: PROD_SSH_KEY, PROD_HOST,
PROD_USER, REGISTRY_USER, REGISTRY_TOKEN. PROD_SSH_KEY/PROD_HOST/
REGISTRY_USER are already set; PROD_USER and REGISTRY_TOKEN still need
Jerod's input.
2026-07-20 04:47:49 +00:00
jhodgkin 53677c9f6c Request Immich preview-size images instead of tiny default thumbnails
Deploy to Dev / Deploy & Smoke Test (push) Successful in 23s
The default /thumbnail endpoint serves a 250x374 WebP, which is
visibly blurry on portfolio cards meant to show off real work.
size=preview returns a ~1440px-wide JPEG at a reasonable file size,
confirmed directly against the live Immich instance.

Fixes #25.
2026-07-20 04:23:09 +00:00
jhodgkin c367758205 Add idempotent Authentik setup script for issues #10/#12
Deploy to Dev / Deploy & Smoke Test (push) Successful in 21s
Creates the OAuth2 provider, application, lisilou-admin group, and the
client enrollment flow via the Authentik API, then prints ready-to-paste
api/.env lines. Run with AUTHENTIK_URL + AUTHENTIK_TOKEN once CT121 is up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 17:04:15 -06:00
jhodgkin 773fcd5740 Mark B5 resolved: NPM proxy host domain typo corrected
Deploy to Dev / Deploy & Smoke Test (push) Successful in 21s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 10:12:46 -06:00
jhodgkin 6adfd2d0b4 Backlog: dev-lisilou.jerodrigged.com blocked on missing NPM proxy host (B5)
Deploy to Dev / Deploy & Smoke Test (push) Successful in 21s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 09:22:52 -06:00
jhodgkin 952303d05c Document current state: backlog from overnight session, CLAUDE.md refresh
Deploy to Dev / Deploy & Smoke Test (push) Successful in 20s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 00:41:30 -06:00
jhodgkin 9dcc73ecfe Design pass: fix dead site config, add hero image support, polish cards
Deploy to Dev / Deploy & Smoke Test (push) Successful in 21s
- Fix applyConfig self-recursion (const _origApply captured the hoisted
  wrapper itself): site.json config — theme, portfolio, socials, bio —
  was never applied; site silently fell back to hardcoded brown defaults
- Strengthen theme test to assert the configured primary color exactly
- Portfolio covers: styled placeholder on missing image (like locations),
  restore img when Immich carousel kicks in; overlay always visible on touch
- Optional hero photo via site.heroImage with soft scrim (activates only
  when the image loads); site.json points at /images/hero.jpg
- Option cards: equal height, centered content
- /#book deep link also works via hashchange

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 00:39:36 -06:00
13 changed files with 828 additions and 48 deletions
+70
View File
@@ -0,0 +1,70 @@
name: Deploy to Prod
# Intentional promotion only: push origin <ref>:prod to ship whatever image
# dev already built and published for that commit. This workflow never runs
# `docker compose build` - it only pulls a pre-existing SHA-tagged image from
# the registry, so prod always runs byte-identical artifacts to what dev
# already validated. See docker-compose.deploy.yml and CLAUDE.md.
on:
push:
branches: [prod]
jobs:
deploy:
name: Deploy & Smoke Test (prod)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install SSH client
run: which ssh || (apt-get update -qq && apt-get install -y -qq openssh-client)
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H ${{ secrets.PROD_HOST }} >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Deploy (pull pre-built image, no rebuild)
run: |
ssh -i ~/.ssh/deploy_key \
-o StrictHostKeyChecking=no \
-o ConnectTimeout=10 \
${{ secrets.PROD_USER }}@${{ secrets.PROD_HOST }} \
'set -e
cd /opt/lisilou-portfolio
git fetch origin
git reset --hard origin/prod
SHA=$(git rev-parse --short HEAD)
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.jerodrigged.com -u "${{ secrets.REGISTRY_USER }}" --password-stdin
export IMAGE_TAG=$SHA
docker compose -f docker-compose.yml -f docker-compose.deploy.yml pull
docker compose -f docker-compose.yml -f docker-compose.deploy.yml up -d
echo "Deployed $SHA (pulled, not built)"
docker image prune -af
echo "Pruned old image generations"'
- name: Wait for health check
run: |
echo "Waiting for services..."
for i in $(seq 1 24); do
if curl -sf --max-time 4 "http://${{ secrets.PROD_HOST }}:8080/health" > /dev/null 2>&1; then
echo "Services are up (attempt $i)"
exit 0
fi
echo " Attempt $i/24 — sleeping 5s"
sleep 5
done
echo "Health check timed out after 2 minutes"
exit 1
- name: Smoke tests
run: bash scripts/smoke-test.sh "http://${{ secrets.PROD_HOST }}:8080"
- name: Cleanup SSH key
if: always()
run: rm -f ~/.ssh/deploy_key
+14
View File
@@ -34,6 +34,20 @@ jobs:
git fetch origin
git reset --hard origin/main
docker compose build
SHA=$(git rev-parse --short HEAD)
if [ -n "${{ secrets.REGISTRY_TOKEN }}" ]; then
set +e
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.jerodrigged.com -u "${{ secrets.REGISTRY_USER }}" --password-stdin \
&& docker tag lisilou-portfolio-web:local git.jerodrigged.com/jhodgkin/lisilou-portfolio-web:$SHA \
&& docker tag lisilou-portfolio-api:local git.jerodrigged.com/jhodgkin/lisilou-portfolio-api:$SHA \
&& docker push git.jerodrigged.com/jhodgkin/lisilou-portfolio-web:$SHA \
&& docker push git.jerodrigged.com/jhodgkin/lisilou-portfolio-api:$SHA \
&& echo "Published $SHA to registry" \
|| echo "WARNING: registry publish failed, continuing deploy anyway"
set -e
else
echo "REGISTRY_TOKEN not set - skipping registry publish (prod promotion unavailable until configured)"
fi
docker compose up -d
echo "Deploy complete"'
+52 -23
View File
@@ -8,7 +8,9 @@ LisiLou Photography Portfolio — a photography booking and portfolio site for E
Built as a zero-framework SPA with a Node.js/Express booking API, deployed on a homelab Proxmox cluster.
**Live dev URL:** http://192.168.1.192:8080 (CT114, lisilou-dev LXC)
**Gitea repo:** https://git.jerodrigged.com/jhodgkin/lisilou-portfolio
**Production URL:** https://lisilou.jerodrigged.com (CT111 via Cloudflare tunnel → NPM CT102)
**Gitea repo:** https://git.jerodrigged.com/jhodgkin/lisilou-portfolio (deploys dev, CT114)
**GitHub mirror:** https://github.com/jhodgkin/lisilou-portfolio (deploys prod, CT111 — push here too or prod drifts!)
## Technology Stack
@@ -18,7 +20,7 @@ Built as a zero-framework SPA with a Node.js/Express booking API, deployed on a
| Portfolio server | Nginx Alpine |
| Booking API | Node.js 20 / Express / better-sqlite3 |
| Deployment | Docker Compose (two services: `portfolio` + `api`) |
| CI/CD | Gitea Actions → auto-deploys to CT114 on push to `main` |
| CI/CD | Gitea Actions → CT114 (dev); GitHub Actions → CT111 (prod) on push to `main` |
| Image hosting | Immich (immich.jerodrigged.com) |
## Development Commands
@@ -67,7 +69,7 @@ All content is loaded at runtime — no rebuild needed:
```
site.json
├── site — title, tagline, logo, favicon
├── site — title, tagline, logo, favicon, heroImage (optional hero photo)
├── photographer — name, bio, profile image
├── contact — email, phone
├── social — instagram, facebook, etc.
@@ -142,29 +144,63 @@ Missing images fall back to a styled placeholder — safe to deploy before photo
| `.gitea/workflows/deploy.yml` | Push-to-main → SSH deploy → smoke tests |
| `scripts/smoke-test.sh` | 6 curl assertions; exits non-zero on failure |
## CI/CD — Trunk-Based Development
## CI/CD — Dev auto-deploys, prod is a deliberate promotion
Every push to `main`:
Both pipelines run on Gitea Actions (git.jerodrigged.com). GitHub is not part
of the deploy story — `main` on the GitHub mirror is unused.
**Dev (every push to `main`)**`.gitea/workflows/deploy.yml`:
1. Gitea runner (CT117) SSHes into CT114 (dev LXC at 192.168.1.192)
2. `git fetch origin && git reset --hard origin/main` — robust, never fails on drift
3. `docker compose build && docker compose up -d`
4. Health check loop (24 × 5s attempts)
5. `bash scripts/smoke-test.sh` — 6 tests; pipeline fails if any fail
3. `docker compose build` (tags images `lisilou-portfolio-web:local` / `lisilou-portfolio-api:local`)
4. If `REGISTRY_TOKEN` is set: tag+push both images to
`git.jerodrigged.com/jhodgkin/lisilou-portfolio-{web,api}:<short-sha>`
best-effort, a publish failure never blocks the dev deploy itself
5. `docker compose up -d` (runs the just-built local image)
6. Health check loop (24 × 5s attempts), then `bash scripts/smoke-test.sh`
**Gitea secrets required:** `DEV_SSH_KEY`, `DEV_HOST`, `DEV_USER`
**Prod (only on `git push origin main:prod`, or `<sha>:prod` to pin an older
commit)** — `.gitea/workflows/deploy-prod.yml`:
1. Gitea runner SSHes into CT111 (prod LXC, 192.168.1.246)
2. `git fetch origin && git reset --hard origin/prod`, then computes the same
short SHA dev used to tag its published image
3. `docker compose -f docker-compose.yml -f docker-compose.deploy.yml pull`
pulls that exact SHA-tagged image from the registry. **No `docker compose
build` ever runs here** — this is what guarantees prod runs the same
artifact dev already validated, not a fresh rebuild that could drift.
Pulling a SHA that dev never published fails loudly instead of silently
rebuilding.
4. `docker compose -f docker-compose.yml -f docker-compose.deploy.yml up -d`
5. Same health check + smoke test pattern, against CT111
`docker-compose.deploy.yml` is the override that swaps each service's `image:`
for the registry-tagged one; it's only ever used for the prod pull, never for
local dev (`docker compose up -d` alone still builds from source as before).
**Gitea secrets required:** `DEV_SSH_KEY`, `DEV_HOST`, `DEV_USER` (dev);
`PROD_SSH_KEY`, `PROD_HOST`, `PROD_USER` (prod); `REGISTRY_USER`,
`REGISTRY_TOKEN` (both pipelines — a Gitea access token scoped
`write:package,read:package`).
## Booking Wizard — Implementation Status
| Step | Feature | Issue | Status |
|------|---------|-------|--------|
| 1 | Date picker | #3 | Shell done; Google Calendar availability pending |
| 1 | Date picker | #3 | Done Google Calendar busy dates (graceful when unconfigured) |
| 2 | Session type | #4 | Done — config-driven from `sessionTypes[]` |
| 3 | Session length | #4 | Done — mini/full with pricing from config |
| 4 | Location picker | #5 | Done — photo cards with detail expand panel |
| 5 | Contract e-signature | #6 | Shell placeholder; PDF.js + pdf-lib pending |
| 6 | Venmo payment | #7 | Shell placeholder; deep link + QR pending |
| 5 | Contract e-signature | #6 | Done — PDF.js viewer + canvas pad; needs `api/contracts/model-release.pdf` on volume |
| 6 | Venmo payment | #7 | Done — deep link + server-generated QR |
| 7 | Confirm & submit | — | Done — summary + POST /api/bookings |
Also done: n8n webhooks (#8), admin dashboard at `/dashboard` (#11), Authentik OIDC
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
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`)
| Function | Purpose |
@@ -181,17 +217,10 @@ Every push to `main`:
| `populateSummary()` | Fills step 7 confirm rows from bookingState + config labels |
| `submitBooking()` | POST /api/bookings; shows success state |
## Pending Issues
## Pending Work
| # | Feature |
|---|---------|
| #3 | Google Calendar availability (real date picker with blocked dates) |
| #6 | Contract e-signature (PDF.js viewer + HTML5 canvas signature pad) |
| #7 | Venmo payment step (deep link + QR code) |
| #8 | n8n email notifications on new booking |
| #10 | Authentik OIDC (photographer admin + client self-registration) |
| #11 | Management dashboard with payment status |
| #12 | Authentik client self-registration enrollment flow |
| #13 | Client portal /my-bookings |
All original wizard issues (#3#13) are code-complete. Remaining items — mostly
infrastructure setup, secrets, and content — are catalogued in
`docs/BACKLOG-2026-07-16.md` and should be transferred to Gitea issues.
Issues are tracked at: https://git.jerodrigged.com/jhodgkin/homelab/issues
+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}`));
+2 -1
View File
@@ -4,7 +4,8 @@
"tagline": "Capturing life's beautiful moments",
"description": "Senior portraits, engagement sessions, and milestone photography",
"logo": "/images/logo.png",
"favicon": "/images/favicon.ico"
"favicon": "/images/favicon.ico",
"heroImage": "/images/hero.jpg"
},
"photographer": {
"name": "Elysse Hodgkin",
+13
View File
@@ -0,0 +1,13 @@
# Prod-only override. Used exclusively by .gitea/workflows/deploy-prod.yml as:
# docker compose -f docker-compose.yml -f docker-compose.deploy.yml pull
# docker compose -f docker-compose.yml -f docker-compose.deploy.yml up -d
#
# Overriding `image:` here means `pull` fetches a specific pre-built tag from
# the registry and `up -d` runs it without ever invoking `build:` — prod can
# only ever run an artifact that dev already built and published under
# IMAGE_TAG (the short commit SHA). See CLAUDE.md CI/CD section.
services:
portfolio:
image: git.jerodrigged.com/jhodgkin/lisilou-portfolio-web:${IMAGE_TAG}
api:
image: git.jerodrigged.com/jhodgkin/lisilou-portfolio-api:${IMAGE_TAG}
+6
View File
@@ -5,6 +5,7 @@ services:
build:
context: .
dockerfile: Dockerfile
image: lisilou-portfolio-web:local
container_name: lisilou-portfolio
restart: unless-stopped
ports:
@@ -29,6 +30,7 @@ services:
build:
context: ./api
dockerfile: Dockerfile
image: lisilou-portfolio-api:local
container_name: lisilou-api
restart: unless-stopped
env_file:
@@ -37,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:
+114
View File
@@ -0,0 +1,114 @@
# Backlog from overnight session — 2026-07-16
Written to the repo because the available Gitea credential (`write:repository` only)
cannot create issues. **Transfer these to Gitea once a token with
`read:issue` + `write:issue` scope exists.**
## Blockers needing Jerod's input
### B1. Gitea API token for agent workflows
The stored git credential can push code but not read/create issues
(`required=[read:issue], token scope=write:repository`). Create a token at
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.
### B2. Production (CT111) api/.env is a blank template
Prod moved off the GitHub deploy path this session (see B4) — `api/.env` now
lives on CT111 and is deployed to via `git push origin main:prod`, no longer
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`)
- **new bookings send no n8n notification — real clients could book silently**
- calendar availability shows all dates free (no Google creds)
- `SITE_URL` still points at CT114's LAN IP (`192.168.1.192:8080`) instead of
`https://lisilou.jerodrigged.com`; `CORS_ORIGIN` is still `localhost:8080`
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)~~ RESOLVED 2026-07-20
`scripts/authentik-setup.sh` was run for real against auth.jerodrigged.com (it
never had been before). Three bugs found and fixed along the way — see the
closing comments on #12 and #17 for the full writeup. Provider, application,
`lisilou-admin` group, and the client enrollment flow are all live; `api/.env`
is filled on both dev and prod; verified end-to-end via a real authorization-
code+PKCE round trip on both hosts.
### B4. Decide the deploy topology (dev vs prod)
Discovered overnight: `lisilou.jerodrigged.com` → NPM (CT102) → **CT111**, deployed
by **GitHub** Actions from github.com/jhodgkin/lisilou-portfolio; the Gitea repo
deploys to **CT114** (dev). The public site was 20 commits stale because pushes
stopped reaching GitHub. I've synced GitHub main and both pipelines are green, but:
- keeping two remotes in sync manually will drift again (mirror Gitea→GitHub, or
point NPM at CT114, or move prod deploy to Gitea Actions)
- CT111 and CT114 have separate SQLite DBs — real bookings live on CT111 only
- `homelab/docs/infrastructure.md` is stale (lists CT114 as Keycloak; CT111 notes
don't mention the GitHub runner deploy chain)
### ~~B5. dev-lisilou.jerodrigged.com has no NPM proxy host~~ RESOLVED 2026-07-16
Root cause was a typo in the NPM proxy host domain (`jerodriggec.com`); Jerod
corrected it and the host is live. Original notes kept below for context.
### B5 (original notes)
The Cloudflare tunnel delivers the hostname to NPM (CT102), but NPM has no proxy
host for it, so it falls through to the "Jerod Rigged" default landing page.
Verified: `curl -H "Host: dev-lisilou.jerodrigged.com" http://192.168.1.185/`
→ landing page, while the same probe with `Host: lisilou.jerodrigged.com`
`healthy`. I could not fix this without the NPM admin login (192.168.1.185:81 —
presumably in Vaultwarden; factory default is disabled, and neither SSH nor the
ansible key works from this machine).
**Fix (2 min in NPM UI):** Hosts → Proxy Hosts → Add:
- Domain: `dev-lisilou.jerodrigged.com`
- Scheme `http`, Forward Host `192.168.1.192`, Port `8080`
- Websockets ON, Block Common Exploits ON; SSL tab: same as the
`lisilou.jerodrigged.com` host
Also confirm the hostname exists as a public hostname/CNAME in the CF tunnel
config (it already resolves and reaches NPM, so likely fine).
**So the agent can do this next time:** put the NPM admin credential in a place
Claude can reach (e.g. `NPM_ADMIN_IDENTITY`/`NPM_ADMIN_SECRET` env vars, or an
API-only NPM user), or install/authorize the `~/.ssh/ansible_ed25519` key on
this machine.
## Security
### S1. Signed-contract download is unauthenticated and enumerable
`GET /api/bookings/:id/contract` serves any signed contract by numeric id (kept
because n8n email links use it). Replace with HMAC-signed URLs
(`?token=…`, using SESSION_SECRET) and update the n8n workflow template.
### S2. Dev ADMIN_SECRET committed in playwright.config.js
`9yPu…` is in git history and matches the dev instance. Rotate the dev secret and
inject via env/Gitea secret instead of a hardcoded default.
## Content (needed to look professional — code is ready, assets are missing)
### C1. Real photography on the prod volume
- `/images/hero.jpg` — new: homepage hero photo (soft scrim applied automatically)
- portfolio category covers (`/images/portfolio/*-cover.jpg`) or working Immich
album links — cards currently show the styled placeholder
- location photos (`/images/locations/<id>/hero.jpg`, `1.jpg`, `2.jpg`)
- `/images/logo.png`, `/images/favicon.ico`, profile photo
Verify the Immich share albums referenced in `site.json` resolve on prod.
### C2. Contract template PDF
`api/contracts/model-release.pdf` is absent on both instances — the e-sign step
falls back and no PDF gets stamped. Drop the real contract on the volumes.
### C3. Confirm public contact details
`site.json` has `hello@lisilou.com` and an empty phone; Venmo username
`LisiLouPhoto` — confirm all are real before promoting the site.
## Nice-to-haves spotted during the walkthrough
- Portfolio lightbox: verify Immich proxy flow end-to-end on prod (couldn't test
without albums)
- `/dashboard` and `/my-bookings` aren't linked from anywhere for the photographer;
consider a footer link or bookmark
- Add `robots.txt` + real meta description / OpenGraph tags for sharing
- The "designer plugin" requested for the design review doesn't exist in this
Claude Code environment — the pass was done manually (screenshots + fixes);
if there's a specific plugin to install, add it to `.claude/settings.json`
+207
View File
@@ -0,0 +1,207 @@
#!/usr/bin/env bash
# One-shot Authentik setup for LisiLou (issues #10 + #12).
#
# Creates (idempotently — safe to re-run):
# 1. OAuth2/OpenID provider "lisilou-portfolio" (confidential, PKCE-friendly)
# with redirect URIs for dev + prod
# 2. Application "LisiLou Portfolio" bound to the provider
# 3. Group "lisilou-admin" (dashboard access) and optionally adds a user
# 4. Enrollment flow "lisilou-enrollment" so clients can self-register (#12),
# and sets it as the brand's enrollment flow (adds a "Sign up" link)
#
# Usage:
# AUTHENTIK_URL=https://auth.jerodrigged.com AUTHENTIK_TOKEN=<api-token> \
# bash scripts/authentik-setup.sh [admin-username-to-add]
#
# The API token comes from: Admin interface → Directory → Tokens → Create
# (intent: API). Prints ready-to-paste api/.env lines at the end.
set -euo pipefail
: "${AUTHENTIK_URL:?set AUTHENTIK_URL, e.g. https://auth.jerodrigged.com}"
: "${AUTHENTIK_TOKEN:?set AUTHENTIK_TOKEN (Directory → Tokens, intent API)}"
ADMIN_USER="${1:-}"
BASE="${AUTHENTIK_URL%/}/api/v3"
DEV_REDIRECT="https://dev-lisilou.jerodrigged.com/api/auth/callback"
PROD_REDIRECT="https://lisilou.jerodrigged.com/api/auth/callback"
CLIENT_ID="lisilou-portfolio"
api() { # method path [json-body]
local method="$1" path="$2" body="${3:-}"
if [ -n "$body" ]; then
curl -sS --fail-with-body -X "$method" "$BASE$path" \
-H "Authorization: Bearer $AUTHENTIK_TOKEN" \
-H "Content-Type: application/json" -d "$body"
else
curl -sS --fail-with-body -X "$method" "$BASE$path" \
-H "Authorization: Bearer $AUTHENTIK_TOKEN"
fi
}
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…"
VERSION=$(api GET /admin/version/ | jget - "d['version_current']" 2>/dev/null || true)
[ -n "$VERSION" ] || { echo "Cannot reach Authentik API — is CT121 running and the token valid?"; exit 1; }
echo " Authentik $VERSION"
# ── 1. Flows and scope mappings we need to reference ─────────────────────────
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 "")
SCOPES=""
for s in openid profile email; do
# 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
SCOPES_JSON=$(python3 -c "import sys;print(__import__('json').dumps(sys.argv[1].split(',')))" "$SCOPES")
# ── 2. OAuth2 provider ────────────────────────────────────────────────────────
echo "── Provider…"
EXISTING=$(api GET "/providers/oauth2/?name=$CLIENT_ID" | jget - "len(d['results'])")
if [ "$EXISTING" -gt 0 ]; then
PROVIDER_PK=$(api GET "/providers/oauth2/?name=$CLIENT_ID" | jget - "d['results'][0]['pk']")
echo " exists (pk=$PROVIDER_PK)"
else
BODY=$(python3 - "$AUTHZ_FLOW" "$INVALIDATION_FLOW" "$SCOPES_JSON" <<'PY'
import json, sys
authz, inval, scopes = sys.argv[1], sys.argv[2], json.loads(sys.argv[3])
p = {
"name": "lisilou-portfolio",
"authorization_flow": authz,
"client_type": "confidential",
"client_id": "lisilou-portfolio",
"property_mappings": scopes,
"redirect_uris": [
{"matching_mode": "strict", "url": "https://dev-lisilou.jerodrigged.com/api/auth/callback"},
{"matching_mode": "strict", "url": "https://lisilou.jerodrigged.com/api/auth/callback"},
],
"sub_mode": "hashed_user_id",
"include_claims_in_id_token": True,
}
if inval: p["invalidation_flow"] = inval
print(json.dumps(p))
PY
)
RESP=$(api POST /providers/oauth2/ "$BODY" 2>&1) || {
# Older Authentik (<2024.2) wants redirect_uris as a newline-joined string
BODY=$(python3 -c "
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")
RESP=$(api POST /providers/oauth2/ "$BODY")
}
PROVIDER_PK=$(jget "$RESP" "d['pk']")
echo " created (pk=$PROVIDER_PK)"
fi
CLIENT_SECRET=$(api GET "/providers/oauth2/$PROVIDER_PK/" | jget - "d['client_secret']")
# ── 3. Application ────────────────────────────────────────────────────────────
echo "── Application…"
if [ "$(api GET "/core/applications/?slug=lisilou" | jget - "len(d['results'])")" -gt 0 ]; then
echo " exists"
api PATCH "/core/applications/lisilou/" "{\"provider\": $PROVIDER_PK}" >/dev/null
else
api POST /core/applications/ "{\"name\": \"LisiLou Portfolio\", \"slug\": \"lisilou\", \"provider\": $PROVIDER_PK, \"meta_launch_url\": \"https://lisilou.jerodrigged.com/my-bookings\"}" >/dev/null
echo " created"
fi
# ── 4. Admin group (+ optional member) ───────────────────────────────────────
echo "── Group lisilou-admin…"
if [ "$(api GET "/core/groups/?name=lisilou-admin" | jget - "len(d['results'])")" -gt 0 ]; then
GROUP_UUID=$(api GET "/core/groups/?name=lisilou-admin" | jget - "d['results'][0]['pk']")
echo " exists"
else
GROUP_UUID=$(api POST /core/groups/ '{"name": "lisilou-admin"}' | jget - "d['pk']")
echo " created"
fi
if [ -n "$ADMIN_USER" ]; then
USER_PK=$(api GET "/core/users/?username=$ADMIN_USER" | jget - "d['results'][0]['pk']" 2>/dev/null || echo "")
if [ -n "$USER_PK" ]; then
api POST "/core/groups/$GROUP_UUID/add_user/" "{\"pk\": $USER_PK}" >/dev/null && echo " added $ADMIN_USER"
else
echo " WARNING: user '$ADMIN_USER' not found — add to lisilou-admin manually"
fi
fi
# ── 5. Enrollment flow (issue #12) ────────────────────────────────────────────
echo "── Enrollment flow…"
if [ "$(api GET "/flows/instances/?slug=lisilou-enrollment" | jget - "len(d['results'])")" -gt 0 ]; then
FLOW_PK=$(api GET "/flows/instances/?slug=lisilou-enrollment" | jget - "d['results'][0]['pk']")
echo " exists"
else
FLOW_PK=$(api POST /flows/instances/ '{
"name": "LisiLou client sign-up",
"slug": "lisilou-enrollment",
"title": "Create your LisiLou Photography account",
"designation": "enrollment",
"authentication": "require_unauthenticated",
"compatibility_mode": true
}' | jget - "d['pk']")
# Prompt fields (created only if a same-key field doesn't already exist)
declare -A FIELD_PKS
make_field() { # key label type order placeholder
local existing
existing=$(api GET "/stages/prompt/prompts/?field_key=$1&name=lisilou-$1" | jget - "d['results'][0]['pk']" 2>/dev/null || echo "")
if [ -n "$existing" ]; then FIELD_PKS[$1]=$existing; return; fi
FIELD_PKS[$1]=$(api POST /stages/prompt/prompts/ "{
\"name\": \"lisilou-$1\", \"field_key\": \"$1\", \"label\": \"$2\",
\"type\": \"$3\", \"required\": true, \"order\": $4, \"placeholder\": \"$5\"
}" | jget - "d['pk']")
}
make_field username "Username" username 0 ""
make_field name "Full name" text 1 "Jane Smith"
make_field email "Email" email 2 "you@example.com"
make_field password "Password" password 3 ""
make_field password_repeat "Confirm password" password 4 ""
PROMPT_STAGE=$(api POST /stages/prompt/stages/ "{
\"name\": \"lisilou-enrollment-prompt\",
\"fields\": [\"${FIELD_PKS[username]}\",\"${FIELD_PKS[name]}\",\"${FIELD_PKS[email]}\",\"${FIELD_PKS[password]}\",\"${FIELD_PKS[password_repeat]}\"]
}" | jget - "d['pk']")
WRITE_STAGE=$(api POST /stages/user_write/ '{"name": "lisilou-enrollment-write", "user_creation_mode": "always_create", "create_users_as_inactive": false}' \
| jget - "d['pk']" 2>/dev/null || \
api POST /stages/user_write/ '{"name": "lisilou-enrollment-write", "can_create_users": true}' | jget - "d['pk']")
LOGIN_STAGE=$(api POST /stages/user_login/ '{"name": "lisilou-enrollment-login"}' | jget - "d['pk']")
for binding in "$PROMPT_STAGE 10" "$WRITE_STAGE 20" "$LOGIN_STAGE 30"; do
set -- $binding
api POST /flows/bindings/ "{\"target\": \"$FLOW_PK\", \"stage\": \"$1\", \"order\": $2}" >/dev/null
done
echo " created (prompt → user_write → login)"
fi
# Point the active brand's enrollment flow at it (adds "Sign up" on the login page)
BRAND=$(api GET "/core/brands/?default=true" | jget - "d['results'][0]['brand_uuid']" 2>/dev/null || echo "")
if [ -n "$BRAND" ]; then
api PATCH "/core/brands/$BRAND/" "{\"flow_enrollment\": \"$FLOW_PK\"}" >/dev/null
echo " set as brand enrollment flow"
else
echo " NOTE: could not find default brand — set Flows→enrollment manually in Brands"
fi
# ── Done ──────────────────────────────────────────────────────────────────────
cat <<EOF
✅ Authentik setup complete. Paste into api/.env on each instance
(SITE-specific OIDC_REDIRECT_URI shown for prod; use the dev URL on CT114):
OIDC_ISSUER=${AUTHENTIK_URL%/}/application/o/lisilou/
OIDC_CLIENT_ID=$CLIENT_ID
OIDC_CLIENT_SECRET=$CLIENT_SECRET
OIDC_REDIRECT_URI=$PROD_REDIRECT
OIDC_ADMIN_GROUP=lisilou-admin
SESSION_SECRET=$(openssl rand -hex 32 2>/dev/null || python3 -c "import secrets;print(secrets.token_hex(32))")
Then: docker compose restart api
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);
}
.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.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 ──────────────────────────────────────────────────────────────────
+75 -12
View File
@@ -198,6 +198,19 @@
opacity: 0.5;
}
/* Optional hero photo (config: site.heroImage) with a soft scrim for text legibility */
.hero-bg.hero-bg--image {
background-size: cover;
background-position: center;
opacity: 1;
}
.hero-bg.hero-bg--image::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(to bottom, rgba(255,255,255,0.5) 0%, rgba(255,255,255,0.72) 100%);
}
.hero-pattern {
position: absolute;
inset: 0;
@@ -413,6 +426,25 @@
letter-spacing: 0.1em;
}
/* Styled stand-in when a category cover image is missing (same idea as location cards) */
.portfolio-card-fallback {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--color-bg-warm) 0%, var(--color-accent) 100%);
font-family: var(--font-display);
font-style: italic;
font-size: 1.3rem;
color: var(--color-primary);
}
/* Touch devices have no hover — keep category titles visible */
@media (hover: none) {
.portfolio-card-overlay { opacity: 1; }
}
/* Client Access Section */
#client-access {
background: var(--color-bg);
@@ -867,6 +899,10 @@
text-align: center;
transition: all 0.3s var(--transition-smooth);
user-select: none;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 6.5rem;
}
.option-card:hover { border-color: var(--color-primary); }
@@ -1402,7 +1438,7 @@
<!-- Hero Section -->
<section class="hero">
<div class="hero-bg"></div>
<div class="hero-bg" id="hero-bg"></div>
<div class="hero-pattern"></div>
<div class="hero-content">
<p class="hero-eyebrow" id="hero-eyebrow">Photography</p>
@@ -1687,9 +1723,10 @@
return [];
}
// Build thumbnail URLs for all assets
// Immich's default thumbnail size is a 250px-wide WebP — too small for
// full-bleed portfolio cards. size=preview serves a ~1440px JPEG instead.
const thumbnailUrls = assets.map(asset =>
`${baseUrl}/api/assets/${asset.id}/thumbnail?key=${shareKey}`
`${baseUrl}/api/assets/${asset.id}/thumbnail?key=${shareKey}&size=preview`
);
return thumbnailUrls;
@@ -1699,6 +1736,14 @@
return [];
}
// Swap a missing category cover for a styled placeholder (keeps the card elegant)
function portfolioCoverFallback(img) {
const fallback = document.createElement('div');
fallback.className = 'portfolio-card-fallback';
fallback.textContent = img.alt || 'Coming soon';
img.replaceWith(fallback);
}
// Start carousel for a portfolio card
function startCarousel(cardElement, images) {
if (images.length <= 1) return;
@@ -1729,8 +1774,9 @@
}
async function applyConfig(config) {
// Store immich config globally first (needed for thumbnail fetching)
// Store config globally: immich for thumbnails, siteConfig for the booking form
window.immichConfig = config.immich || {};
window.siteConfig = config;
// Apply theme colors
if (config.theme) {
@@ -1752,6 +1798,16 @@
if (config.site.tagline) {
document.getElementById('hero-tagline').textContent = config.site.tagline;
}
if (config.site.heroImage) {
// Only activate the photo treatment once the image actually loads
const probe = new Image();
probe.onload = () => {
const heroBg = document.getElementById('hero-bg');
heroBg.style.backgroundImage = `url('${config.site.heroImage}')`;
heroBg.classList.add('hero-bg--image');
};
probe.src = config.site.heroImage;
}
}
// Apply portfolio categories
@@ -1763,7 +1819,7 @@
// First render the cards with placeholder/fallback images
grid.innerHTML = config.portfolio.categories.map(cat => `
<div class="portfolio-card reveal" data-album-id="${cat.immichAlbumId || ''}" onclick="openPortfolio('${cat.id}', '${cat.immichAlbumId || ''}')">
<img src="${cat.coverImage || '/images/placeholder.jpg'}" alt="${cat.name}" loading="lazy">
<img src="${cat.coverImage || '/images/placeholder.jpg'}" alt="${cat.name}" loading="lazy" onerror="portfolioCoverFallback(this)">
<div class="portfolio-card-overlay">
<h3 class="portfolio-card-title">${cat.name}</h3>
<p class="portfolio-card-desc">${cat.description || ''}</p>
@@ -1778,7 +1834,15 @@
const images = await getImmichAlbumImages(baseUrl, prefix, cat.immichAlbumId);
if (images.length > 0) {
const card = grid.children[index];
const img = card.querySelector('img');
let img = card.querySelector('img');
// The cover may have been swapped for the styled fallback — restore an <img>
if (!img) {
const fallback = card.querySelector('.portfolio-card-fallback');
img = document.createElement('img');
img.alt = cat.name;
if (fallback) fallback.replaceWith(img); else card.prepend(img);
}
// Set the first image
img.src = images[0];
@@ -1889,12 +1953,7 @@
reveals.forEach(el => observer.observe(el));
}
// Store config globally for the booking form to use
const _origApply = applyConfig;
async function applyConfig(config) {
window.siteConfig = config;
return _origApply(config);
}
// (window.siteConfig is set at the top of applyConfig for the booking form)
// ── Date Picker ───────────────────────────────────────
@@ -2613,6 +2672,10 @@ A complete service agreement and model release will be provided at your session
setTimeout(openBooking, 400);
}
});
window.addEventListener('hashchange', function() {
if (window.location.hash === '#book') openBooking();
});
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js" crossorigin="anonymous"></script>
</body>
+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 });
});
});
+10 -12
View File
@@ -45,19 +45,17 @@ test.describe('Portfolio site', () => {
expect(json.ok).toBe(true);
});
test('pink theme is applied (primary color is rose/pink)', async ({ page }) => {
test('configured theme is applied (primary color matches site.json)', async ({ page, request }) => {
const config = await (await request.get('/config/site.json')).json();
const expected = (config.theme?.primaryColor || '').toLowerCase();
expect(expected).toMatch(/^#[a-f0-9]{6}$/);
await page.goto('/');
const primary = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue('--color-primary').trim()
// Config is fetched on window load; wait until the var flips from the CSS default
await page.waitForFunction(
exp => getComputedStyle(document.documentElement).getPropertyValue('--color-primary').trim().toLowerCase() === exp,
expected,
{ timeout: 8_000 }
);
// Should be a pink/rose hex — starts with #B or #b (our dusty rose #B06A7A)
expect(primary.toLowerCase()).toMatch(/^#[a-f0-9]{6}$/i);
// Hue should be in the red-pink range: R > G and R > B
const hex = primary.replace('#', '');
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
expect(r).toBeGreaterThan(g);
expect(r).toBeGreaterThan(b);
});
});