- api/auth.js: zero-dep OIDC authorization-code flow with PKCE against Authentik; HMAC-signed HttpOnly session cookies (SESSION_SECRET) - requireAdmin now accepts an OIDC session in the admin group; legacy ADMIN_SECRET bearer kept for n8n and scripts - New client endpoints: GET /api/my-bookings, owner-gated contract download - Bookings created while signed in are bound to the client's OIDC sub - Dashboard: "Sign in with SSO" alongside passphrase fallback - New /my-bookings portal page (nginx route + themed page) - Fix booking modal: content area now scrolls; nav no longer overlaps the calendar on short viewports (was swallowing clicks on date cells) - 10 new Playwright tests; suite green at 67 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+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-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; }
|
||||
@@ -230,8 +236,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>
|
||||
|
||||
@@ -340,6 +348,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 +373,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 +438,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 +457,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'); }
|
||||
@@ -823,7 +853,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();
|
||||
|
||||
+13
-3
@@ -685,7 +685,7 @@
|
||||
width: 100%;
|
||||
max-width: 680px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -693,6 +693,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 +797,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); }
|
||||
@@ -1377,6 +1380,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,6 +1395,7 @@
|
||||
<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>
|
||||
@@ -2602,6 +2607,11 @@ 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);
|
||||
}
|
||||
});
|
||||
</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>
|
||||
Reference in New Issue
Block a user