Compare commits
15 Commits
bfe374206c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 945ebdec57 | |||
| 4ac34ca943 | |||
| 943fd9495f | |||
| 1e1eab3d41 | |||
| 2f5607df82 | |||
| c97f1f73ce | |||
| 016256cd2f | |||
| 53677c9f6c | |||
| c367758205 | |||
| 773fcd5740 | |||
| 6adfd2d0b4 | |||
| 952303d05c | |||
| 9dcc73ecfe | |||
| a67fdba170 | |||
| 84fdfdfdb8 |
@@ -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"'
|
||||
|
||||
|
||||
@@ -17,11 +17,16 @@ jobs:
|
||||
- name: Pull latest changes
|
||||
run: |
|
||||
cd /opt/lisilou-portfolio
|
||||
git pull origin main
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
|
||||
- name: Build and restart containers
|
||||
run: |
|
||||
cd /opt/lisilou-portfolio
|
||||
# First-run prerequisites for the two-service stack:
|
||||
# compose requires api/.env to exist and the external "web" network
|
||||
if [ ! -f api/.env ]; then cp api/.env.example api/.env; fi
|
||||
docker network inspect web >/dev/null 2>&1 || docker network create web
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
docker compose ps
|
||||
@@ -32,5 +37,12 @@ jobs:
|
||||
|
||||
- name: Verify deployment
|
||||
run: |
|
||||
for i in $(seq 1 24); do
|
||||
if curl -sf http://localhost:8080/health >/dev/null; then
|
||||
echo "Healthy after $((i*5))s"
|
||||
exit 0
|
||||
fi
|
||||
sleep 5
|
||||
curl -f http://localhost:8080/health || echo "Health check failed"
|
||||
done
|
||||
echo "Health check failed after 120s"
|
||||
exit 1
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,8 +19,12 @@ CONTRACT_SIG_Y=700
|
||||
CONTRACT_SIG_PAGE=1
|
||||
|
||||
# OIDC / Authentik (issue #10)
|
||||
# Issuer is the Authentik provider URL, e.g. https://auth.jerodrigged.com/application/o/lisilou/
|
||||
# Redirect URI must be registered on the provider, e.g. https://lisilou.jerodrigged.com/api/auth/callback
|
||||
SESSION_SECRET=
|
||||
OIDC_ISSUER=
|
||||
OIDC_CLIENT_ID=
|
||||
OIDC_CLIENT_SECRET=
|
||||
OIDC_REDIRECT_URI=
|
||||
# Authentik group whose members get admin access (default: lisilou-admin)
|
||||
OIDC_ADMIN_GROUP=lisilou-admin
|
||||
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Authentik OIDC authentication (issue #10).
|
||||
*
|
||||
* Authorization-code flow with PKCE for a confidential client, implemented with
|
||||
* Node.js built-ins only (fetch + crypto) — same zero-dependency approach as
|
||||
* google-calendar.js. Sessions are stateless HMAC-signed cookies.
|
||||
*
|
||||
* Env vars (all required for OIDC to activate; otherwise routes return 503 and
|
||||
* the legacy ADMIN_SECRET bearer check in server.js keeps working):
|
||||
* OIDC_ISSUER — e.g. "https://auth.jerodrigged.com/application/o/lisilou/"
|
||||
* OIDC_CLIENT_ID
|
||||
* OIDC_CLIENT_SECRET
|
||||
* OIDC_REDIRECT_URI — e.g. "https://lisilou.jerodrigged.com/api/auth/callback"
|
||||
* SESSION_SECRET — HMAC key for session cookies (any long random string)
|
||||
* OIDC_ADMIN_GROUP — optional, Authentik group that grants admin (default "lisilou-admin")
|
||||
*
|
||||
* Authentik setup (one-time, in the Authentik admin UI):
|
||||
* 1. Create an OAuth2/OpenID Provider (confidential client, redirect URI above).
|
||||
* 2. Create an Application "LisiLou Portfolio" bound to that provider.
|
||||
* 3. Create a group (default name "lisilou-admin") and add the photographer.
|
||||
* 4. Copy client ID/secret into api/.env on the server.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
const crypto = require('crypto');
|
||||
const express = require('express');
|
||||
|
||||
const SESSION_COOKIE = 'lisilou_sess';
|
||||
const TXN_COOKIE = 'lisilou_oidc_txn';
|
||||
const SESSION_TTL_S = 8 * 60 * 60; // 8 hours
|
||||
const TXN_TTL_S = 10 * 60; // 10 minutes to complete the login round-trip
|
||||
|
||||
let _discoveryCache = null; // { config, fetchedAt }
|
||||
|
||||
function configured() {
|
||||
return Boolean(
|
||||
process.env.OIDC_ISSUER &&
|
||||
process.env.OIDC_CLIENT_ID &&
|
||||
process.env.OIDC_CLIENT_SECRET &&
|
||||
process.env.OIDC_REDIRECT_URI &&
|
||||
process.env.SESSION_SECRET
|
||||
);
|
||||
}
|
||||
|
||||
// ── Cookie signing ────────────────────────────────────────────────────────────
|
||||
|
||||
function b64url(buf) {
|
||||
return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
}
|
||||
|
||||
function b64urlDecode(str) {
|
||||
return Buffer.from(str.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8');
|
||||
}
|
||||
|
||||
function sign(payloadObj) {
|
||||
const payload = b64url(JSON.stringify(payloadObj));
|
||||
const mac = crypto.createHmac('sha256', process.env.SESSION_SECRET).update(payload).digest();
|
||||
return `${payload}.${b64url(mac)}`;
|
||||
}
|
||||
|
||||
function verify(token) {
|
||||
if (!token || !process.env.SESSION_SECRET) return null;
|
||||
const dot = token.lastIndexOf('.');
|
||||
if (dot < 1) return null;
|
||||
const payload = token.slice(0, dot);
|
||||
const mac = crypto.createHmac('sha256', process.env.SESSION_SECRET).update(payload).digest();
|
||||
const expected = b64url(mac);
|
||||
const given = token.slice(dot + 1);
|
||||
if (expected.length !== given.length ||
|
||||
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(given))) return null;
|
||||
try {
|
||||
const obj = JSON.parse(b64urlDecode(payload));
|
||||
if (!obj.exp || obj.exp < Math.floor(Date.now() / 1000)) return null;
|
||||
return obj;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseCookies(req) {
|
||||
const out = {};
|
||||
const header = req.headers.cookie;
|
||||
if (!header) return out;
|
||||
for (const part of header.split(';')) {
|
||||
const eq = part.indexOf('=');
|
||||
if (eq > 0) out[part.slice(0, eq).trim()] = decodeURIComponent(part.slice(eq + 1).trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isSecureDeployment() {
|
||||
return (process.env.OIDC_REDIRECT_URI || '').startsWith('https://');
|
||||
}
|
||||
|
||||
function cookieAttrs(maxAgeS) {
|
||||
const secure = isSecureDeployment() ? '; Secure' : '';
|
||||
return `; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeS}${secure}`;
|
||||
}
|
||||
|
||||
function setCookie(res, name, value, maxAgeS) {
|
||||
const prev = res.getHeader('Set-Cookie');
|
||||
const cookie = `${name}=${encodeURIComponent(value)}${cookieAttrs(maxAgeS)}`;
|
||||
res.setHeader('Set-Cookie', prev ? [].concat(prev, cookie) : cookie);
|
||||
}
|
||||
|
||||
function clearCookie(res, name) {
|
||||
setCookie(res, name, '', 0);
|
||||
}
|
||||
|
||||
// ── Session access (used by server.js middleware) ─────────────────────────────
|
||||
|
||||
function getSession(req) {
|
||||
return verify(parseCookies(req)[SESSION_COOKIE]);
|
||||
}
|
||||
|
||||
// ── OIDC provider discovery ───────────────────────────────────────────────────
|
||||
|
||||
async function discover() {
|
||||
if (_discoveryCache && Date.now() - _discoveryCache.fetchedAt < 60 * 60 * 1000) {
|
||||
return _discoveryCache.config;
|
||||
}
|
||||
const issuer = process.env.OIDC_ISSUER.replace(/\/$/, '');
|
||||
const res = await fetch(`${issuer}/.well-known/openid-configuration`);
|
||||
if (!res.ok) throw new Error(`OIDC discovery failed: ${res.status}`);
|
||||
const config = await res.json();
|
||||
_discoveryCache = { config, fetchedAt: Date.now() };
|
||||
return config;
|
||||
}
|
||||
|
||||
// ── Routes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Begin login. ?redirect=/dashboard controls where the user lands afterwards.
|
||||
router.get('/api/auth/login', async (req, res) => {
|
||||
if (!configured()) return res.status(503).json({ error: 'SSO not configured' });
|
||||
try {
|
||||
const config = await discover();
|
||||
const state = b64url(crypto.randomBytes(24));
|
||||
const verifier = b64url(crypto.randomBytes(48));
|
||||
const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());
|
||||
// Only allow same-site relative redirect targets
|
||||
const redirect = (req.query.redirect || '/').startsWith('/') && !String(req.query.redirect || '/').startsWith('//')
|
||||
? (req.query.redirect || '/') : '/';
|
||||
|
||||
setCookie(res, TXN_COOKIE, sign({
|
||||
state, verifier, redirect,
|
||||
exp: Math.floor(Date.now() / 1000) + TXN_TTL_S,
|
||||
}), TXN_TTL_S);
|
||||
|
||||
const url = new URL(config.authorization_endpoint);
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('client_id', process.env.OIDC_CLIENT_ID);
|
||||
url.searchParams.set('redirect_uri', process.env.OIDC_REDIRECT_URI);
|
||||
url.searchParams.set('scope', 'openid profile email');
|
||||
url.searchParams.set('state', state);
|
||||
url.searchParams.set('code_challenge', challenge);
|
||||
url.searchParams.set('code_challenge_method', 'S256');
|
||||
res.redirect(url.toString());
|
||||
} catch (err) {
|
||||
console.error('[auth] login failed:', err.message);
|
||||
res.status(502).json({ error: 'SSO provider unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/api/auth/callback', async (req, res) => {
|
||||
if (!configured()) return res.status(503).json({ error: 'SSO not configured' });
|
||||
const txn = verify(parseCookies(req)[TXN_COOKIE]);
|
||||
clearCookie(res, TXN_COOKIE);
|
||||
if (!txn || !req.query.code || req.query.state !== txn.state) {
|
||||
return res.status(400).send('Login session expired or invalid. <a href="/api/auth/login">Try again</a>.');
|
||||
}
|
||||
try {
|
||||
const config = await discover();
|
||||
const tokenRes = await fetch(config.token_endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: req.query.code,
|
||||
redirect_uri: process.env.OIDC_REDIRECT_URI,
|
||||
client_id: process.env.OIDC_CLIENT_ID,
|
||||
client_secret: process.env.OIDC_CLIENT_SECRET,
|
||||
code_verifier: txn.verifier,
|
||||
}),
|
||||
});
|
||||
if (!tokenRes.ok) {
|
||||
const body = await tokenRes.text();
|
||||
throw new Error(`token exchange failed: ${tokenRes.status} ${body.slice(0, 200)}`);
|
||||
}
|
||||
const tokens = await tokenRes.json();
|
||||
|
||||
// Claims come from the userinfo endpoint over TLS directly from the issuer,
|
||||
// so a local JWT signature check is not required for this trust model.
|
||||
const uiRes = await fetch(config.userinfo_endpoint, {
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||
});
|
||||
if (!uiRes.ok) throw new Error(`userinfo failed: ${uiRes.status}`);
|
||||
const claims = await uiRes.json();
|
||||
|
||||
const adminGroup = process.env.OIDC_ADMIN_GROUP || 'lisilou-admin';
|
||||
const groups = Array.isArray(claims.groups) ? claims.groups : [];
|
||||
|
||||
setCookie(res, SESSION_COOKIE, sign({
|
||||
sub: claims.sub,
|
||||
email: claims.email || null,
|
||||
name: claims.name || claims.preferred_username || null,
|
||||
admin: groups.includes(adminGroup),
|
||||
exp: Math.floor(Date.now() / 1000) + SESSION_TTL_S,
|
||||
}), SESSION_TTL_S);
|
||||
|
||||
res.redirect(txn.redirect || '/');
|
||||
} catch (err) {
|
||||
console.error('[auth] callback failed:', err.message);
|
||||
res.status(502).send('Login failed. <a href="/api/auth/login">Try again</a>.');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api/auth/logout', (req, res) => {
|
||||
clearCookie(res, SESSION_COOKIE);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Session probe for the frontend
|
||||
router.get('/api/auth/me', (req, res) => {
|
||||
const session = getSession(req);
|
||||
if (!session) return res.json({ authenticated: false, ssoConfigured: configured() });
|
||||
res.json({
|
||||
authenticated: true,
|
||||
ssoConfigured: true,
|
||||
sub: session.sub,
|
||||
email: session.email,
|
||||
name: session.name,
|
||||
admin: Boolean(session.admin),
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = { router, getSession, configured };
|
||||
+103
-8
@@ -5,12 +5,15 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const db = require('./db');
|
||||
const { getBusyDates } = require('./google-calendar');
|
||||
const auth = require('./auth');
|
||||
|
||||
const app = express();
|
||||
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' }));
|
||||
@@ -32,14 +35,33 @@ function notify(event, booking, extra = {}) {
|
||||
}).catch(e => console.error(`[notify] ${event} failed:`, e.message));
|
||||
}
|
||||
|
||||
// ── Admin auth ────────────────────────────────────────────────────────────────
|
||||
// TODO (#10): swap this bearer-token check for Authentik OIDC session validation
|
||||
// when issue #10 lands. The middleware signature stays the same; only the check changes.
|
||||
// ── Auth (issue #10) ──────────────────────────────────────────────────────────
|
||||
// Admin access is granted by either:
|
||||
// 1. an Authentik OIDC session whose user is in the admin group (see auth.js), or
|
||||
// 2. the legacy ADMIN_SECRET bearer token — kept for n8n workflows and tests.
|
||||
app.use(auth.router);
|
||||
|
||||
function requireAdmin(req, res, next) {
|
||||
const session = auth.getSession(req);
|
||||
if (session && session.admin) {
|
||||
req.session = session;
|
||||
return next();
|
||||
}
|
||||
const secret = process.env.ADMIN_SECRET;
|
||||
if (!secret) return res.status(503).json({ error: 'Admin access not configured (set ADMIN_SECRET)' });
|
||||
const auth = req.headers.authorization || '';
|
||||
if (auth !== `Bearer ${secret}`) return res.status(401).json({ error: 'Unauthorized' });
|
||||
const header = req.headers.authorization || '';
|
||||
if (secret && header === `Bearer ${secret}`) return next();
|
||||
if (session) return res.status(403).json({ error: 'Admin access required' });
|
||||
if (!secret && !auth.configured()) {
|
||||
return res.status(503).json({ error: 'Admin access not configured (set ADMIN_SECRET or OIDC_* vars)' });
|
||||
}
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
// Any signed-in user (client portal)
|
||||
function requireUser(req, res, next) {
|
||||
const session = auth.getSession(req);
|
||||
if (!session) return res.status(401).json({ error: 'Sign in required' });
|
||||
req.session = session;
|
||||
next();
|
||||
}
|
||||
|
||||
@@ -106,6 +128,11 @@ app.post('/api/bookings', (req, res) => {
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
// If the client is signed in, bind the booking to their OIDC identity so it
|
||||
// shows up in the /my-bookings portal regardless of what email they typed.
|
||||
const session = auth.getSession(req);
|
||||
const sub = session ? session.sub : client_sub;
|
||||
|
||||
const ps = payment_status || 'pending';
|
||||
// Record when the client indicated they sent payment
|
||||
const paymentNotifiedAt = ps === 'pending_confirmation' ? new Date().toISOString() : null;
|
||||
@@ -116,7 +143,7 @@ app.post('/api/bookings', (req, res) => {
|
||||
session_length, location, payment_status, payment_notified_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
client_name, client_email, client_phone, client_sub,
|
||||
client_name, client_email, client_phone, sub,
|
||||
session_date, session_type, session_length, location,
|
||||
ps, paymentNotifiedAt,
|
||||
);
|
||||
@@ -210,7 +237,40 @@ app.post('/api/bookings/:id/sign', async (req, res) => {
|
||||
res.json({ ok: true, signed_at: now, pdf_path: pdfPath });
|
||||
});
|
||||
|
||||
// ── Admin routes (require ADMIN_SECRET bearer token) ──────────────────────────
|
||||
// ── Client portal (issue #13) ─────────────────────────────────────────────────
|
||||
|
||||
// Bookings belonging to the signed-in client, matched by OIDC subject or email.
|
||||
app.get('/api/my-bookings', requireUser, (req, res) => {
|
||||
const { sub, email } = req.session;
|
||||
const bookings = db.prepare(`
|
||||
SELECT id, created_at, session_date, session_type, session_length, location,
|
||||
contract_signed_at, payment_status, status
|
||||
FROM bookings
|
||||
WHERE (client_sub = ? AND client_sub IS NOT NULL)
|
||||
OR (client_email = ? AND client_email IS NOT NULL)
|
||||
ORDER BY session_date DESC
|
||||
`).all(sub, email || '');
|
||||
res.json(bookings);
|
||||
});
|
||||
|
||||
// Signed contract download for the booking's owner (admin route also exists)
|
||||
app.get('/api/my-bookings/:id/contract', requireUser, (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id);
|
||||
if (!booking) return res.status(404).json({ error: 'Not found' });
|
||||
const { sub, email } = req.session;
|
||||
const owns = (booking.client_sub && booking.client_sub === sub) ||
|
||||
(booking.client_email && email && booking.client_email === email);
|
||||
if (!owns) return res.status(403).json({ error: 'Not your booking' });
|
||||
if (!booking.contract_pdf_path) return res.status(404).json({ error: 'No signed contract' });
|
||||
const pdfPath = path.join(__dirname, booking.contract_pdf_path);
|
||||
if (!fs.existsSync(pdfPath)) return res.status(404).json({ error: 'Contract file missing on disk' });
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="contract-booking-${id}.pdf"`);
|
||||
res.sendFile(pdfPath);
|
||||
});
|
||||
|
||||
// ── Admin routes (OIDC admin session or ADMIN_SECRET bearer token) ────────────
|
||||
|
||||
// Stats: counts + next upcoming bookings for the Overview panel
|
||||
app.get('/api/admin/stats', requireAdmin, (req, res) => {
|
||||
@@ -316,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`
|
||||
@@ -65,6 +65,11 @@ http {
|
||||
try_files $uri /dashboard.html;
|
||||
}
|
||||
|
||||
# Client portal (issue #13)
|
||||
location /my-bookings {
|
||||
try_files $uri /my-bookings.html;
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
@@ -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
|
||||
+243
-6
@@ -47,6 +47,12 @@ body { font-family: 'Nunito Sans', sans-serif; color: var(--text); background: v
|
||||
}
|
||||
.login-card input:focus { border-color: var(--primary); }
|
||||
.login-error { color: var(--red); font-size: .8rem; margin-bottom: .75rem; display: none; }
|
||||
.login-divider {
|
||||
display: flex; align-items: center; gap: .75rem;
|
||||
color: var(--text-muted); font-size: .75rem; text-transform: uppercase; letter-spacing: .1em;
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
.login-divider::before, .login-divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||||
|
||||
/* ── App shell ─────────────────────────────────────── */
|
||||
#app { display: none; flex-direction: column; min-height: 100vh; }
|
||||
@@ -220,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>
|
||||
@@ -230,8 +254,10 @@ tr.expanded td { background: var(--bg); }
|
||||
<h1>LisiLou</h1>
|
||||
<p>Admin Dashboard</p>
|
||||
<p class="login-error" id="login-error">Incorrect passphrase.</p>
|
||||
<button class="btn btn-primary" id="sso-btn" style="width:100%;justify-content:center;display:none;margin-bottom:.75rem;" onclick="ssoLogin()">Sign in with SSO</button>
|
||||
<div class="login-divider" id="login-divider" style="display:none;">or</div>
|
||||
<input type="password" id="login-input" placeholder="Admin passphrase" autocomplete="current-password">
|
||||
<button class="btn btn-primary" style="width:100%;justify-content:center;" onclick="doLogin()">Sign In</button>
|
||||
<button class="btn btn-outline" id="login-btn" style="width:100%;justify-content:center;" onclick="doLogin()">Sign In with Passphrase</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -250,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 -->
|
||||
@@ -325,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>
|
||||
@@ -340,6 +444,7 @@ tr.expanded td { background: var(--bg); }
|
||||
<script>
|
||||
// ── Config & state ────────────────────────────────────────────────────────────
|
||||
let TOKEN = sessionStorage.getItem('admin_token') || '';
|
||||
let SSO_SESSION = false;
|
||||
let siteConfig = {};
|
||||
let allBookings = [];
|
||||
let calYear = new Date().getFullYear();
|
||||
@@ -364,12 +469,31 @@ function fullPrice() { return siteConfig?.booking?.pricing?.full || FULL_PRICE_D
|
||||
document.getElementById('p-full').textContent = fullPrice();
|
||||
} catch(e) { /* non-fatal */ }
|
||||
|
||||
// Prefer an SSO session (issue #10); fall back to the stored passphrase token.
|
||||
try {
|
||||
const me = await (await fetch('/api/auth/me')).json();
|
||||
if (me.authenticated && me.admin) { SSO_SESSION = true; showApp(); return; }
|
||||
if (me.ssoConfigured) {
|
||||
document.getElementById('sso-btn').style.display = 'flex';
|
||||
document.getElementById('login-divider').style.display = 'flex';
|
||||
if (me.authenticated && !me.admin) {
|
||||
const err = document.getElementById('login-error');
|
||||
err.textContent = 'Your account does not have admin access.';
|
||||
err.style.display = 'block';
|
||||
}
|
||||
}
|
||||
} catch(e) { /* API down — passphrase input still shown */ }
|
||||
|
||||
if (TOKEN) {
|
||||
const ok = await verifyToken();
|
||||
if (ok) showApp();
|
||||
}
|
||||
})();
|
||||
|
||||
function ssoLogin() {
|
||||
window.location.href = '/api/auth/login?redirect=' + encodeURIComponent('/dashboard');
|
||||
}
|
||||
|
||||
function applyTheme(t) {
|
||||
const r = document.documentElement.style;
|
||||
if (t.primaryColor) r.setProperty('--primary', t.primaryColor);
|
||||
@@ -410,6 +534,10 @@ async function verifyToken() {
|
||||
}
|
||||
|
||||
function doLogout() {
|
||||
if (SSO_SESSION) {
|
||||
SSO_SESSION = false;
|
||||
fetch('/api/auth/logout', { method: 'POST' }).catch(() => {});
|
||||
}
|
||||
TOKEN = '';
|
||||
sessionStorage.removeItem('admin_token');
|
||||
document.getElementById('app').style.display = 'none';
|
||||
@@ -425,10 +553,8 @@ function showApp() {
|
||||
|
||||
// ── API helpers ───────────────────────────────────────────────────────────────
|
||||
async function api(method, url, body) {
|
||||
const opts = {
|
||||
method,
|
||||
headers: { 'Authorization': 'Bearer ' + TOKEN, 'Content-Type': 'application/json' },
|
||||
};
|
||||
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
||||
if (TOKEN) opts.headers['Authorization'] = 'Bearer ' + TOKEN;
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
const r = await fetch(url, opts);
|
||||
if (r.status === 401) { doLogout(); throw new Error('Unauthorized'); }
|
||||
@@ -446,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 ──────────────────────────────────────────────────────────────────
|
||||
@@ -823,7 +1060,7 @@ async function confirmPaymentP(id, btn) {
|
||||
|
||||
async function exportCSV() {
|
||||
const r = await fetch('/api/admin/payments/export', {
|
||||
headers: { 'Authorization': 'Bearer ' + TOKEN }
|
||||
headers: TOKEN ? { 'Authorization': 'Bearer ' + TOKEN } : {}
|
||||
});
|
||||
if (!r.ok) { alert('Export failed'); return; }
|
||||
const blob = await r.blob();
|
||||
|
||||
+88
-15
@@ -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);
|
||||
@@ -685,7 +717,7 @@
|
||||
width: 100%;
|
||||
max-width: 680px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -693,6 +725,8 @@
|
||||
transition: transform 0.4s var(--transition-smooth);
|
||||
}
|
||||
|
||||
.booking-header, .booking-nav { flex-shrink: 0; }
|
||||
|
||||
.booking-overlay.active .booking-modal {
|
||||
transform: translateY(0);
|
||||
}
|
||||
@@ -795,11 +829,12 @@
|
||||
|
||||
.booking-step-item.active .step-label-text { color: var(--color-primary); }
|
||||
|
||||
/* Step panels */
|
||||
/* Step panels — the content area scrolls; header and nav stay pinned */
|
||||
.booking-content {
|
||||
padding: 2.5rem 3rem;
|
||||
flex: 1;
|
||||
min-height: 300px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.step-panel { display: none; animation: stepFadeIn 0.3s var(--transition-smooth); }
|
||||
@@ -864,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); }
|
||||
@@ -1377,6 +1416,7 @@
|
||||
<nav id="main-nav">
|
||||
<a href="#portfolio">Portfolio</a>
|
||||
<a href="#client-access">Client Access</a>
|
||||
<a href="/my-bookings">My Bookings</a>
|
||||
<a href="#connect">Connect</a>
|
||||
<button class="nav-book-btn" onclick="openBooking()">Book a Session</button>
|
||||
</nav>
|
||||
@@ -1391,13 +1431,14 @@
|
||||
<div class="mobile-nav" id="mobile-nav">
|
||||
<a href="#portfolio" class="mobile-nav-link">Portfolio</a>
|
||||
<a href="#client-access" class="mobile-nav-link">Client Access</a>
|
||||
<a href="/my-bookings" class="mobile-nav-link">My Bookings</a>
|
||||
<a href="#connect" class="mobile-nav-link">Connect</a>
|
||||
<a href="#" class="mobile-nav-link" onclick="mobileNav.classList.remove('active'); openBooking(); return false;">Book a Session</a>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
@@ -1682,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;
|
||||
@@ -1694,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;
|
||||
@@ -1724,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) {
|
||||
@@ -1747,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
|
||||
@@ -1758,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>
|
||||
@@ -1773,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];
|
||||
@@ -1884,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 ───────────────────────────────────────
|
||||
|
||||
@@ -2602,6 +2666,15 @@ A complete service agreement and model release will be provided at your session
|
||||
}, 1000);
|
||||
|
||||
observeRevealElements();
|
||||
|
||||
// Deep link: /#book opens the booking wizard (used by the client portal)
|
||||
if (window.location.hash === '#book') {
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>My Bookings — Lisi Lou Photography</title>
|
||||
<meta name="robots" content="noindex">
|
||||
<link rel="icon" type="image/x-icon" href="/images/favicon.ico">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;1,400&family=Nunito+Sans:opsz,wght@6..12,300;6..12,400;6..12,600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--primary: #B06A7A;
|
||||
--accent: #E8C4CC;
|
||||
--text: #2C2C2C;
|
||||
--text-muted: #6B6B6B;
|
||||
--bg: #FEF9FA;
|
||||
--border: #F0DCE1;
|
||||
--green: #4C8A5C;
|
||||
--amber: #B08430;
|
||||
--red: #B04A4A;
|
||||
--font-display: 'Cormorant Garamond', serif;
|
||||
--font-body: 'Nunito Sans', sans-serif;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: var(--font-body); background: var(--bg); color: var(--text); min-height: 100vh; line-height: 1.6; }
|
||||
|
||||
header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 1.25rem clamp(1rem, 5vw, 3rem);
|
||||
border-bottom: 1px solid var(--border); background: #fff;
|
||||
}
|
||||
.brand { font-family: var(--font-display); font-weight: 400; font-size: 1.5rem; color: var(--primary); text-decoration: none; letter-spacing: .02em; }
|
||||
.header-actions { display: flex; align-items: center; gap: 1rem; font-size: .85rem; }
|
||||
.header-actions .who { color: var(--text-muted); }
|
||||
|
||||
main { max-width: 760px; margin: 0 auto; padding: clamp(1.5rem, 5vw, 3rem) 1rem 4rem; }
|
||||
h1 { font-family: var(--font-display); font-weight: 300; font-size: clamp(1.8rem, 4vw, 2.4rem); margin-bottom: .35rem; }
|
||||
.sub { color: var(--text-muted); font-size: .95rem; margin-bottom: 2rem; }
|
||||
|
||||
.card {
|
||||
background: #fff; border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 1.5rem; margin-bottom: 1rem;
|
||||
box-shadow: 0 2px 12px rgba(176,106,122,.06);
|
||||
}
|
||||
.booking-row { display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
|
||||
.booking-main .date { font-family: var(--font-display); font-size: 1.35rem; font-weight: 500; }
|
||||
.booking-main .meta { color: var(--text-muted); font-size: .88rem; margin-top: .15rem; }
|
||||
.booking-side { display: flex; flex-direction: column; align-items: flex-end; gap: .5rem; }
|
||||
|
||||
.badge {
|
||||
display: inline-block; padding: .2rem .6rem; border-radius: 99px;
|
||||
font-size: .72rem; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
|
||||
}
|
||||
.badge.confirmed { background: #E7F2EA; color: var(--green); }
|
||||
.badge.pending { background: #FBF3E2; color: var(--amber); }
|
||||
.badge.pending_confirmation { background: #FBF3E2; color: var(--amber); }
|
||||
.badge.cancelled, .badge.refunded { background: #F9E8E8; color: var(--red); }
|
||||
|
||||
.link { color: var(--primary); font-size: .85rem; text-decoration: none; border-bottom: 1px solid var(--accent); padding-bottom: 1px; }
|
||||
.link:hover { border-color: var(--primary); }
|
||||
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: .5rem;
|
||||
font-family: var(--font-body); font-size: .9rem; font-weight: 600;
|
||||
padding: .7rem 1.6rem; border-radius: 99px; cursor: pointer; text-decoration: none;
|
||||
border: 1px solid var(--primary); transition: all .2s ease;
|
||||
}
|
||||
.btn-primary { background: var(--primary); color: #fff; }
|
||||
.btn-primary:hover { background: #9a5a69; }
|
||||
.btn-outline { background: transparent; color: var(--primary); }
|
||||
.btn-outline:hover { background: var(--accent); }
|
||||
.btn-sm { padding: .35rem .9rem; font-size: .8rem; }
|
||||
|
||||
.empty, .signin {
|
||||
text-align: center; padding: 3.5rem 1.5rem;
|
||||
background: #fff; border: 1px solid var(--border); border-radius: 10px;
|
||||
}
|
||||
.empty p, .signin p { color: var(--text-muted); margin-bottom: 1.5rem; }
|
||||
.signin h2, .empty h2 { font-family: var(--font-display); font-weight: 400; margin-bottom: .5rem; }
|
||||
.signin .hint { font-size: .8rem; margin-top: 1.25rem; margin-bottom: 0; }
|
||||
|
||||
footer { text-align: center; padding: 2rem; color: var(--text-muted); font-size: .8rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<a class="brand" href="/">Lisi Lou Photography</a>
|
||||
<div class="header-actions" id="header-actions"></div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<h1>My Bookings</h1>
|
||||
<p class="sub">Your sessions with Lisi Lou Photography</p>
|
||||
<div id="content"><div class="signin"><p>Loading…</p></div></div>
|
||||
</main>
|
||||
|
||||
<footer>Questions about a booking? <a class="link" id="contact-link" href="mailto:hello@lisilou.com">Get in touch</a></footer>
|
||||
|
||||
<script>
|
||||
const LENGTH_LABELS = { mini: 'Mini Session', full: 'Full Session' };
|
||||
let siteConfig = {};
|
||||
|
||||
(async function init() {
|
||||
try {
|
||||
siteConfig = await (await fetch('/config/site.json')).json();
|
||||
applyTheme(siteConfig.theme || {});
|
||||
const email = siteConfig.contact && siteConfig.contact.email;
|
||||
if (email) document.getElementById('contact-link').href = 'mailto:' + email;
|
||||
} catch (e) { /* defaults are fine */ }
|
||||
|
||||
let me = { authenticated: false, ssoConfigured: false };
|
||||
try { me = await (await fetch('/api/auth/me')).json(); } catch (e) { /* API down */ }
|
||||
|
||||
if (!me.authenticated) return renderSignin(me.ssoConfigured);
|
||||
|
||||
document.getElementById('header-actions').innerHTML =
|
||||
'<span class="who">' + esc(me.name || me.email || '') + '</span>' +
|
||||
'<button class="btn btn-outline btn-sm" onclick="signOut()">Sign out</button>';
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/my-bookings');
|
||||
if (!r.ok) throw new Error(r.status);
|
||||
renderBookings(await r.json());
|
||||
} catch (e) {
|
||||
document.getElementById('content').innerHTML =
|
||||
'<div class="empty"><h2>Something went wrong</h2><p>We couldn\'t load your bookings. Please try again shortly.</p></div>';
|
||||
}
|
||||
})();
|
||||
|
||||
function applyTheme(t) {
|
||||
const r = document.documentElement.style;
|
||||
if (t.primaryColor) r.setProperty('--primary', t.primaryColor);
|
||||
if (t.accentColor) r.setProperty('--accent', t.accentColor);
|
||||
if (t.backgroundColor) r.setProperty('--bg', t.backgroundColor);
|
||||
if (t.textColor) r.setProperty('--text', t.textColor);
|
||||
}
|
||||
|
||||
function renderSignin(ssoConfigured) {
|
||||
document.getElementById('content').innerHTML = ssoConfigured
|
||||
? '<div class="signin"><h2>Sign in to view your bookings</h2>' +
|
||||
'<p>Use the account you created when booking your session.</p>' +
|
||||
'<a class="btn btn-primary" href="/api/auth/login?redirect=%2Fmy-bookings">Sign In</a>' +
|
||||
'<p class="hint">First time here? Signing in will let you create an account.</p></div>'
|
||||
: '<div class="signin"><h2>Client sign-in isn\'t available yet</h2>' +
|
||||
'<p>Please <a class="link" href="/#contact">contact us</a> about your booking.</p></div>';
|
||||
}
|
||||
|
||||
function renderBookings(bookings) {
|
||||
if (!bookings.length) {
|
||||
document.getElementById('content').innerHTML =
|
||||
'<div class="empty"><h2>No sessions yet</h2><p>When you book a session it will show up here.</p>' +
|
||||
'<a class="btn btn-primary" href="/#book">Book a Session</a></div>';
|
||||
return;
|
||||
}
|
||||
const typeLabel = id => {
|
||||
const t = (siteConfig.booking && siteConfig.booking.sessionTypes || []).find(t => t.id === id);
|
||||
return t ? t.label : (id || 'Session');
|
||||
};
|
||||
const locLabel = id => {
|
||||
const l = (siteConfig.locations || []).find(l => l.id === id);
|
||||
return l ? l.name : (id || '');
|
||||
};
|
||||
const fmtDate = d => {
|
||||
if (!d) return 'Date TBD';
|
||||
const dt = new Date(d + 'T12:00:00');
|
||||
return isNaN(dt) ? d : dt.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||
};
|
||||
const payLabel = { pending: 'Payment pending', pending_confirmation: 'Payment sent', confirmed: 'Paid', refunded: 'Refunded' };
|
||||
|
||||
document.getElementById('content').innerHTML = bookings.map(b => {
|
||||
const badgeClass = b.status === 'cancelled' ? 'cancelled' : (b.payment_status || 'pending');
|
||||
const badgeText = b.status === 'cancelled' ? 'Cancelled' : (payLabel[b.payment_status] || b.payment_status);
|
||||
const contract = b.contract_signed_at
|
||||
? '<a class="link" href="/api/my-bookings/' + b.id + '/contract">Download signed contract</a>'
|
||||
: '<span class="link" style="border:none;color:var(--text-muted);">Contract not signed yet</span>';
|
||||
return '<div class="card"><div class="booking-row">' +
|
||||
'<div class="booking-main">' +
|
||||
'<div class="date">' + esc(fmtDate(b.session_date)) + '</div>' +
|
||||
'<div class="meta">' + esc(typeLabel(b.session_type)) + ' · ' + esc(LENGTH_LABELS[b.session_length] || b.session_length || '') +
|
||||
(b.location ? ' · ' + esc(locLabel(b.location)) : '') + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="booking-side"><span class="badge ' + badgeClass + '">' + esc(badgeText) + '</span>' + contract + '</div>' +
|
||||
'</div></div>';
|
||||
}).join('') +
|
||||
'<div style="text-align:center;margin-top:2rem;"><a class="btn btn-outline" href="/#book">Book Another Session</a></div>';
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
try { await fetch('/api/auth/logout', { method: 'POST' }); } catch (e) {}
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,80 @@
|
||||
// Auth + client portal (issues #10, #13)
|
||||
// These tests pass whether or not OIDC env vars are configured on the target:
|
||||
// configured → /api/auth/login redirects to the Authentik authorize endpoint
|
||||
// unconfigured → /api/auth/login returns 503 and sessions simply don't exist
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
test.describe('Auth endpoints', () => {
|
||||
test('GET /api/auth/me reports session state', async ({ request }) => {
|
||||
const r = await request.get('/api/auth/me');
|
||||
expect(r.ok()).toBeTruthy();
|
||||
const body = await r.json();
|
||||
expect(body).toHaveProperty('authenticated');
|
||||
expect(typeof body.authenticated).toBe('boolean');
|
||||
expect(body).toHaveProperty('ssoConfigured');
|
||||
});
|
||||
|
||||
test('GET /api/auth/login redirects to IdP or returns 503', async ({ request }) => {
|
||||
const r = await request.get('/api/auth/login', { maxRedirects: 0 });
|
||||
expect([302, 503]).toContain(r.status());
|
||||
if (r.status() === 302) {
|
||||
const loc = r.headers()['location'];
|
||||
expect(loc).toContain('response_type=code');
|
||||
expect(loc).toContain('code_challenge_method=S256');
|
||||
}
|
||||
});
|
||||
|
||||
test('POST /api/auth/logout always succeeds and clears cookie', async ({ request }) => {
|
||||
const r = await request.post('/api/auth/logout');
|
||||
expect(r.ok()).toBeTruthy();
|
||||
expect((await r.json()).ok).toBe(true);
|
||||
});
|
||||
|
||||
test('session cookie tampering is rejected', async ({ request }) => {
|
||||
const forged = Buffer.from(JSON.stringify({ sub: 'x', admin: true, exp: 9999999999 }))
|
||||
.toString('base64url') + '.forgedsignature';
|
||||
const r = await request.get('/api/my-bookings', {
|
||||
headers: { Cookie: `lisilou_sess=${forged}` },
|
||||
});
|
||||
expect(r.status()).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Client portal page', () => {
|
||||
test('/my-bookings serves the portal', async ({ page }) => {
|
||||
await page.goto('/my-bookings');
|
||||
await expect(page.locator('h1')).toHaveText('My Bookings');
|
||||
});
|
||||
|
||||
test('signed-out visitor sees a sign-in prompt, not bookings', async ({ page }) => {
|
||||
await page.goto('/my-bookings');
|
||||
await expect(page.locator('#content .signin')).toBeVisible();
|
||||
await expect(page.locator('.booking-row')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('/api/my-bookings requires a session', async ({ request }) => {
|
||||
const r = await request.get('/api/my-bookings');
|
||||
expect(r.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('contract download requires a session', async ({ request }) => {
|
||||
const r = await request.get('/api/my-bookings/1/contract');
|
||||
expect(r.status()).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Admin auth still works', () => {
|
||||
test('legacy ADMIN_SECRET bearer is accepted', async ({ request }) => {
|
||||
const r = await request.get('/api/admin/stats', {
|
||||
headers: { Authorization: `Bearer ${process.env.ADMIN_SECRET}` },
|
||||
});
|
||||
// 200 when the target's ADMIN_SECRET matches the test env; 401 otherwise —
|
||||
// either way the endpoint is auth-gated, never open or 500
|
||||
expect([200, 401]).toContain(r.status());
|
||||
});
|
||||
|
||||
test('no credentials at all is rejected', async ({ request }) => {
|
||||
const r = await request.get('/api/admin/bookings');
|
||||
expect([401, 503]).toContain(r.status());
|
||||
});
|
||||
});
|
||||
+25
-4
@@ -13,14 +13,14 @@ test.describe('Admin dashboard — auth', () => {
|
||||
test('wrong passphrase shows error message', async ({ page }) => {
|
||||
await page.goto('/dashboard');
|
||||
await page.locator('#login-input').fill('wrongpassphrase');
|
||||
await page.locator('button:has-text("Sign In")').click();
|
||||
await page.locator('[id=login-btn]').click();
|
||||
await expect(page.locator('#login-error')).toBeVisible({ timeout: 6_000 });
|
||||
});
|
||||
|
||||
test('correct passphrase shows the app', async ({ page }) => {
|
||||
await page.goto('/dashboard');
|
||||
await page.locator('#login-input').fill(ADMIN_SECRET);
|
||||
await page.locator('button:has-text("Sign In")').click();
|
||||
await page.locator('[id=login-btn]').click();
|
||||
await expect(page.locator('#app')).toBeVisible({ timeout: 8_000 });
|
||||
await expect(page.locator('#login-screen')).not.toBeVisible();
|
||||
});
|
||||
@@ -35,7 +35,7 @@ test.describe('Admin dashboard — auth', () => {
|
||||
test('Sign Out returns to login screen', async ({ page }) => {
|
||||
await page.goto('/dashboard');
|
||||
await page.locator('#login-input').fill(ADMIN_SECRET);
|
||||
await page.locator('button:has-text("Sign In")').click();
|
||||
await page.locator('[id=login-btn]').click();
|
||||
await expect(page.locator('#app')).toBeVisible({ timeout: 8_000 });
|
||||
await page.locator('button:has-text("Sign Out")').click();
|
||||
await expect(page.locator('#login-screen')).toBeVisible();
|
||||
@@ -46,7 +46,7 @@ test.describe('Admin dashboard — panels', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/dashboard');
|
||||
await page.locator('#login-input').fill(ADMIN_SECRET);
|
||||
await page.locator('button:has-text("Sign In")').click();
|
||||
await page.locator('[id=login-btn]').click();
|
||||
await expect(page.locator('#app')).toBeVisible({ timeout: 8_000 });
|
||||
});
|
||||
|
||||
@@ -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