Implements the recurring-customer login surface from
the-luap/picpeak#354 plugged into the maintainer's
new feature-flag infrastructure (PR #443) instead of
a parallel toggle.
* New `customerPortal` feature flag (foundation flag for the
not-yet-built calendar/quotes/bills/messaging customer
surfaces). Defaults FALSE on fresh installs, TRUE on existing
installs (events > 0) via migration 095 so live customer
accounts don't disappear mid-deployment.
* Foundation schema: customer_accounts, customer_invitations,
event_customer_assignments, customer_password_resets, plus
RBAC permissions customers.view / .create / .delete granted
to super_admin + admin system roles.
* Backend: /api/admin/customers (invite, list, search, assign,
deactivate, reset password) + /api/customer/auth/* +
/api/customer/* (login, dashboard, accept-invite, reset).
Customer JWT bypass minted via
/api/customer/events/:slug/access-token so existing gallery
middleware stays untouched.
* Frontend: /customer/* route tree gated by RequireFeature flag
customerPortal, with login / dashboard / accept-invite /
reset pages and a customer-side sidebar layout.
/admin/customers and /admin/customers/:id gated identically.
* Settings → Features grows a "Customers" section with a
Customer portal card. The maintainer's Features tab stays the
single source of truth — no parallel Advanced features tab.
* CustomerAccountPicker on event create/edit forms hides itself
when the flag is off; backend ignores customer_account_ids in
that case instead of erroring the whole event save.
Translations: en + de hand-translated. nl/pt/ru fall through to
en — flagged here as needing native review.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
Reorganises the admin sidebar around what users actually do, and adds a
single Features page that gates which feature surfaces appear in the
nav. Shrinks the main sidebar from 11 items to 4-6 (depending on
feature flags) and groups configuration screens into a single Settings
home with six logical sections.
Why
---
The current sidebar mixes three concerns: workspaces (Dashboard, Events,
Archives), feature surfaces (Analytics, Users), and configuration
screens that get touched maybe once a month (Email Settings, Branding,
Event Types, Backup, CMS Pages). That's 11 items, half of them config.
Backend
-------
- New `feature_flags` table (key, value, updated_at, updated_by).
Migration 088 detects existing-vs-fresh installs from the events
table:
* Existing install (events>0) → all 9 flags TRUE so nothing
vanishes from an admin's UI on upgrade.
* Fresh install (events=0) → spec defaults: galleries,
reminderEmails, analytics, userManagement TRUE; calendar,
calendarBooking, quotes, bills, messaging FALSE.
- New `/api/admin/feature-flags` (GET/PUT) under `settings.view` and
`settings.edit`. Server enforces the same dependency rules the
frontend does (galleries always TRUE, quotes=false → bills=false,
calendar=false → calendarBooking=false). PUT writes one
`feature_flags_updated` activity log row with the diff.
Frontend
--------
- `FeatureFlagsContext` provides `useFeatureFlags()` (with staged/save/
reset/isDirty) and `useFeatureEnabled(key)`. Mounted inside
AdminLayout so flag fetches carry the auth cookie. Source of truth
is the server response; staged is a local copy that the Features tab
edits and the Save button PUTs.
- `RequireFeature` route guard for /admin/analytics and /admin/users —
redirects to /admin/dashboard when the corresponding flag is OFF.
- AdminSidebar dropped from 11 to 6 items. Removed: Email Settings,
Branding, Event Types, Backup, CMS Pages (now Settings tabs).
Feature-gated: Analytics, Users.
- Old top-level routes (/admin/email, /admin/branding, /admin/event-
types, /admin/backup, /admin/cms) kept as <Navigate> redirects to
/admin/settings?tab=<key> so existing bookmarks don't 404.
- SettingsPage rewritten with a 6-group inner-nav (General /
Content & Appearance / Communication / Privacy & Security /
Integrations / System) and 19 tabs. New Features tab is the
default landing tab. URL ?tab=<key> roundtrips with state — deep
links and the back button work.
- FeaturesTab renders 9 cards across 5 sections. Toggles enabled for
Analytics + User Management (the two flags that gate sidebar items
in this PR). All other toggles disabled with a "Not yet available"
lockedReason — the cards still render so admins see the roadmap, but
the flag has no UI effect until the surface ships in its own PR. The
galleries card is locked TRUE per spec (foundation, can't be off).
- Live SidebarPreview reflects unsaved staged changes — admins see
what their sidebar will look like before they save.
- New i18n keys across all 5 locales (en, de, nl, pt, ru) for the
Features tab copy, the new Settings group labels, and the lifted
tab titles.
Verified end-to-end
-------------------
- Migration on this dev DB (existing install, 977 events): all 9 flags
set to TRUE.
- Migration on simulated fresh install (events table emptied): spec
defaults applied (5 OFF, 4 ON).
- Backend round-trip: GET → PUT → audit-log entry written, dependency
rule enforced (bills forced false when quotes=false even when bills=
true requested).
- UI Playwright spec: sidebar dropped 5 items, old top-level routes
redirect, Features tab is default, Galleries+Calendar+Quotes+Bills+
Messaging+ReminderEmails toggles disabled, Analytics+Users toggles
enabled, toggling Analytics off + saving updates the sidebar +
redirects /admin/analytics to /admin/dashboard.
- Smoke 13/13 still green; no regressions on existing flows.
Two follow-ups from PR #401's review:
1. Download button text was hardcoded `color: '#ffffff'`. Once admins
start picking palettes via #400's expanded customizer, a pale accent
(yellow, pastel blue, etc.) leaves the button unreadable — white
text on near-white background.
Fix: derive the foreground colour from the accent's WCAG relative
luminance and expose it as the new `--color-accent-fg` CSS variable
in ThemeContext.applyTheme. Light backgrounds (L >= 0.5) get black
text; dark backgrounds get white. Same treatment applied to
`--color-accent-dark-fg` for the filled-CTA token.
The Download button now reads `var(--color-accent-fg, #ffffff)` so
any future component that paints on accent gets the same treatment
for free, and legacy deployments before the variable is set fall
back to the previous hardcoded white.
Threshold-based (rather than "highest contrast ratio") to preserve
how saturated mid-tone accents have always rendered. The Picpeak
default green (#5C8762, L≈0.20) keeps white text — same visual
identity as before. Only genuinely pale accents flip to black,
which is the actual scenario the review flagged.
2. The Download button JSX was duplicated three times in
GalleryLayout.tsx (standard/banner, minimal, hero — ~15 lines
each). Extracted into a small inline `HeaderDownloadButton`
component above the GalleryLayout export. Three call sites now
collapse to a 5-line component invocation each. Markup,
accessibility, and styling live in one place — future tweaks
only need to happen once.
## Files
- `frontend/src/utils/contrast.ts` — new helper module:
`relativeLuminance(hex)` (WCAG 2.x sRGB luminance) and
`getReadableForeground(hex)` (white-or-black picker).
- `frontend/src/utils/__tests__/contrast.test.ts` — 10 cases:
fallbacks, saturated mid-tones, pale accents, near-black,
shorthand `#RGB`, no-leading-`#`, case-insensitive, anchors
(black/white luminance).
- `frontend/src/contexts/ThemeContext.tsx` — wire the helper into
`applyTheme`: set `--color-accent-fg` from `accentColor` and
`--color-accent-dark-fg` from `accentDarkColor`/`primaryColor`.
- `frontend/src/components/gallery/GalleryLayout.tsx` — extract
`HeaderDownloadButton` component above `GalleryLayout`, replace
three inline button blocks with the component, update its inline
style to read `--color-accent-fg` (with the legacy `#ffffff` as
the CSS-variable fallback).
## Verified
- `npx vitest run src/utils/__tests__/contrast.test.ts` — 10/10 pass
- `npx tsc --noEmit` — clean
- `npx eslint` clean on every touched file
- Default PicPeak green still renders white text (no regression)
- Pale accent (#fef9c3 yellow-100) now correctly renders black text
Opening a gallery with a dark theme briefly painted a white background
between the initial HTML render and React applying the per-event theme.
The HTML shipped with no theme info, so the first paint used the
default (#fafafa) before /gallery/:slug/info resolved.
Two-part fix.
1. Inline bootstrap script in index.html runs synchronously before React
mounts. Reads the URL, looks up a per-slug background colour from
localStorage (gallery-theme-bg-<slug>), and applies it to
documentElement immediately. Falls back to #171717 when no cache
exists and the OS prefers dark, so first visits with dark OS still
land on a dark background.
2. ThemeContext.applyTheme writes the resolved background to
localStorage keyed by slug whenever a gallery theme loads. Revisits
then hit the bootstrap cache and never see a flash.
Added a 200ms transition on html.background-color so the rare
cache→API drift (e.g. theme palette changed admin-side since last
visit) is a smooth fade instead of a snap.
Limitation: first visit on a light-OS device to a dark gallery still
flashes once. Killing that case requires a server-rendered theme hint,
out of scope for an SPA bootstrap fix.
The empty-skeleton-grid part of the same report is already addressed
by the 300ms lazy render in #352 — Rekoo-PS just needs to update from
v3.32.1-beta.0 to v3.32.2-beta.0+.
Pre-dedup: 7 calls to /api/public/settings on a single /admin/login page
load — 4 from raw-fetch consumers + 3 from React Query consumers using
inconsistent queryKeys. Captured live in Chrome DevTools.
Post-dedup: 1 call. Verified by tests/e2e/public-settings-dedup.spec.ts.
Adds:
- frontend/src/hooks/usePublicSettings.ts — single React Query hook,
60s staleTime, queryKey ['public-settings']. Vitest with mocked api
proves multi-mount dedup.
- Extended PublicSettings interface with seo_meta_* fields used by
RobotsMetaTags and branding_logo_* fields used by GalleryView/AdminHeader.
Migrates 19 call sites across 4 risk-ordered rounds:
- Round A (high fan-in): GlobalThemeProvider, MaintenanceContext (with
refetchInterval to preserve maintenance polling), MaintenanceWrapper
(drops the now-redundant per-route ping; axios interceptor already
handles 503), AdminHeader.
- Round B (gallery/login): GalleryView, GalleryPage, ClientAccessPage,
AdminLoginPage, MaintenanceMode.
- Round C (decorative): RobotsMetaTags, DynamicFavicon, CMSContentBlock,
ReCaptcha, useWatermarkSettings (rips out raw fetch + local state),
LegalPage.
- Round D (queryKey alignment): useLocalizedDate, UserPhotoUpload,
CreateEventPage. EventDetailsPage Round D ships in the follow-up
commit that adds presigned-download UI on the same page.
App.tsx (AnalyticsBootstrap) and EventDetailsPage are deferred to
later commits — both files mix #325 changes with backend feature work.
Introduces a new "Per-guest selections" identity mode for event
feedback, letting each visitor register under their own name so their
likes/favorites/comments/ratings are tracked independently. Includes
admin insights (list, per-guest detail, aggregate view, export) and
advanced identity features (forget-me, email recovery, invite tokens,
merge).
New event-level setting
- event_feedback_settings.identity_mode = 'simple' | 'guest' (default
'simple' → zero behavior change for existing events).
- Admin UI radio under Feedback Settings to toggle per event.
Root cause of the previous "all guests share state" bug
- generateGuestIdentifier() was sha256(ip + userAgent), so every visitor
on the same WiFi + similar device collided into one identity.
- Now: when a verified guest JWT is present (x-guest-token header),
req.guest.identifier takes precedence — per-person rate limits and
per-person deduplication.
Phase 1 — identity layer
- Migration 078: new gallery_guests, guest_invites, guest_verification_
codes tables; identity_mode column + check constraint; nullable
guest_id FK on photo_feedback.
- New guest JWT type scoped to (eventId, guestId).
- New middleware guestAuth.resolveGuest (non-blocking) + requireGuest.
- POST /gallery/:slug/guest, GET /guest/me, DELETE /guest/me.
- Gallery feedback route enforces guest identity in guest mode and
reads name/email from the verified token (never from the body).
- Frontend GuestIdentityContext + GuestNamePromptModal; axios
interceptor injects x-guest-token on gallery API calls.
- Feedback-only blocking: gallery opens freely, prompt only on first
interactive feedback action.
- Admin "Guests" tab (conditional on identity_mode='guest') with the
AdminGuestsList component.
Phase 2 — admin insights
- GET /admin/events/:eventId/guests list + aggregated counts.
- GET /admin/events/:eventId/guests/:guestId detail with per-type
groupings; AdminGuestDetail modal with thumbnail grid + tabs.
- GET /admin/events/:eventId/guests/aggregate sorted by distinct guest
pick count; GuestSelectionsAggregate component.
- Per-guest export (txt/csv/json) and bulk export-all ZIP.
Phase 3 — polish
- 3.1 Self-service forget-me link in gallery footer.
- 3.2 Email-based identity recovery: POST /guest/recover sends a
6-digit code via the existing emailProcessor, POST /guest/verify
exchanges it for a token (rate-limited, enumeration-safe).
- 3.3 Admin invite tokens: pre-mint identities, share URLs with
?invite=, single-use redemption stripping the param from history.
- 3.4 Admin merge endpoint reassigns feedback + soft-deletes sources.
Shared helper
- useGalleryFeedbackAction hook wraps the identity-check logic for
inline like buttons across Masonry/Grid/Justified/Mosaic/Carousel/
Timeline/Premium layouts.
Backwards compatibility
- Existing events default to 'simple' after migration; behavior
unchanged.
- Legacy photo_feedback rows keep guest_id NULL; admin shows them in
the generic feedback moderation view as before.
- feedback_count denormalized stat now uses COALESCE(guest_id,
guest_identifier) so per-guest counts are accurate without touching
legacy rows.
Verified end-to-end against local Docker
- Migration clean on existing data.
- Simple mode unchanged (no prompt, legacy flow).
- Guest mode: Alice registers on click, tokens persist in
sessionStorage, feedback rows carry guest_id.
- Carol via invite link auto-redeems, sees Alice's "1 likes" badge.
- Admin Guests tab shows both with correct counts; detail modal
displays thumbnail grid with badges; aggregate view sorts by picker
count (photo 227 = 2, others = 1); CSV/JSON export matches DB.
- Merge Carol into Alice: feedback reassigned, Carol soft-deleted,
Alice count = 4.
Admin Dark Mode:
- Add AdminDarkModeContext with light/dark/system preference
- Update all admin components with Tailwind dark: classes
- Add dark mode toggle in admin header
- Persist preference in localStorage
SEO Settings:
- Add robots.txt configuration in Settings > SEO tab
- Block AI crawlers (GPTBot, ChatGPT-User, etc.) with toggle
- Custom robots.txt rules management
- Add RobotsMetaTags component for gallery pages
- Backend service for dynamic robots.txt generation
- Database migration for SEO settings storage
UI/UX Improvements:
- Consistent dark mode styling across all admin pages
- Update gallery components with themed CSS classes
- Fix input, card, and button styling for dark mode
- Add separate header_style setting (hero/standard/minimal/none) that can
be combined with any layout type (grid/masonry/carousel/timeline/mosaic)
- Create HeroHeader and HeroDivider components for reusable hero section
- Add hero_divider_style setting (wave/straight/angle/curve/none)
- Add database migration for header_style and hero_divider_style columns
- Remove deprecated HeroGalleryLayout component
- Fix various TypeScript errors across the codebase:
- Add missing type properties (css_template_id, updatedAt, justified settings)
- Fix null handling for event_date and expires_at fields
- Fix translation function calls and i18n config
- Remove unused imports and variables
Add event-level custom logo upload/delete endpoints and UI, allowing
per-event logos to override the global branding logo in gallery views.
Also fixes several bugs discovered during testing:
- fix: category_id 'individual' parsed as NaN causing photo upload failures
- fix: gallery auth race condition where photos query fired before token stored
- fix: gallery-photos query not invalidated after favorite/like mutations
- fix: e2e test race conditions with View Gallery button detachment
- Remove all console.log/debug statements from production code
- Add NODE_ENV checks for development-only logging
- Remove test scripts (test-feedback, test-image-security, test-backup-*, test-restore)
- Remove one-time fix scripts (fix-temp-photos, fix-migration-state, mark-migration-applied)
- Remove sensitive files (.env.backup, ADMIN_CREDENTIALS.txt)
- Update package.json to remove references to deleted scripts
- Replace console statements with logger utility in backend
- Secure error boundaries to not expose stack traces in production
This makes the codebase production-ready with no debug output or test scripts.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <[email protected]>
Original: feat: enhance security logging and ensure rate limit blocks are properly tracked
- Add comprehensive logging for rate limit blocks with full request details
- IP address (with proper proxy detection), user agent, headers, timestamps
- Rate limit info (current count, limit, remaining, reset time)
- Separate tracking for auth vs general endpoints
- Enhance authentication failure logging
- JWT validation failures with detailed error info
- Admin auth attempts without token
- Failed token validation with user context
- All events include IP, path, method, user agent
- Improve Winston logger configuration for production
- Add automatic log rotation (10MB errors, 50MB combined)
- Create separate security.log for auth/rate limit events
- Ensure logs directory exists automatically
- Add structured JSON format for log aggregation
- Support container logging with LOG_TO_CONSOLE env var
- Create comprehensive documentation
- Security logging guide with examples
- Monitoring recommendations
- Configuration reference
- Add test script to verify logging functionality
All rate limit settings remain configurable via admin panel:
- Window duration, max requests, auth limits
- Skip authenticated requests option
- Public endpoints only option
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <[email protected]>