Compare commits
13 Commits
a67fdba170
...
prod
| Author | SHA1 | Date | |
|---|---|---|---|
| 945ebdec57 | |||
| 4ac34ca943 | |||
| 943fd9495f | |||
| 1e1eab3d41 | |||
| 2f5607df82 | |||
| c97f1f73ce | |||
| 016256cd2f | |||
| 53677c9f6c | |||
| c367758205 | |||
| 773fcd5740 | |||
| 6adfd2d0b4 | |||
| 952303d05c | |||
| 9dcc73ecfe |
@@ -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
|
||||
@@ -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"'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
@@ -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:
|
||||
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -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">■ Calendar</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('config')" id="nav-config">■ 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 & 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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user