Commit Graph
636 Commits
Author SHA1 Message Date
Paul NothaftandPaul Nothaft f8a95d29d2 feat(auth): OIDC role mapping + login policy — phase 2 (#798) (#854)
* feat(auth): OIDC role mapping + login policy — phase 2 (#798)

Role mapping: configurable dot-path roles claim (Keycloak realm_access.roles,
Authentik/Pocket ID groups, Entra roles), IdP-value → PicPeak-role mapping
table validated against the roles table, re-evaluated on every SSO login with
highest-priority-wins on multiple matches. The last active super_admin is
never demoted. Optional require-mapped-role policy refuses logins whose token
maps to no role (sso_error=no_role).

Login policy: oidc_disable_local_login makes the API refuse password logins
(403 LOCAL_LOGIN_DISABLED) and the login page render SSO-only; only effective
while SSO is enabled+configured, and OIDC_BREAK_GLASS=true always re-opens
local login. Public settings expose the EFFECTIVE flag only.

Settings UI: Role-mapping card (claim path, mapping rows editor, strict
toggle) and Login-policy card with break-glass hint, EN+DE.

14 new integration tests over the mock IdP.

* fix(auth): harden phase-2 review findings (#798)

- memoize the scrypt-derived OIDC key and serve /public/settings from a
  10s-TTL flag cache — the unauthenticated endpoint no longer pays a
  13-key config read + blocking scryptSync per request (login route
  still checks uncached)
- make the last-super-admin demotion guard atomic (FOR UPDATE on the
  active super rows) — concurrent mapped callbacks could previously
  both count 2 and demote both supers
- own-property lookup in role mapping: IdP values like `constructor`
  now count as unmapped instead of corrupting the roles query
- SsoTab clears oidc_disable_local_login in the same save that turns
  SSO off — the full-form payload otherwise hit the server-side 400

* fix(auth): guarantee break-glass reachability for SSO-only mode (#798)

- wire OIDC_BREAK_GLASS and OIDC_ENCRYPTION_KEY through the quick-start
  docker-compose.yml env allowlist (production compose already passes
  .env via env_file) and document both in .env.example
- refuse enabling oidc_disable_local_login unless an active
  local-password super_admin exists: OIDC_BREAK_GLASS only re-opens the
  password route, which OIDC-owned accounts can never use, and
  settings.edit is super_admin-only — an all-OIDC instance would be
  unrecoverable during an IdP outage

* fix(auth): close SSO-only lockout gaps from review round 3 (#798)

- role sync never demotes the last active LOCAL-password super_admin
  (an OIDC-owned super does not count as break-glass), and
  isLocalLoginDisabled() disarms itself when no such account remains —
  self-healing against manual demotion/deactivation/deletion paths
- the local-super save-time check now validates the MERGED state, so
  re-enabling SSO with a stored disable flag is checked too
- ALL oidc_* keys are reserved from the generic settings upserts/reads
  (prefix match) — policy and mapping invariants can only go through
  the validated PUT /sso
- /admin/login/mfa re-checks the policy so an mfa_pending token minted
  before the flip cannot complete into a local session

---------

Co-authored-by: Paul Nothaft <[email protected]>
2026-07-22 20:59:19 +02:00
Paul Nothaft cb5b319f10 feat(notifications): surface guest activity in the admin bell (#849)
* feat(notifications): surface guest activity in the admin bell (#746)

Favorites already reached activity_logs (feedbackService), but gallery
opens and downloads only landed in access_logs — invisible in the
notification bell. Now:

- gallery_opened on the guest photo-list route, debounced in-memory to
  one notification per event per 6h (the endpoint fires per page load;
  per-hit notifications would spam the bell). Slideshow traffic stays
  excluded, matching the analytics exclusion.
- gallery_downloaded on all four download paths (streamed + pre-zipped +
  presigned download-all, download-selected) with scope metadata.
- Frontend: locale entries for galleryOpened/galleryDownloaded (and
  photoFavorite, which previously fell through to the generic 'system
  activity' line) in all 8 languages — resolved via the existing smart
  camelCase fallback, no switch cases needed. Distinct bell icons per
  type.

* fix(notifications): single-photo download activity + render per-type bell icons (codex review of #849)

- The per-photo Save route (GET /:slug/download/:photoId) only wrote to
  access_logs — the most common download path never reached the bell.
  Now emits gallery_downloaded with scope 'single', debounced to one
  notification per event per hour: a guest saving 30 photos is one
  signal, not thirty (exact counts stay in access_logs/analytics).
- getNotificationStyle's icon names were dead — AdminHeader hard-coded
  <Bell> for every row. Added an icon map so gallery opens (Eye),
  downloads (Download), favorites (Heart) and the pre-existing style
  names render their intended icons.

* fix(notifications): notify after successful delivery, complete the icon map (codex review of #849, round 2)

- Single-photo notification now fires on res 'finish' with status < 400:
  emitting up-front logged downloads that then 404ed/failed AND burned
  the 1h debounce window against the next real download.
- Icon map completed over every name getNotificationStyle returns
  (grep-verified) — settings/user/mail/etc. styles render their declared
  icons instead of falling back to Bell.

Deliberately NOT taken from the review: DB-backed debounce state for
multi-worker deployments. The backend's current deployment contract is
single-process (no PM2 cluster in-repo; multi-replica explicitly parked
in #799 — chunked-upload/session state is process-local for the same
reason). Worst case under a future multi-worker setup is N notifications
per window, which degrades, not breaks; a shared-store debounce belongs
to the #799 phase-3 work.

* fix(notifications): attribute client sessions, log cached-ZIP after finish, add Trash2 icon (codex review of #849, round 3)

- gallery_opened/gallery_downloaded now carry the real actor: client
  sessions (accessLevel 'client') are recorded as 'customer' instead of
  being mislabeled 'guest' — #746 explicitly covers client activity, so
  they are attributed, not excluded.
- Cached-ZIP streaming path logs on res 'finish' (< 400) like the
  single-photo path — piping is not delivery. The presigned-redirect
  and on-the-fly-archiver paths keep their existing timing (redirect
  handoff / post-finalize).
- Trash2 added to the icon map (customer_erased, bulk_delete_completed
  no longer fall back to Bell — the grep that built the map missed the
  digit in the name).

* fix(notifications): dashboard formatting, portal dedup, actor-aware wording, archiver finish-hooks (codex review of #849, confirmation round)

- activity_logs feed TWO surfaces: the dashboard's Recent Activity used
  admin.activities.<type> keys that didn't exist, rendering raw
  identifiers — added gallery_opened/gallery_downloaded entries in all
  8 locales.
- Customer-portal opens already log customer_event_access at the
  access-token mint; the ensuing /photos call no longer double-notifies
  (client sessions surface via downloads only).
- gallery_downloaded formatting is actor-aware: customer sessions render
  'Customer downloaded…' (new galleryDownloadedCustomer key ×8) instead
  of 'A guest…'.
- Both on-the-fly ZIP paths (download-all fallback + download-selected)
  notify on res 'finish' < 400 — archive.finalize() ends Archiver's
  input, not the HTTP transfer.

* fix(notifications): key customer dedup/attribution on portal provenance, neutral favorite wording (codex review of #849, final round)

The previous dedup was inverted: portal-minted tokens carry
via:'customer' but NO accessLevel (they run as guest), while PIN-client
logins carry accessLevel:'client' and log nothing else. So PIN clients'
only open signal was suppressed while portal opens still double-
notified and portal downloads read as guest activity.

verifyGalleryAccess now surfaces req.viaCustomer; gallery_opened dedups
on THAT (portal only), and galleryActor treats via-customer OR
accessLevel-client as 'customer'. photoFavorite wording is actor-neutral
across all 8 locales — feedbackService logs favorites without an actor,
so claiming 'a guest' was wrong for customer favorites.
2026-07-19 22:36:23 +02:00
Paul Nothaft e8dad4b40d feat(slideshow): guest-scannable share-link QR overlay (#848)
* feat(slideshow): guest-scannable share-link QR overlay (#837)

- Global settings (Settings → Slideshow): slideshow_qr_enabled/position/
  opacity/size — same option shape and cascade as the watermark.
- Per-event tri-state show_qr (migration 163): NULL inherits the global,
  true/false force on/off; editable in the per-event slideshow card.
- State endpoint ships the QR as a PNG data URI (cached per share URL —
  the 3s projector poll never re-encodes), so the kiosk needs no QR lib
  and no extra authenticated request.
- Kiosk renders the QR in a white padded corner box so it stays
  scannable on any photo.
- i18n: en + de (the slideshow namespace has no other locales yet).

* fix(slideshow): persist per-event QR override, show QR on empty shows, bound the QR cache (codex review of #848)

- OverviewTab never passed event.show_qr into the settings card (and the
  Event type lacked the field), so a stored true/false override always
  displayed as 'inherit' and the next save silently reset it to NULL.
- The QR overlay was nested inside the photos.length > 0 branch — an
  empty or category-filtered live gallery showed only 'Waiting for
  photos', exactly when 'scan to add the first photos' matters most.
  Now rendered for any running show.
- slideshowQrCache: insertion-order eviction at 50 entries — rotated
  tokens and past events no longer accumulate base64 PNGs forever.

* fix(slideshow): derive the QR origin from the kiosk request when the base is loopback (codex review of #848, round 2)

With the compose-default FRONTEND_URL=http://localhost:3000 (or no base
configured) the overlay QR sent scanning phones to their own localhost.
The state poll comes from the kiosk browser itself, so its Host header +
protocol (trust proxy is configured) are exactly the public origin
guests can reach — used whenever the configured base is missing or
loopback. Mirrors the ?origin= fallback #847 uses for the admin-side
QR downloads.

* fix(slideshow): kiosk passes its origin for the QR fallback (codex review of #848, round 3)

req.get('host') is not the browser origin behind the standard proxies —
frontend/nginx.conf forwards $host with the port stripped, so a compose
LAN deployment on :3000 encoded port 80. The kiosk now sends
window.location.origin with the session/state calls (validated
server-side, same pattern as #847's admin downloads); the Host-derived
origin remains as second fallback.

* fix(slideshow): reject loopback kiosk origins, throttle QR regeneration per event (codex review of #848, confirmation round)

- A loopback window.location.origin from the kiosk is no more
  guest-reachable than the loopback base it would replace — rejected;
  when no reachable URL remains the overlay is suppressed entirely (no
  QR beats a QR that sends phones to their own localhost). New test
  pins the suppression.
- The QR cache is keyed by event id with a 60s regeneration throttle:
  the origin is caller-influenced when the base is loopback, so
  URL-keyed caching let a slideshow-link holder force a fresh
  QRCode.toDataURL per request via unique origins — a cheap CPU
  exhaustion path. Encode rate is now bounded per event regardless of
  input. QR margin also raised to the 4-module spec quiet zone,
  matching #847.

* fix(slideshow): never serve a mismatched cached QR + single-flight encoding (codex review of #848, final round)

- A slideshow-token holder could poison the projector's QR: an
  attacker-origin entry cached per event was served to the legitimate
  kiosk for the rest of the throttle window. A cached artifact is now
  only served when its URL matches the request; mismatches inside the
  window suppress the overlay briefly instead of showing foreign
  content.
- Cold-cache stampede closed: concurrent polls share one in-flight
  encode promise instead of each scheduling a 512px render.

Rejected from the same round (false positive, verified empirically):
the loopback regex claim — /^https?:\/\/(localhost|127\.)/ matches
'http://localhost:3000' and '127.0.0.1:port' just fine (no trailing
slash required), and the suppression test runs green.
2026-07-19 22:36:03 +02:00
Paul Nothaft 60cdd07085 feat(events): gallery QR code + printable table-card/poster PDFs (#847)
* feat(events): gallery QR code + printable table-card/poster PDFs (#836)

- GET /api/admin/events/:id/qr — share-link QR as PNG (128-2048px) or
  SVG, inline or attachment; adminAuth + events.view + ownership.
- GET /api/admin/events/:id/qr-print — pdfkit-rendered A6 table card /
  A4 poster with event name, QR, localized caption (8 locales; Cyrillic
  falls back to English — built-in Helvetica has no Cyrillic glyphs) and
  the share URL as footer.
- Event detail: QR section in ShareLinkCard with live preview (blob
  fetch — Bearer auth) and PNG/SVG/table-card/poster downloads; print
  language follows the admin UI language. i18n keys in all 8 locales.
- qrcode + pdfkit were already dependencies (MFA / CRM PDFs).

* fix(events): QR origin fallback, Unicode PDF font, bounded layout, stale-preview guard (codex review of #847)

- QR URLs: prefer the configured public base, but fall back to the admin
  browser's origin (passed as ?origin=, validated) when the base is
  missing or localhost — mirrors buildShareLinkUrl so the QR encodes the
  same URL the card displays instead of an unusable localhost target.
- PDFs render with the bundled IBM Plex Sans TTFs (Latin+Cyrillic+Greek)
  instead of WinAnsi-only Helvetica: Cyrillic event names no longer
  silently disappear, and the caption's English-fallback hack is gone.
- Fixed vertical layout: title gets a bounded two-line ellipsis region
  and all positions derive from constants, so long event names can't
  push the QR/caption over the footer; URL footer bounded too.
- ShareLinkCard preview: stale-response guard — a late blob response
  after unmount/event-switch is revoked instead of leaking and
  overwriting the newer event's QR.

* fix(events): bundle complete IBM Plex Sans for QR PDFs + IPv6 loopback fallback (codex review of #847, round 2)

Round 2 caught that the pre-existing assets/fonts/IBM-Plex-Sans/ files
are 270-glyph Latin SUBSETS — my round-1 font swap didn't actually fix
Cyrillic titles and regressed the ru caption. Now bundling the complete
IBM Plex Sans 400/700 TTFs (1019 glyphs, Latin+Cyrillic+Greek — cmap
verified via fontkit, rendering verified on a generated PDF) under
assets/fonts/IBM-Plex-Sans-Full/ with the OFL license alongside.
~400 KB total; source: IBM/plex release zip @ibm/[email protected].

Also: LOCAL_BASE_RE now recognizes IPv6 loopback ([::1]) so a
FRONTEND_URL of http://[::1]:3000 falls back to the browser origin like
the frontend's own URL logic does.

Note for a follow-up: the CRM invoice/quote PDFs use the same Latin-only
subsets and share the Cyrillic gap.

* fix(events): responsive QR card that survives preview failures (codex review of #847, round 3)

- The QR section keys off share-link availability instead of a loaded
  preview: a transient failure of the preview request no longer hides
  every download button until reload; a placeholder tile renders in
  place of the image.
- Preview + actions stack on phone widths and the button grid drops to
  one column below sm, so 'Tischkarte (A6)'-length labels don't
  overflow.

* fix(events): QR encodes the stored share_link + spec quiet zone (codex review of #847, confirmation round)

- The QR target is now the STORED share_link — exactly what the card
  displays and the admin copies. Rebuilding from current slug/token/
  short-URL setting could diverge for legacy absolute links or events
  created under a different short-URL setting; a printed QR encoding a
  different URL than the card is a permanent mistake. Rebuild remains
  only as fallback when no share_link is stored.
- QR margin back to the library's 4-module default for all generated
  assets — the spec's quiet zone; margin 2 risks scan failures when the
  printout sits against colored surroundings.

* fix(events): bare share_link tokens resolve as /gallery/<token> in QR URLs (codex review of #847, final round)

Quote-/contract-converted events persist share_link as the raw token —
the frontend's buildShareLinkUrl prefixes those with /gallery/, but the
QR path normalization only added a leading slash, encoding
<origin>/<token> into every image/PDF for such events. Now mirrors the
frontend exactly.

* test(events): 30s timeout for the print-PDF cases (CI fix)

The poster PDF now embeds the full IBM Plex Sans TTFs (~200 KB each);
font parsing + subsetting exceeds jest's 5s default on slower CI
runners — the suite went red on exactly that test after the font
commit.
2026-07-19 22:35:44 +02:00
Paul Nothaft d7ba781c0f Merge remote-tracking branch 'origin/main' into feat/guest-upload-dng-raw
# Conflicts:
#	backend/src/services/uploadSettings.js
#	backend/src/utils/fileSecurityUtils.js
#	frontend/src/utils/fileTypes.ts
2026-07-18 20:52:08 +02:00
Paul Nothaft ee9d2f70d3 Merge pull request #832 from PicPeak/feat/guest-upload-heic-dynamic-hint
feat(uploads): HEIC/HEIF support + dynamic format hint on guest upload (#821)
2026-07-18 20:47:36 +02:00
Paul Nothaft c9b64d9c1a fix(uploads): register HEIC/HEIF with the file validator + fix admin format hint (codex review of #832)
Two findings from the Codex review:

- validateFileType requires an ALLOWED_MEDIA_TYPES entry, which had neither
  image/heic nor image/heif — so HEIC was rejected before sharp ever saw it,
  despite the EXTENSION_TO_MIME additions. Added both with a single 'ftyp'
  (offset 4) magic number (the check is .every, so alternatives can't be
  separate entries).

- Changing the shared upload.fileRequirements string to interpolate {{formats}}
  left the admin PhotoUpload caller passing only { limit }, rendering the
  placeholder literally (it was also already dropping {{sizeLimit}} from #823).
  The admin caller now passes formats + sizeLimit + limit, from the admin
  settings it already loads.
2026-07-17 22:06:40 +02:00
Paul Nothaft be2ec0a4a1 feat(uploads): DNG / camera-RAW support via embedded-preview extraction (#821)
Sharp's bundled libvips has no raw loader, so a DNG can't be thumbnailed
directly. This adds a preview-extraction step so RAW/DNG uploads get a proper
thumbnail + gallery preview while the original RAW is kept for download.

- imageProcessor: isRawFilename() + extractRawPreview() (exiftool extracts the
  embedded full-res JPEG — JpgFromRaw → PreviewImage → ThumbnailImage, validated
  with sharp) + withProcessableImage() which is a pass-through for ordinary
  images and swaps in the extracted JPEG for RAW. Wired into ingest
  (photoProcessor) and all three on-demand generators (ensureThumbnail/Hero/
  Preview). generateHeroImage/generatePreviewImage gained outputBasename so
  RAW-derived outputs stay named after the source.
- Dockerfile: add exiftool (confirmed present in Alpine v3.24 community).
- Format maps: dng → image/x-adobe-dng in uploadSettings.js and fileTypes.ts;
  ALLOWED_MEDIA_TYPES gains a DNG entry (TIFF magic numbers) so it passes the
  security file-validator.

Strictly gated by extension: nothing in this path runs for jpg/png/webp/etc, so
existing photos are unaffected. If extraction fails (corrupt RAW, no embedded
preview), the photo is marked 'failed' with a clear error — same as any
unreadable upload.

Verification boundary (please validate on a real DNG after the image rebuilds):
the exiftool extraction itself couldn't be exercised in the dev sandbox
(exiftool isn't a dev dependency and there's no DNG fixture). Unit tests cover
the gating (RAW detection + non-RAW pass-through + clean failure without
exiftool); existing processPhoto tests still pass. Known limitation: a DNG is
only accepted when the browser reports its MIME as image/x-adobe-dng (Chrome
does); browsers that send an empty type reject it client- and server-side —
a follow-up can add extension-based acceptance for the RAW set.

Companion to the HEIC/dynamic-hint PR; targets main only.
2026-07-17 21:51:21 +02:00
Paul Nothaft 2b5b23b96f feat(uploads): HEIC/HEIF support + dynamic format hint on guest upload (#821)
Two of the three things from #821:

- HEIC/HEIF (iPhone) can now be enabled. Sharp's bundled libvips decodes `heif`
  input (verified: sharp.format.heif.input.file === true on 0.34.3 / libvips
  8.17.1), so thumbnails generate. Added heic/heif to EXTENSION_TO_MIME in both
  the backend (uploadSettings.js) and the frontend (fileTypes.ts) maps, which
  are kept in sync. (iOS Safari usually transcodes HEIC→JPEG at file selection,
  but a genuine .heic upload is now handled when it arrives.)

- The upload requirements hint no longer hardcodes "JPEG, PNG or WebP". New
  extensionsToLabel() renders the actually-configured, supported formats (e.g.
  "JPG, PNG, WEBP, MOV"), and upload.fileRequirements interpolates {{formats}}
  across all 8 locales. Unsupported extensions are dropped from the label so it
  never advertises a format the backend would reject.

DNG / camera RAW is deliberately NOT included: Sharp's libvips has no raw loader,
so a DNG would upload then fail thumbnailing (photo → 'failed', no preview).
Proper RAW support (embedded-preview extraction) is a separate PR.

Adds vitest coverage for extensionsToLabel + the HEIC mapping.
2026-07-17 21:39:34 +02:00
Paul Nothaft e03d13efde fix(uploads): tighten guest max-file-size setting (codex review of #823)
Three follow-ups from the Codex review of #823:

1. PublicSettings TypeScript interface was missing general_max_file_size_mb,
   so UserPhotoUpload's access produced TS2339 under `tsc -b` (build:check). CI
   didn't catch it because the pipeline runs `build` (esbuild, no typecheck),
   but it's a real type gap — the #614 count field is declared, this one wasn't.
   Added the optional numeric field.

2. The general-settings update endpoint validated general_max_files_per_upload
   but not general_max_file_size_mb, so an out-of-range value (0, -1, huge)
   could persist. publicSettings then advertised the raw value while
   getMaxFileSizeMb() normalised it — the guest UI would reject files the
   backend accepts. Added the same validate-and-clamp block (1..MAX_ALLOWED_FILE_SIZE_MB).

3. The update route cleared the file-count cache but not the new file-size
   cache, so for up to 60s the public endpoint could advertise a new limit
   while multer still enforced the old one. Now clears both under the same
   uploadLimitTouched guard.

Follow-up on the merged #823 (main-only), so this targets main only.
2026-07-17 21:30:48 +02:00
Paul Nothaft 1e38d84808 fix(uploads): apply configured max file size to guest uploads (#613 follow-up)
The admin's Settings → General → "Max File Size (MB)" value
(general_max_file_size_mb) never applied to guest gallery uploads — the guest
route hardcoded multer's per-file cap at 50MB (gallery.js) and the guest UI
hardcoded the same 50MB client-side guard and "max 50MB" hint text. So a guest
could not upload a large video even when the admin raised the limit (reported by
mat1990dj on #613). Same class as the file-count miss fixed in #614, for size.

- uploadSettings.js: new getMaxFileSizeMb()/getMaxFileSizeBytes() reading
  general_max_file_size_mb (default 50MB, cached 60s, clamped to a 10GB ceiling),
  mirroring getMaxFilesPerUpload.
- gallery.js (guest upload): multer limits.fileSize now resolves from the
  setting; a LIMIT_FILE_SIZE error returns an actionable "max N MB" message.
- publicSettings.js: exposes general_max_file_size_mb (default 50) so the gallery
  UI can render the real limit and guard client-side before an oversized POST.
- UserPhotoUpload.tsx: reads the limit, uses it for the client-side size guard,
  and passes it to the requirements hint. The "max 50MB" literal in
  upload.fileRequirements is now interpolated ({{sizeLimit}}) across all 8
  locales; adds upload.fileTooLarge (en/de; others fall back to en).

Scope: guest path only (the reported gap). The admin path keeps its generous
10GB cap — admins are trusted and default 50MB would otherwise regress large
admin video uploads. Format and batch-size limits already work correctly and are
untouched. Adds SQLite-backed unit tests for the new getter.

Verified end-to-end on a booted instance: admin sets 500MB → persisted → public
settings exposes 500 → guest multer sources its cap from it.
2026-07-17 20:41:27 +02:00
Paul Nothaft 6cd546e86a fix(security): remove unguarded legacy /api/events router (GHSA-4j34-x562-5vfq)
The legacy gallery router mounted at /api/events exposed create/list/update/
delete/extend guarded by adminAuth ALONE — no requirePermission, no
requireEventOwnership. adminAuth only checks the token is a valid type:'admin'
session, which every back-office role holds, down to read-only `viewer`. So any
non-super-admin account could:
  - GET /api/events → every gallery's bcrypt password_hash, share_token, and
    client name/email (the list handler selects * and mapEventForApi keeps
    those columns),
  - PUT /api/events/:id → reset any gallery's password (full takeover),
  - DELETE /api/events/:id → delete any gallery,
all bypassing the per-photographer ownership isolation the canonical
/api/admin/events router enforces. Affects any instance with more than the
single super_admin.

Fix: remove the legacy router entirely (mount + require + src/routes/events.js).
It was a superseded duplicate of /api/admin/events and unused by the frontend
EXCEPT for one live route — POST /:id/extend (the "Extend expiration" UI action,
which hit /api/events/:id/extend via the api client's /api base). That route is
migrated to the canonical mount as POST /api/admin/events/:id/extend with the
same guards as every other gallery mutation (adminAuth + requirePermission
('events.edit') + requireEventOwnership), and the frontend is repointed to it.
Behaviour of the extend itself is unchanged (expires_at + reactivate).

Verified end-to-end on a booted instance: /api/events (all methods) now 404;
/api/admin/events/:id/extend returns 401 unauth, 200 for the owner, 403 for a
non-owning editor; the full login→create→extend flow works. Adds a regression
test pinning the router removal and the extend ownership check.
2026-07-17 09:16:51 +02:00
Paul Nothaft cbde7636aa Merge remote-tracking branch 'origin/main' into feat/oidc-sso-phase1
# Conflicts:
#	backend/src/middleware/maintenance.js
2026-07-16 13:53:28 +02:00
Paul Nothaft 38fd41aad3 fix(security): harden .picpeak restore operator-preservation (GHSA-qxfx follow-up)
The req.admin.id fix activated reinjectCurrentAdmin(); hardening its preservation
logic (found across Codex review rounds of #811):

- MFA hijack: reinject wrote back only password_hash/is_active/
  must_change_password, leaving a crafted backup's two_factor_* on the
  operator's row — it could strip or replace their second factor. The email-
  matched row is now updated with the operator's full AUTH set (login identity,
  password, and all two_factor_* columns). Relationship/audit FKs (role_id,
  created_by) are deliberately NOT forced from the snapshot: on a cross-instance
  restore those pre-restore ids may be absent from the backup and would dangle
  the FK (SQLite rolls back at commit); the restored row keeps its own valid
  values.

- Cross-instance restore rollback / FK safety: reinject matched only by email,
  so a backup shipping a different admin with the default `admin` username hit
  UNIQUE(username) and rolled the whole restore back; email and username could
  even collide on two different rows. Reconciliation is now non-destructive:
  the email-matching row is updated in place (id preserved → restored FKs like
  events.created_by stay valid); any different row holding the operator's
  username is RENAMED, not deleted (deletion would fire ON DELETE actions /
  dangle references); only when no row has the operator's email is a fresh row
  inserted, with created_by nulled and an explicit max(id)+1 id (batchInsert
  left the Postgres identity sequence unadvanced, so a sequence-based insert
  could collide).

- Stale session after restore: admin_users ids shift on restore, but the
  operator's live JWT is bound only to decoded.id (IP logged not enforced; the
  backup controls password_changed_at). The route now revokes the token (result
  checked and logged) and clears the admin cookie; the client redirects to a
  fresh login via a sessionInvalidated flag. Cookie clear is the unconditional
  guarantee.

Adds SQLite-backed reinject regression tests (in-place login/MFA restore with id
and FK columns preserved, username-only rename, email+username on different rows,
clean insert with created_by nulled) and the frontend redirect on
sessionInvalidated.

Deferred (design decisions / pre-existing, need a Postgres test env — see PR
discussion): global "invalidate all pre-restore sessions" cutoff; preserving the
operator's ROLE semantics across an RBAC-table replace; and resyncing Postgres
identity sequences after any restore (batchInsert leaves them behind max(id) —
pre-existing, affects every restored table).
2026-07-16 12:27:59 +02:00
Paul Nothaft e91c7deaa4 fix(oidc): local-credential lockout, session hydration, split-origin gaps (codex round 3)
- OIDC-owned accounts can never authenticate locally: the password
  login rejects auth_provider='oidc' rows outright (generic 401), and
  the super-admin password reset refuses them with a clear message —
  previously a reset would have minted a local password bypassing the
  IdP's MFA/access policies
- /auth/session now returns a full adminUser payload (role join) and
  AdminAuthContext hydrates user state from it: an SSO redirect
  establishes the session without any login JSON, which left the header
  identity blank and current-admin form defaults empty
- the /sso/login error path redirects absolute to the frontend base
  (same split-origin reasoning as the callback)
- docker-compose.yml passes API_URL through to the backend (production
  compose uses env_file and needs nothing; dev compose is gitignored)
- authSession.symmetry test mock taught the joined admin lookup
  (leftJoin, prefixed columns, aliases) — the route change made the old
  mock throw, which read as "table missing, trust token"

Tests: new case pins that a known-good password on an OIDC-owned row
still gets 401. 14/14 OIDC, 13/13 symmetry.
2026-07-16 10:07:59 +02:00
Paul Nothaft 7f7d38a57f fix(oidc): security + robustness hardening from codex review rounds 1-2
Round 1:
- bind SSO identities to (external_issuer, external_subject): OIDC only
  guarantees sub uniqueness within an issuer, so a sub-only lookup let a
  newly configured IdP's user inherit an old IdP's admin account on
  subject collision; migration 162 gains external_issuer + composite
  unique index (unmerged migration, edited in place)
- fetch UserInfo (with sub cross-check) when the ID token carries no
  email — spec-compliant providers may serve email/profile claims only
  there; ID-token claims win on merge
- allowlist /admin/sso/login + /callback in maintenance mode, or
  SSO-only (JIT) admins are locked out exactly when they need in
- strip reserved keys (oidc_client_secret, setup_token) from BOTH
  generic settings reads (GET / and GET /:type)

Round 2:
- redirect_uri prefers API_URL (the API's public origin — where the
  state cookie lives); final redirects absolute to the frontend base;
  login button builds its URL via buildResourceUrl — split-origin
  deployments (absolute VITE_API_URL) work end to end
- PUT /sso validates the MERGED resulting state (partial update cannot
  blank issuer/client while enabled=true survives; enabling requires a
  derivable redirect URI)
- openid scope forced into oidc_scopes on save
- discovery-cache key includes a secret fingerprint (multi-worker
  secret rotation)
- email→admin linking claims the row atomically (conditional update on
  external_subject IS NULL) — concurrent first-time callbacks with the
  same verified email but different subjects can't both authenticate

Tests: mock IdP gains userinfo endpoint + email-via-userinfo-only mode;
new cases pin the userinfo merge and issuer-collision non-inheritance;
redirect assertions updated for absolute URLs. 13/13.
2026-07-16 09:41:48 +02:00
Paul Nothaft ed5fc5ad5c feat(auth): OIDC SSO for admin users — phase 1 (#798)
Authorization-code + PKCE against a single configurable IdP via
openid-client v5, with JIT provisioning. Verified end-to-end against a
real Keycloak 26 (realm + confidential client + verified-email user):
settings → discovery test → login button → Keycloak → dashboard.

Backend:
- migration 162: admin_users.auth_provider ('local' default) +
  external_subject, composite unique index
- oidcService: settings-driven config (client secret AES-256-GCM at
  rest, mfaService pattern, OIDC_ENCRYPTION_KEY fallback JWT_SECRET),
  cached discovery, sub-based identity binding — email linking of
  existing admins only with email_verified=true; JIT behind
  oidc_autoprovision with configurable default role and an unusable
  random password hash
- GET /api/auth/admin/sso/login + /callback: state/nonce/PKCE verifier
  cross the redirect in a 10-min signed httpOnly SameSite=Lax cookie;
  the callback reuses the local login's session establishment
  (completeAdminLogin split into establishAdminSession + JSON wrapper)
  so SSO sessions are identical downstream; every failure lands on
  /admin/login?sso_error=<key> as a translated toast
- dedicated /admin/settings/sso GET/PUT/test endpoints (secret
  write-only, redacted to a set-flag; registered ABOVE the generic
  /:type matcher which would shadow them); oidc_client_secret added to
  the reserved keys stripped from generic settings upserts
- public settings expose only oidc_enabled + oidc_button_label for the
  login page

Frontend:
- Settings → Single Sign-On (OIDC) tab: issuer/client/secret, scopes,
  autoprovision + default role, button label, enable toggle, redirect
  URI copy box, server-side discovery test
- login page: SSO button (custom label) when enabled; sso_error query
  param surfaced as translated toasts; EN+DE i18n

Tests: 11 integration cases against an in-process mock IdP (real
discovery/JWKS/PKCE/ID-token validation) — JIT on/off, sub-vs-email
binding, unverified-email rejection, deactivated admin, missing/forged
state cookie, nonce tamper, secret encryption round-trip, disabled 404.

MFA is delegated to the IdP on the SSO path; local login stays
available as break-glass. Role-claim mapping and logout-to-IdP follow
in phase 2/3.
2026-07-16 08:54:26 +02:00
Paul Nothaft 0751a08aa6 Merge pull request #804 from PicPeak/fix/gallery-feedback-filter-chips
fix(gallery): show feedback filter chips on desktop for galleries without categories
2026-07-15 23:02:48 +02:00
Paul Nothaft b9283386a5 fix(gallery): show feedback filter chips on desktop for galleries without categories (#802)
The desktop feedback-filter chips (All/Likes/Saved/Rated/Commented)
were nested inside the categories row conditional, and the standalone
fallback block is lg:hidden — so a gallery without photo categories
(the default) rendered no feedback filter at all on desktop, despite
the docs and a fully working filter implementation behind it.

Render the row whenever either part has content and gate only the
category scroller on categories existing. The media-count label hides
below lg when no categories exist so the mobile layout stays unchanged
(mobile keeps its own chip block). With-categories galleries render
identically to before.

Regression test pins both chip groups in the DOM with and without
categories (fails on the pre-fix component).
2026-07-15 22:51:22 +02:00
Paul Nothaft f8ba669716 fix(event-types): harden setup window + catalog validation (codex review)
Three review rounds on PR #801; fixes in response:

- isValidEventType: live catalog is authoritative when it has rows — a
  deleted or deactivated slug no longer validates via the legacy
  fallback (fallback now only serves an empty-catalog install)
- deleteEventType: refuse deleting the last (and last ACTIVE) type;
  updateEventType: refuse deactivating the last active type (unknown
  slugs are rejected since the validator change, so an empty active
  catalog would brick event creation)
- setup window fails closed: only an explicit stored `false` opens it
  (a portable-backup restore can leave the key absent) and a normal
  admin login durably closes it (abandoned-wizard case)
- reserved bootstrap keys (setup_wizard_completed, setup_token) are
  stripped from ALL generic settings upserts (/general, /security,
  /analytics, /seo) so the marker is genuinely one-way
- wizard step: deletes ordered so the catalog can never end up empty,
  and a genuinely failed system-type deletion reloads the list and
  stays on the step instead of advancing past the only window in which
  it can be retried
- CreateEventPage: snap the hardcoded initial 'wedding' selection to
  the first active type when the catalog no longer contains it
- v1 API: new GET /event-types (read scope) so token clients can
  discover valid slugs; OpenAPI enum replaced with the live-catalog
  description
2026-07-15 22:21:20 +02:00
Paul Nothaft 7eb6357b4a feat(setup): event-types step in first-run wizard + un-hardcode event type deps (#800)
Fresh installs can now shape the event-type catalog during the setup
wizard — rename, delete or replace the seeded defaults while nothing
references them. On existing installs system types stay protected.

- New wizard step between features and config: edit name/URL prefix,
  remove, or add types; defaults shown as recommendations
- setup_wizard_completed app setting (migration 161): seeded true when
  an admin already exists, false on fresh installs; POST /api/setup/
  complete (adminAuth) flips it when the wizard finishes
- deleteEventType: system types deletable only while the flag is unset;
  in-use check extended to quotes; per-type reminder template
  (event_reminder_<slug>) is deleted with the type
- reminder-template self-heal no longer resurrects templates for slugs
  removed from the catalog
- v1 API event creation validates event_type against the live catalog
  instead of a hardcoded whitelist (custom types were rejected; the
  never-seeded 'family' slug is no longer silently accepted)
- contract→event conversion resolves the event type via
  crm_default_event_type / resolveDefaultEventType instead of
  hardcoding 'wedding' (resolveDefaultEventType moved from quoteService
  to eventTypeService for reuse)
2026-07-15 21:30:23 +02:00
Luca 1476884dd0 feat(invoices): configurable VAT/free-text note + fix multi-page page-number overlap (#794)
Two invoice-PDF changes from #794.

1. VAT / free-text note (Benedikt's request, placement A). A new
   `crm_invoices_vat_note_text` setting (Settings → CRM → Invoices) prints a
   free-text line directly under the MwSt. row on every invoice. Data-driven:
   the admin types the exact wording (Austrian Kleinunternehmer § 6 Abs. 1 Z 27
   UStG, German § 19, reverse-charge, …) — no jurisdiction hardcoded. The
   totals-block reserve grows by the measured note height so a long note can't
   push the grand total into the footer. Read in invoice/render.js, threaded
   through normaliseContext, drawn in drawTotals. Empty → row omitted; quotes
   unaffected.

2. Multi-page footer overlap. On a full continuation page the line-item table
   filled to the bottom margin, but the "Seite X von Y" stamp was drawn at
   marginBottom-12 — INSIDE that fill zone — so items overlapped the page
   number. Move the stamp into the bottom margin (below the content edge),
   zeroing that page's bottom margin during the write so it can't trigger
   PDFKit's auto-page-break. Verified: on a full page the lowest item text is
   at pdfkitY ~790 while the page number sits at ~816 — ~26pt clearance.

Tests: render the note on a single page (byte-delta proves it renders) and
paginate a long invoice with the note (2–3 pages, no stray blank page).
2026-07-11 02:01:20 +02:00
Luca a4b4485d32 fix(categories): address PR #790 review — event ownership, migration renumber, nits
- 🔴 Event ownership: GET /event/:eventId and DELETE /reorder/:eventId now use
  requireEventOwnership; POST /reorder (event_id in body) gets the equivalent
  inline check (super_admin bypasses; others limited to owned/ownerless events).
  New test covers a settings.edit-holding non-super_admin blocked (403) on all
  three per-event routes.
- 🔴 Migration renumber: 158→159, 159→160 (upstream #788 already took 158);
  headers + the test's require path updated.
- 🟢 Nits: stale inline "Drag the arrows" fallback → "Use the arrows" (matches
  en.json; control is click-only); invalid bg-accent-dark/150 → bg-accent-dark.
2026-07-10 20:10:40 +02:00
Luca 4698402b54 feat(categories): per-event category ordering — global default + override (#782)
Order a gallery's categories in the flow of the day instead of A–Z. Two layers,
resolved per event: per-event override > global default > name.

- migration 158: photo_categories.display_order (global default), backfilled
  from the current alphabetical order so existing galleries don't reshuffle.
- migration 159: event_category_order (event_id, category_id, position) — the
  per-event override; no backfill, every event starts on the default.
- utils/categoryOrder: shared resolution used by the admin event view and the
  public gallery; fails safe to the global default if the table is absent.
- adminCategories: POST /reorder sets a per-event override (globals +
  event-specific, interleaved); DELETE /reorder/:eventId resets; POST
  /reorder-global sets the global default. Ordering endpoints + create append.
- gallery renders the resolved order.
- Settings → Photo Categories reorders the global default; an event's Categories
  tab reorders that gallery (one combined list + Reset to default). Up/down
  buttons — no drag-and-drop dependency.
- en/de strings.
2026-07-10 16:24:17 +02:00
Paul Nothaft b768a53c5b feat(slideshow): per-event play order + category filter (#202)
The Live Slideshow already covers the core of #202 (fullscreen kiosk,
live-appending new uploads, timing/transitions/watermark, per-event
opt-in via the share link). This adds the two customization dimensions
the reporter also asked for:

- **Play order** (show_order): 'chronological' (upload order, default) or
  'random' — the client shuffles the initial set (Fisher-Yates) so
  live-appended uploads keep working.
- **Category filter** (show_category_id): restrict the slideshow to a
  single photo category (NULL = all photos, default). Enforced
  server-side on the slideshow /photos access and mirrored in the
  /session + /state photo_count, so the kiosk viewer can't widen the set.

Per-event enable/disable (default off) is unchanged — it's the existing
'Generate/Disable slideshow link' flow (no token = no slideshow).

- Migration 158: show_order (default 'chronological') + show_category_id.
- Admin: Play-order dropdown + category picker in the Live Slideshow card
  (picker hidden for events without categories); EN + DE i18n.
- Verified: migration (SQLite + PG); live API (category filter → 3/2/5
  photos + matching count; order propagates) and the running kiosk
  requests exactly the filtered set; tsc clean, 136 backend tests pass.
2026-07-10 10:46:54 +02:00
Paul Nothaft d3d7df46f2 feat(admin): GitHub repo button in the sidebar footer (#778)
Adds a subtle 'View PicPeak on GitHub' link in the admin sidebar footer
(next to the version/storage widgets), so admins can reach the repo —
star it, browse source, report an issue — from anywhere in the dashboard,
not just the setup screen.

- Centralizes the repo URL as `repoUrl` in utils/githubReleaseUrl.ts
  (githubReleaseUrl now derives from it) so the org URL lives in one place.
- target=_blank + rel=noopener noreferrer; EN + DE i18n
  (`admin.viewOnGithub`); dark-mode aware, matches the muted footer style.
2026-07-10 09:50:05 +02:00
Paul Nothaft a71b9b5ed7 Merge pull request #769 from Luca-Timo/feat/messages-email-client
feat(messages): unified Messages email client (flag-gated, default off)
2026-07-08 12:38:58 +02:00
Luca 1e08a4fb15 fix(messages): PR #769 nits — server-side search, bare-email recipient, DE i18n
- Search now hits the backend (debounced) so results aren't truncated to the
  first loaded page: /received gains a `q` filter (sender/subject); the frontend
  passes the debounced term to every list query. The instant client-side filter
  stays for responsiveness.
- Reply/compose recipient extracts the bare address from a "Name <addr>" From
  header (extractEmail) — also used for the customer-lookup key.
- Added the full de + en `messages.*` and `email.customerMailbox.*` translation
  namespaces (were English inline-fallbacks only). Swiss-German spelling.
2026-07-07 18:39:04 +02:00
Luca bb235e72e5 fix(messages): PR #769 review — escape reply sender (XSS), gate backend routes, exact customer match
- BLOCKER: stored XSS via inbound sender display name. The reply stub built raw
  HTML with the unsanitized From name and set it as innerHTML on the composer's
  contentEditable (admin origin) → onerror JS ran on Reply. Now HTML-escape
  from_address in the stub AND DOMPurify-sanitize the composer body before
  innerHTML (defense in depth).
- Gate the NEW Messages routes with requireFeatureFlag('messaging') per-route
  (queue/:id, received/:id, item/*, identities, accounts, accounts/test, send)
  — NOT the shared /email mount, so the pre-existing email-config endpoints stay
  ungated.
- DocumentActionModal auto-picks a customer only on an EXACT email match
  (customer search is prefix/fuzzy), else leaves the picker to the admin.
2026-07-07 18:28:26 +02:00
Luca 99d5996561 feat(messages): search bar + Archive/Delete with Archived & Deleted folders
- Search box in the header filters the current folder's list (sender/subject),
  client-side; works across the merged Archived/Deleted views too.
- Archive and Delete are now implemented as soft moves: migration 157 adds
  mailbox_state ('active'|'archived'|'deleted') to email_queue + received_emails.
  Archive → 'archived', Delete → 'deleted' (trash). Restore → 'active'. Deleting
  FROM the Deleted folder is permanent (hard row delete).
- New cross-account system folders Archived + Deleted (merge sent + received of
  that state, sorted by date). Normal folders now exclude archived/deleted.
- Backend: /queue + /received gain a `state` filter (default active + legacy
  NULL); new POST /item/:kind/:id/state (archive/delete/restore) and DELETE
  /item/:kind/:id (purge, email.edit).
- Toolbar Archive/Delete wired; Restore + "Delete permanently" shown in the
  system folders.

Frontend build + migration boot (157) verified.
2026-07-07 16:16:51 +02:00
Luca c8cb4c88ca harden(messages): SSRF guard on mailbox host, strict sandbox + sanitizer, per-account TLS
Pre-upstream review hardening:
- /accounts + /accounts/test now reject private/internal IMAP/SMTP hosts via
  isPrivateIP(), matching /config + /incoming-config (SSRF).
- QueueDetail body iframe uses sandbox="" (script-less, no same-origin) like the
  inbound pane, instead of allow-same-origin.
- /send sanitizer drops the <style> tag + data: scheme to match the stricter
  inbound sanitizeBody allowlist.
- Per-account SMTP transport sets tls.rejectUnauthorized explicitly.
2026-07-07 16:04:11 +02:00
Luca 2c5c1d561b fix(messages): show the resolved customer's name in the doc-action modal
CustomerPicker uses its 'label' prop as the selected-customer chip text, so
passing the static 'Customer' string hid the actual name. Pass the resolved
customer's name as label and add a separate field heading.
2026-07-07 15:45:59 +02:00
Luca 0dbf863f60 feat(messages): create/select quote, contract, invoice, gallery from a message
The toolbar doc buttons now open a real document-action flow instead of just
loading an email template:

- DocumentActionModal resolves the customer from the message's sender address
  (customers/search); if no match, the CustomerPicker lets you search or create
  a passive customer inline.
- Create new -> jumps to the real editor prefilled with the customer
  (quotes/contracts/bills ?customerAccountId=), so numbering, line items and PDF
  all come from the existing CRM. Gallery opens the event editor.
- Select existing -> lists that customer's quotes/contracts/invoices and drops
  the chosen document number into a reply composer.
- Toolbar buttons are gated by the global feature flags (quotes/contracts/bills).

Adds the missing customerAccountId prefill to ContractEditorPage (quotes + bills
already had it). Frontend-only; reuses existing endpoints. Build verified.
2026-07-07 13:16:18 +02:00
Luca 88fe9f9844 fix(messages): show only the mailbox local part in the sidebar (full address on hover) 2026-07-07 12:32:08 +02:00
Luca f9c2b4ed75 fix(messages): dynamic addresses, branding accent, compose/sync, per-identity SMTP
Addresses dev-test feedback:
- Sidebar + reading-pane addresses (rechnungen@ / hello@ / no-reply@) are now
  read from the mail config via GET /admin/email/identities, not hardcoded.
- Highlight/selection now uses the branding accent (bg-accent-soft /
  text-on-accent-soft / accent-dark) instead of hardcoded blue, so it follows
  the admin's CI colour like the sidebar.
- Header gains "New message" (compose) and "Sync" (poll mailboxes now) buttons.
- Composer modal enlarged (920px, taller editable body).
- Customer mailbox (hello@) now has BOTH incoming (IMAP) and outgoing (SMTP)
  settings — migration 156 adds smtp_* + from_* to mail_accounts;
  emailProcessor.sendRawEmail takes an accountKey and sends via that mailbox's
  SMTP identity (falls back to the global from). Manual/reply sends from the
  Messages UI use the 'customers' identity, so replies come from hello@.

Frontend build + migration boot (156) verified.
2026-07-07 11:58:41 +02:00
Luca 976d52280e Merge pull request #356 from Luca-Timo/feat/messages-email-client
Feat/messages email client
2026-07-07 11:11:21 +02:00
Luca 0ed3bbefa1 chore(messages): describe the feature as 'unified' rather than by a third-party product name 2026-07-07 11:10:00 +02:00
Luca b96ad36f5d fix(messages): make the Messaging feature flag toggleable
The Messaging FeatureCard was a hardcoded-disabled 'roadmap' placeholder
(no-op toggle), so the messaging flag could never be turned on — the Messages
sidebar item + page stayed hidden. Wire the toggle to setFlag, mark it 'new',
and describe the actual admin Messages client.
2026-07-07 11:06:16 +02:00
Luca d0bdcb1a6a Merge pull request #355 from Luca-Timo/feat/messages-email-client
feat(messages): Outlook-style Messages email client (3 phases, flag-gated)
2026-07-07 10:48:46 +02:00
Luca 768e84711f feat(messages): Phase 3 — editable-template composer, reply + create actions
The CRM action buttons and Reply now open a send-composer, not a silent
templated send.

- New send-composer (MessageComposer): loads the rendered template (via
  previewTemplate) or a reply stub into a fully-editable body — the admin can
  rewrite it or drop a note anywhere before sending. On send it goes out as-is
  (server-sanitized), no template re-render.
- Backend: emailProcessor.sendRawEmail() sends admin-edited HTML via the
  configured SMTP identity; POST /admin/email/send sanitizes + sends + records
  the message in email_queue as a 'manual' send.
- Migration 155: email_queue.origin ('system' default | 'manual'). The Sent
  stream now splits by origin — Automated ▸ Sent = system, Customers ▸ Sent =
  the human/edited messages (which finally populates that folder). /queue gains
  an origin filter + returns origin.
- Toolbar wired: Reply enabled on inbound customer mail (prefilled + quoted);
  Create Quote/Contract/Invoice open the composer with that template loaded;
  Gallery opens a blank compose. Accounting/Forward/Archive/Delete stay disabled
  (later phases). After send, jumps to Customers ▸ Sent.

Deferred to a later phase: two-way IMAP write-back; per-identity SMTP (manual
sends currently use the global from address). Frontend build + migration boot
verified.
2026-07-07 10:42:54 +02:00
Luca ee46cf2125 feat(messages): Phase 2 — customer (hello@) mailbox + inbound body capture
Second inbound mailbox and real message bodies for the Messages viewer.

Backend:
- Migration 154: mail_accounts table (additional inbound mailboxes beyond the
  primary accounting IMAP) + received_emails.{account_key,to_address,body_html,
  body_text}. Additive/guarded.
- emailIntakeService now polls the accounting mailbox AND every enabled
  mail_accounts row. Extracted pollAccountOnce(cfg, {accountKey, routeToExpenses});
  accounting keeps its exact attachment->expenses behavior, customer mail is
  logged with its body and NOT routed to accounting. Inbound HTML is sanitized
  server-side (sanitize-html) on ingest.
- adminEmail: /received gains an account filter + returns account_key/to_address
  (bodies excluded from the list); new GET /received/:id returns the body;
  GET/POST /accounts + /accounts/test manage the extra mailboxes.

Frontend:
- Customers inbox now pulls the hello@ mailbox; reading pane renders the
  sanitized body in a strict (script-less, no same-origin) sandboxed iframe.
  Accounting inbox shows bodies too. Toolbar context keys off the mailbox.
- CustomerMailboxCard in Settings -> Email (behind the messaging flag) to
  configure + test the hello@ IMAP box.

No behavior change to the existing accounting inbound flow. Frontend build +
migration boot verified.
2026-07-07 10:11:58 +02:00
Paul Nothaft 945026d446 fix(admin): stop the event-date field crashing the page on backspace
Repro: create an event, click into the date field, backspace a day digit.
The whole page white-screened and needed a reload.

Root cause: LocalizedDateInput's `toIso` only checked the day/month were
1-2 digits, not that they formed a real date — so a mid-backspace value
like "0/07/2026" was coerced to the string "2026-07-00" and committed to
`event_date`. CreateEventPage then rendered
`format(addDays(new Date('2026-07-00'), days))`, and date-fns `format`
throws RangeError on an Invalid Date — thrown during render, so React
tore the tree down to the error boundary.

Two complementary fixes:
- `toIso` round-trips the parsed y/m/d through `Date` and rejects
  impossible dates (day 00, month 13, 31 Feb…), so the field never
  commits a value that isn't a real calendar date.
- `useLocalizedDate.format`/`formatDistanceToNow` guard with `isValid`
  and return '' instead of throwing — defence in depth for the ~57 call
  sites that could otherwise white-screen on a bad date.

Verified live: backspacing to a partial/invalid date no longer crashes
(the form stays rendered), a valid date still commits + the expiry
preview renders. Adds a LocalizedDateInput regression test; tsc + build
green.
2026-07-07 08:56:04 +02:00
Luca 26eeb76197 feat(messages): Phase 1 read-only Messages viewer (email client shell)
New admin "Messages" page — a three-pane mail viewer over the mail picpeak
already stores, feature-flagged behind `messaging` (default off):

- Sidebar account tree: All mail / Customers (hello@) / Accounting (rechnungen@)
  / Automated (no-reply@), matching the agreed IA.
- Automated + All Sent = email_queue (listQueue); Accounting + All Inbox =
  received_emails (listReceived). Customers folders show an explanatory empty
  state pending the hello@ mailbox (Phase 2).
- Reading pane renders the sent body from rendered_html (migration 119) in a
  sandboxed iframe; new GET /admin/email/queue/:id returns body + cc +
  attachment filenames (disk paths never exposed).
- Received supplier invoices: envelope + rasterized PDF viewer reusing the
  accounting inbound blob endpoint, plus "Open in Accounting inbox".
- Context toolbar (Reply/Forward/Create Quote-Contract-Gallery-Invoice /
  Book-as-expense-Re-bill) present but disabled — wired in later phases.

Reuses email.service, accounting inbound blob endpoint, RequireFeature +
PermissionGate (email.view), Tailwind dark: theming. No schema change.
2026-07-07 03:15:41 +02:00
Luca fcc3e9195d fix(email,ui): billing emails follow customer language + readable payment-check confirmation
- Billing/dunning emails no longer render in the gallery event's language.
  emailProcessor now honors an explicit `__language` in the email data
  (else falls back to the event-first recipient resolution), and the invoice
  reminder passes the customer/invoice locale (customer.preferred_language
  || invoice.language || 'de'). Fixes German customers getting English
  dunning notices. (#760)
- Payment-check confirmation card ("Action recorded") is now theme-adaptive
  (green tint + readable text on both light and dark surfaces) instead of a
  hardcoded light-green mix + dark-green title that vanished in dark mode. (#759)
- The per-customer "Preferred language" field already exists
  (CustomerDetailPage) plus the business-profile default; updated the helper
  text to note billing emails now honor it too. (#761)
2026-07-06 19:27:57 +02:00
Paul Nothaft 60b03b1728 fix(branding): unify hero logo SIZE the same way as visibility (#756)
Follow-on to the visibility fix in this PR — hero_logo_size had the same
split-brain: GalleryLayout read the global branding_logo_size live while
the hero-header path used the per-event snapshot, so a hero logo could
render at different sizes on different layouts and the global size didn't
reach hero-header galleries.

Now mirrored on the visibility model: NULL per-event hero_logo_size =
inherit branding_logo_size; explicit = override.

- Migration 153: hero_logo_size nullable + backfill NULL so existing
  galleries inherit the global size (restores GalleryLayout's prior
  live-global behaviour and fixes the hero-header staleness).
- Creation stores NULL unless explicit; gallery.js resolves
  per-event ?? global and sends the effective size.
- GalleryLayout now consumes that resolved size for the hero logo (new
  heroLogoSize prop) instead of the global — both render paths match.
- Admin size control gains a 'Use branding default' (inherit) option.

Verified: migration on SQLite + PG; live resolution (inherit follows
global both ways, override wins); creation stores NULL on PG; tsc clean,
106 adminEvents+gallery tests pass, build green.
2026-07-06 11:44:33 +02:00
Paul Nothaft 96fe478bf8 fix(branding): make 'Show logo in hero' a true global toggle with per-event override (#756)
Before: the global branding_logo_display_hero toggle was only a
creation-time default — snapshotted into each event's hero_logo_visible
column at creation and never consulted again. Disabling it did nothing
to existing galleries (the reporter's bug), and the two gallery render
paths disagreed (GalleryLayout read the global, HeroHeader read the
per-event snapshot).

Now: NULL per-event hero_logo_visible = 'inherit the global toggle';
an explicit true/false is a per-gallery override.

- Migration 152: make events.hero_logo_visible nullable and NULL out the
  defaulted  rows so existing galleries follow the global going
  forward. Deliberate per-gallery hides () are preserved.
- Creation stores NULL unless the admin explicitly sets it; the update
  path preserves NULL.
- gallery.js resolves per-event ?? global (branding_logo_display_hero,
  default true) and sends the EFFECTIVE value on both gallery responses.
- Both frontend render paths now consume that resolved value
  (GalleryLayout gets it via a new heroLogoVisible prop).
- Admin per-event control is now tri-state: Use branding default /
  Always show / Always hide (en + de).

Verified: SQLite migration + live resolution (inherit follows global
both ways; override wins both ways); PG migration SQL dry-run; the admin
tri-state renders 'Use branding default' for an inherited event; tsc
clean, 106 adminEvents+gallery tests pass, build green.
2026-07-06 11:20:36 +02:00
Paul Nothaft dadaaeea77 feat(setup): final community/thank-you step (#732); fix create-admin button overflow (#730)
#730 — the account step's Create-admin button shared a flex row with Back;
its label + loading spinner exceeded the card width, so the button
overflowed the card outline while submitting (and was fragile for longer
i18n labels). Stack both buttons full-width — the primary always has room
for the spinner now, matching every other wizard step.

#732 — add a final 'community' step, shown once on first-run after
config / no-config, before entering the app. Mission line + four link
cards (report a bug, request a feature, star/share, Buy Me a Coffee),
all target=_blank rel=noopener, and a Finish → Dashboard button. Fully
i18n (en + de). Restore keeps its reload flow (a restored instance is no
longer first-run, so it never reaches this step). Adds .github/FUNDING.yml
so GitHub renders a Sponsor button too.

Verified live: drove the real first-run wizard end to end — stacked
account buttons render inside the card, community step shows the mission
+ all four links, Finish lands on the dashboard.
2026-07-03 12:29:58 +02:00
Paul Nothaft f564b38c5a Merge remote-tracking branch 'origin/main' into refactor/codebase-cleanup
# Conflicts:
#	backend/src/routes/adminEvents.js
#	backend/src/routes/protectedImages.js
#	frontend/src/pages/admin/EventDetailsPage.tsx
2026-07-03 11:54:01 +02:00
Paul Nothaft cf073615ef Merge pull request #739 from PicPeak/feat/admin-mfa
feat: admin two-factor authentication (TOTP) with recovery codes + CLI reset
2026-07-03 11:49:07 +02:00
Paul Nothaft b732974779 Merge pull request #737 from PicPeak/fix/auth-access-control
fix(security): cross-event thumbnail leak, bulk-op ownership bypass + auth hardening
2026-07-03 11:47:25 +02:00