Compare commits
2 Commits
bfe374206c
...
a67fdba170
| Author | SHA1 | Date | |
|---|---|---|---|
| a67fdba170 | |||
| 84fdfdfdb8 |
@@ -17,11 +17,16 @@ jobs:
|
|||||||
- name: Pull latest changes
|
- name: Pull latest changes
|
||||||
run: |
|
run: |
|
||||||
cd /opt/lisilou-portfolio
|
cd /opt/lisilou-portfolio
|
||||||
git pull origin main
|
git fetch origin main
|
||||||
|
git reset --hard origin/main
|
||||||
|
|
||||||
- name: Build and restart containers
|
- name: Build and restart containers
|
||||||
run: |
|
run: |
|
||||||
cd /opt/lisilou-portfolio
|
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 build
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
docker compose ps
|
docker compose ps
|
||||||
@@ -32,5 +37,12 @@ jobs:
|
|||||||
|
|
||||||
- name: Verify deployment
|
- name: Verify deployment
|
||||||
run: |
|
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
|
sleep 5
|
||||||
curl -f http://localhost:8080/health || echo "Health check failed"
|
done
|
||||||
|
echo "Health check failed after 120s"
|
||||||
|
exit 1
|
||||||
|
|||||||
@@ -19,8 +19,12 @@ CONTRACT_SIG_Y=700
|
|||||||
CONTRACT_SIG_PAGE=1
|
CONTRACT_SIG_PAGE=1
|
||||||
|
|
||||||
# OIDC / Authentik (issue #10)
|
# 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=
|
SESSION_SECRET=
|
||||||
OIDC_ISSUER=
|
OIDC_ISSUER=
|
||||||
OIDC_CLIENT_ID=
|
OIDC_CLIENT_ID=
|
||||||
OIDC_CLIENT_SECRET=
|
OIDC_CLIENT_SECRET=
|
||||||
OIDC_REDIRECT_URI=
|
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 };
|
||||||
+66
-8
@@ -5,6 +5,7 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const db = require('./db');
|
const db = require('./db');
|
||||||
const { getBusyDates } = require('./google-calendar');
|
const { getBusyDates } = require('./google-calendar');
|
||||||
|
const auth = require('./auth');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
@@ -32,14 +33,33 @@ function notify(event, booking, extra = {}) {
|
|||||||
}).catch(e => console.error(`[notify] ${event} failed:`, e.message));
|
}).catch(e => console.error(`[notify] ${event} failed:`, e.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Admin auth ────────────────────────────────────────────────────────────────
|
// ── Auth (issue #10) ──────────────────────────────────────────────────────────
|
||||||
// TODO (#10): swap this bearer-token check for Authentik OIDC session validation
|
// Admin access is granted by either:
|
||||||
// when issue #10 lands. The middleware signature stays the same; only the check changes.
|
// 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) {
|
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;
|
const secret = process.env.ADMIN_SECRET;
|
||||||
if (!secret) return res.status(503).json({ error: 'Admin access not configured (set ADMIN_SECRET)' });
|
const header = req.headers.authorization || '';
|
||||||
const auth = req.headers.authorization || '';
|
if (secret && header === `Bearer ${secret}`) return next();
|
||||||
if (auth !== `Bearer ${secret}`) return res.status(401).json({ error: 'Unauthorized' });
|
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();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +126,11 @@ app.post('/api/bookings', (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Missing required fields' });
|
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';
|
const ps = payment_status || 'pending';
|
||||||
// Record when the client indicated they sent payment
|
// Record when the client indicated they sent payment
|
||||||
const paymentNotifiedAt = ps === 'pending_confirmation' ? new Date().toISOString() : null;
|
const paymentNotifiedAt = ps === 'pending_confirmation' ? new Date().toISOString() : null;
|
||||||
@@ -116,7 +141,7 @@ app.post('/api/bookings', (req, res) => {
|
|||||||
session_length, location, payment_status, payment_notified_at)
|
session_length, location, payment_status, payment_notified_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
client_name, client_email, client_phone, client_sub,
|
client_name, client_email, client_phone, sub,
|
||||||
session_date, session_type, session_length, location,
|
session_date, session_type, session_length, location,
|
||||||
ps, paymentNotifiedAt,
|
ps, paymentNotifiedAt,
|
||||||
);
|
);
|
||||||
@@ -210,7 +235,40 @@ app.post('/api/bookings/:id/sign', async (req, res) => {
|
|||||||
res.json({ ok: true, signed_at: now, pdf_path: pdfPath });
|
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
|
// Stats: counts + next upcoming bookings for the Overview panel
|
||||||
app.get('/api/admin/stats', requireAdmin, (req, res) => {
|
app.get('/api/admin/stats', requireAdmin, (req, res) => {
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ http {
|
|||||||
try_files $uri /dashboard.html;
|
try_files $uri /dashboard.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Client portal (issue #13)
|
||||||
|
location /my-bookings {
|
||||||
|
try_files $uri /my-bookings.html;
|
||||||
|
}
|
||||||
|
|
||||||
# SPA fallback
|
# SPA fallback
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
+36
-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-card input:focus { border-color: var(--primary); }
|
||||||
.login-error { color: var(--red); font-size: .8rem; margin-bottom: .75rem; display: none; }
|
.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 shell ─────────────────────────────────────── */
|
||||||
#app { display: none; flex-direction: column; min-height: 100vh; }
|
#app { display: none; flex-direction: column; min-height: 100vh; }
|
||||||
@@ -230,8 +236,10 @@ tr.expanded td { background: var(--bg); }
|
|||||||
<h1>LisiLou</h1>
|
<h1>LisiLou</h1>
|
||||||
<p>Admin Dashboard</p>
|
<p>Admin Dashboard</p>
|
||||||
<p class="login-error" id="login-error">Incorrect passphrase.</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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -340,6 +348,7 @@ tr.expanded td { background: var(--bg); }
|
|||||||
<script>
|
<script>
|
||||||
// ── Config & state ────────────────────────────────────────────────────────────
|
// ── Config & state ────────────────────────────────────────────────────────────
|
||||||
let TOKEN = sessionStorage.getItem('admin_token') || '';
|
let TOKEN = sessionStorage.getItem('admin_token') || '';
|
||||||
|
let SSO_SESSION = false;
|
||||||
let siteConfig = {};
|
let siteConfig = {};
|
||||||
let allBookings = [];
|
let allBookings = [];
|
||||||
let calYear = new Date().getFullYear();
|
let calYear = new Date().getFullYear();
|
||||||
@@ -364,12 +373,31 @@ function fullPrice() { return siteConfig?.booking?.pricing?.full || FULL_PRICE_D
|
|||||||
document.getElementById('p-full').textContent = fullPrice();
|
document.getElementById('p-full').textContent = fullPrice();
|
||||||
} catch(e) { /* non-fatal */ }
|
} 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) {
|
if (TOKEN) {
|
||||||
const ok = await verifyToken();
|
const ok = await verifyToken();
|
||||||
if (ok) showApp();
|
if (ok) showApp();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
function ssoLogin() {
|
||||||
|
window.location.href = '/api/auth/login?redirect=' + encodeURIComponent('/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
function applyTheme(t) {
|
function applyTheme(t) {
|
||||||
const r = document.documentElement.style;
|
const r = document.documentElement.style;
|
||||||
if (t.primaryColor) r.setProperty('--primary', t.primaryColor);
|
if (t.primaryColor) r.setProperty('--primary', t.primaryColor);
|
||||||
@@ -410,6 +438,10 @@ async function verifyToken() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function doLogout() {
|
function doLogout() {
|
||||||
|
if (SSO_SESSION) {
|
||||||
|
SSO_SESSION = false;
|
||||||
|
fetch('/api/auth/logout', { method: 'POST' }).catch(() => {});
|
||||||
|
}
|
||||||
TOKEN = '';
|
TOKEN = '';
|
||||||
sessionStorage.removeItem('admin_token');
|
sessionStorage.removeItem('admin_token');
|
||||||
document.getElementById('app').style.display = 'none';
|
document.getElementById('app').style.display = 'none';
|
||||||
@@ -425,10 +457,8 @@ function showApp() {
|
|||||||
|
|
||||||
// ── API helpers ───────────────────────────────────────────────────────────────
|
// ── API helpers ───────────────────────────────────────────────────────────────
|
||||||
async function api(method, url, body) {
|
async function api(method, url, body) {
|
||||||
const opts = {
|
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
||||||
method,
|
if (TOKEN) opts.headers['Authorization'] = 'Bearer ' + TOKEN;
|
||||||
headers: { 'Authorization': 'Bearer ' + TOKEN, 'Content-Type': 'application/json' },
|
|
||||||
};
|
|
||||||
if (body) opts.body = JSON.stringify(body);
|
if (body) opts.body = JSON.stringify(body);
|
||||||
const r = await fetch(url, opts);
|
const r = await fetch(url, opts);
|
||||||
if (r.status === 401) { doLogout(); throw new Error('Unauthorized'); }
|
if (r.status === 401) { doLogout(); throw new Error('Unauthorized'); }
|
||||||
@@ -823,7 +853,7 @@ async function confirmPaymentP(id, btn) {
|
|||||||
|
|
||||||
async function exportCSV() {
|
async function exportCSV() {
|
||||||
const r = await fetch('/api/admin/payments/export', {
|
const r = await fetch('/api/admin/payments/export', {
|
||||||
headers: { 'Authorization': 'Bearer ' + TOKEN }
|
headers: TOKEN ? { 'Authorization': 'Bearer ' + TOKEN } : {}
|
||||||
});
|
});
|
||||||
if (!r.ok) { alert('Export failed'); return; }
|
if (!r.ok) { alert('Export failed'); return; }
|
||||||
const blob = await r.blob();
|
const blob = await r.blob();
|
||||||
|
|||||||
+13
-3
@@ -685,7 +685,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 680px;
|
max-width: 680px;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
overflow-y: auto;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -693,6 +693,8 @@
|
|||||||
transition: transform 0.4s var(--transition-smooth);
|
transition: transform 0.4s var(--transition-smooth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.booking-header, .booking-nav { flex-shrink: 0; }
|
||||||
|
|
||||||
.booking-overlay.active .booking-modal {
|
.booking-overlay.active .booking-modal {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
@@ -795,11 +797,12 @@
|
|||||||
|
|
||||||
.booking-step-item.active .step-label-text { color: var(--color-primary); }
|
.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 {
|
.booking-content {
|
||||||
padding: 2.5rem 3rem;
|
padding: 2.5rem 3rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 300px;
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.step-panel { display: none; animation: stepFadeIn 0.3s var(--transition-smooth); }
|
.step-panel { display: none; animation: stepFadeIn 0.3s var(--transition-smooth); }
|
||||||
@@ -1377,6 +1380,7 @@
|
|||||||
<nav id="main-nav">
|
<nav id="main-nav">
|
||||||
<a href="#portfolio">Portfolio</a>
|
<a href="#portfolio">Portfolio</a>
|
||||||
<a href="#client-access">Client Access</a>
|
<a href="#client-access">Client Access</a>
|
||||||
|
<a href="/my-bookings">My Bookings</a>
|
||||||
<a href="#connect">Connect</a>
|
<a href="#connect">Connect</a>
|
||||||
<button class="nav-book-btn" onclick="openBooking()">Book a Session</button>
|
<button class="nav-book-btn" onclick="openBooking()">Book a Session</button>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -1391,6 +1395,7 @@
|
|||||||
<div class="mobile-nav" id="mobile-nav">
|
<div class="mobile-nav" id="mobile-nav">
|
||||||
<a href="#portfolio" class="mobile-nav-link">Portfolio</a>
|
<a href="#portfolio" class="mobile-nav-link">Portfolio</a>
|
||||||
<a href="#client-access" class="mobile-nav-link">Client Access</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="#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>
|
<a href="#" class="mobile-nav-link" onclick="mobileNav.classList.remove('active'); openBooking(); return false;">Book a Session</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -2602,6 +2607,11 @@ A complete service agreement and model release will be provided at your session
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
observeRevealElements();
|
observeRevealElements();
|
||||||
|
|
||||||
|
// Deep link: /#book opens the booking wizard (used by the client portal)
|
||||||
|
if (window.location.hash === '#book') {
|
||||||
|
setTimeout(openBooking, 400);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js" crossorigin="anonymous"></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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,14 +13,14 @@ test.describe('Admin dashboard — auth', () => {
|
|||||||
test('wrong passphrase shows error message', async ({ page }) => {
|
test('wrong passphrase shows error message', async ({ page }) => {
|
||||||
await page.goto('/dashboard');
|
await page.goto('/dashboard');
|
||||||
await page.locator('#login-input').fill('wrongpassphrase');
|
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 });
|
await expect(page.locator('#login-error')).toBeVisible({ timeout: 6_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('correct passphrase shows the app', async ({ page }) => {
|
test('correct passphrase shows the app', async ({ page }) => {
|
||||||
await page.goto('/dashboard');
|
await page.goto('/dashboard');
|
||||||
await page.locator('#login-input').fill(ADMIN_SECRET);
|
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('#app')).toBeVisible({ timeout: 8_000 });
|
||||||
await expect(page.locator('#login-screen')).not.toBeVisible();
|
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 }) => {
|
test('Sign Out returns to login screen', async ({ page }) => {
|
||||||
await page.goto('/dashboard');
|
await page.goto('/dashboard');
|
||||||
await page.locator('#login-input').fill(ADMIN_SECRET);
|
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('#app')).toBeVisible({ timeout: 8_000 });
|
||||||
await page.locator('button:has-text("Sign Out")').click();
|
await page.locator('button:has-text("Sign Out")').click();
|
||||||
await expect(page.locator('#login-screen')).toBeVisible();
|
await expect(page.locator('#login-screen')).toBeVisible();
|
||||||
@@ -46,7 +46,7 @@ test.describe('Admin dashboard — panels', () => {
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto('/dashboard');
|
await page.goto('/dashboard');
|
||||||
await page.locator('#login-input').fill(ADMIN_SECRET);
|
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('#app')).toBeVisible({ timeout: 8_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user