After 'Create draft invoice' mints the single scheduled invoice from a
per-event customer's unbilled hours, navigate to the bill editor so the
admin can add other line items before it ships (invoice is already
status='scheduled' + editable). Updated the per-event hint copy.
New /admin/system-health page (sidebar entry, settings.view) that lists
emails the queue gave up on (status='failed' or pending+retry>=3) with
retry (re-queue) and dismiss (delete) actions. Backend adds /failures,
/failures/email/:id/retry and DELETE on adminSystemHealth. First source
is email failures (the original trigger — quote_sent template errors
left invoices unsent for 14h with no signal); more sources can be added.
Pick the logo by the active color mode with a symmetric fallback: a
single uploaded logo serves both modes (dark uses dark||light, light
uses light||dark). Apply to admin header, customer gallery, and the
customer portal (follows branding_force_color_mode). PDFs already use
the light branding logo with the business-profile PDF logo as override
(resolveLogoFile) — unchanged.
Add an optional dark-mode logo (branding_logo_url_dark) alongside the
main logo. Upload/remove via the logo endpoint (?variant=dark) on the
Branding settings page. Admin header (admin dark mode) and the public
gallery (dark themes) pick the dark logo when active, falling back to
the light logo when unset. PDFs keep using the light logo.
Route 10 surfaces that hardcoded 12-hour date-fns patterns
('h:mm a', 'PPp', 'p', toLocaleTimeString) through useLocalizedDate's
formatDateTime/formatTime so they respect general_date_format +
general_time_format: backup/restore, archives, photo viewer, feedback,
event details, gallery timeline, public quote page, CMS save indicator.
Also pin a lang hint on the business-hours native time inputs (Chrome/Edge
render 24h). Drops now-unused date-fns imports.
Add a paginated, filterable view of the email_queue (recipient, type,
status, queued/sent timestamps, error, event link) as a third tab in
Email config, beside SMTP + Templates. Filters: status, recipient/type
search, created-at range. email_data is never exposed. Pairs with the
"Send queued emails now" flush — flush, then watch what sent/failed.
Hour-entry saves hard-failed with an English-only error when a customer
had no rate, and the standalone hours page showed a disabled rate field
that looked set. Add a global business_profile default_hourly_rate_minor
(migration 113) as the last link in the rate chain
(entry override → customer → install default), so saves succeed with the
global rate. When no rate resolves anywhere, replace the save-time error
with a read-only resolved-rate display + a CTA to set a customer or
install-wide rate, disable Add-entry until a rate/override exists, and
translate the backend HOURLY_RATE_REQUIRED toast (en+de).
Replace the free-text 2-char country code field on the inline customer
create form and the customer detail page with a dropdown that shows
localized country names (Intl.DisplayNames, no hardcoded map) while
still storing the ISO 3166-1 alpha-2 code. The create form now seeds the
default country from the business profile instead of leaving it blank or
guessing CH/FL. The free-text countryName override is kept for the rare
case where an operator wants a custom display string.
Standardize Liechtenstein on the ISO code LI instead of the colloquial
plate code FL so it matches the PDF renderer's locale-aware lookup and
the new dropdown. Migration 110 normalizes existing FL rows to LI on
customer_accounts and business_profile (idempotent, case-insensitive).
Require at least one human-readable identifier (company name or a
contact name) at create time so the form can't produce a nameless row
that's impossible to recognise in lists later. Enforced on both the
frontend (isValid + toast) and the backend POST /admin/customers
validator so the API can't be bypassed.
i18n: en + de updated; other locales fall back to inline English
defaults and should get a native review before release.
The four backup admin panes (BackupHistory, BackupDashboard,
BackupCoverageCard, BackupIntegrityCard) used raw date-fns
`format()` with hard-coded tokens like 'p' (12-hour AM/PM), 'PP',
'PPP', 'PPp', and 'yyyy-MM-dd HH:mm:ss' — ignoring the admin's
configured `general_date_format` and `general_time_format`
settings.
Net effect on a 24h-configured install: backup History row showed
"11:25 PM" instead of "23:25", and the Coverage tab's "Last dump"
+ "Coverage generated" timestamps were stuck on
yyyy-MM-dd HH:mm:ss regardless of the admin's date-format choice.
All four panes now route through `useLocalizedDate()` which honors
both settings + the active i18n locale (per the existing
[[feedback_respect_general_format_settings]] pattern).
Tokens replaced:
format(date, 'p') → formatTime(date)
format(date, 'PP') → format(date)
format(date, 'PPP') → format(date)
format(date, 'PPp') → formatDateTime(date)
format(date, 'yyyy-MM-dd HH:mm:ss') → formatDateTime(date)
format(date, 'yyyy-MM-dd HH:mm') → formatDateTime(date)
No backend changes — settings already shipped via /admin/settings;
this just makes the consumers actually read them.
Rekoo-PS's v3.59.0-beta.0 screenshot showed a different shape than
the truncate fix in e7cf834 addressed. Their company name ("Arkan
Studio") isn't unusually long, but with logo_and_text display mode
on a phone-width viewport the wordmark wrapped to two lines and the
LanguageSelector button — sitting in the right action cluster —
landed visually on top of the wrapped second line.
Truncate alone left "Arkan Studio" rendered as "Ar..." after the
logo image. Functional but ugly, and on accounts where the wordmark
reaches the right cluster the visual overlap returns. Match what
LanguageSelector does for its language name in #527: hide the
wordmark on <sm when a logo is also showing (the logo carries the
identity), keep it on sm+. text_only mode is unchanged — wordmark
shows on every width, otherwise nothing would render.
Truncate stays in place as defensive depth for the text_only path.
Closes the last gap from tonight's backup-hardening: backup_runs.
statistics now carries a `per_path` map keyed by backup_paths.path
(e.g. `events/active`, `business-docs`), with per-bucket count + size.
Backend (backupService.js):
- new `computePerPathStats(backedUpFiles, allFiles)` helper that
bucket-sorts each backed-up file into its owning backup_paths row
by longest-prefix match. Reuses the same backup_paths source the
walker reads, so toggling include_in_default off propagates
correctly. Falls back to LEGACY_BACKUP_PATHS if the table is
missing.
- runBackupInternal calls it after the destination implementation
reports back, includes the result in statistics under both
snake_case (`per_path`) and camelCase (`perPath`) keys for the
same alias treatment the existing fields get.
Frontend (BackupHistory.jsx):
- Backup History detail pane now renders one row per per_path entry
when present, with path label + count + formatted size.
- Falls back to the legacy Photos / Archives / "Other" rendering
when the field is absent (backups taken before this commit). No
breaking change for stored history.
Tests: new backupService.perPathStats.test.js — 2 scenarios pinning
attribution behaviour (single-path, nested-paths-don't-collide).
Plus a NOTE comment about overlapping-path walker behaviour (out of
scope; canonical seed doesn't hit it).
The dashboard widget used `lastBackup.created_at` for the "Last
successful backup: X ago" text — but lastBackup is the most recent
row of any status. So a crashed restore (status=running, never
updated) or a recent failure showed up labeled as the last
successful backup. Same "silent failure not surfaced" class the
restore wizard had.
Backend now returns:
lastSuccessfulBackup — most recent backup_runs with status='completed'
zombieRuns — running rows older than 30 min (likely crashed mid-flight)
lastBackup — unchanged (most recent any status)
Frontend renders:
- "Last successful backup: X ago" — always from lastSuccessfulBackup
- "Last attempt: Y ago · failed/running" — when lastBackup differs
from lastSuccessful. failed shows the first line of error_message
in red; running stays neutral.
- Zombie callout — "N backup(s) running >30min — may have crashed"
in amber, so admin sees stuck rows at a glance.
- Health score downgrades from "excellent" to "warning" if the
latest attempt failed, even when older successes keep the age
fresh — surfaces regressions without erasing the green history.
The progress step used a binary `isRunning ? "in progress" : "completed"`
check. So when the backend rejected the restore (pre-flight validator
threw, path error, etc.) the wizard cheerfully rendered "Restore
completed" with 0% progress and no error context — the admin had to
SSH into the server and inspect `restore_runs.error_message` to find
out what happened.
Now reads the most recent row from `restoreStatus.history[0]` and
renders one of three states:
- running → blue text, progress bar updates
- succeeded → green tick + post-restore actions (existing behaviour)
- failed → red banner with the first line of error_message, and
a callout if was_rollback_attempted is true so the
admin knows the destination is safe to retry on top of.
Net: the wizard now tells the truth about what just happened.
#527 hid the language *name* on <sm to free space for the title.
Since then the right cluster gained dark-mode toggle, notifications,
and the user avatar, and the brand block still had no truncation —
so a long branding_company_name would still push past the available
width into the action buttons on phones.
Defensive fix: min-w-0 on the brand-block wrapper, truncate on the
company-name span, flex-shrink-0 on the logo image. Long names now
ellipsis within the left cluster regardless of how many widgets
fill the right.
The "Content Backed Up" panel in Backup History only counted two
categories (Photos + Archives), so a 3-file backup that landed all
3 in business-docs (Ralf's case after the storage truncation +
restore tonight) showed:
Photos (0 of 0)
Archives (0)
→ total: 3 files
The discrepancy made admins wonder where the 3 files actually went.
Adds two rows:
- "Business documents & other" = files_processed - photos - archives
- "Total files" = files_processed
So the math adds up regardless of which Stage B path the files came
from. Properly per-path-category breakdown requires backend-side
per-path counters (separate follow-up); this commit closes the
visible-discrepancy gap without that schema change.
i18n: en + de added; other locales fall back to en until reviewed.
`BackupHistory.jsx` opened `/admin/backup/download/<id>` via window.open,
which goes to the React SPA's router — no matching route, so it
rendered the "Page Not Found" screen.
The actual download endpoint lives at `/api/admin/backup/download/:id`
on the backend (adminBackup.js:685). Cookie-based admin auth already
supports the implicit cookie sent by window.open, so the URL prefix
was the only thing missing.
Predates today's backup-hardening work — the bug has existed since
this download button shipped. Surfaced now because Ralf finally has a
completed backup to try downloading after the Stage A inline-dump
guard started working.
Stage C of the three-stage backup-hardening plan (Stage A: inline
DB dump + fail-loud landed in 7fdf01a; Stage B: config-driven walker
in 302fc6b). Answers the "what would I lose if I clicked Run Backup
Now right now?" question that Stage B made possible to answer.
Backend:
- new backupCoverageService.js: per-path coverage classification,
drift detection (top-level subdirs not in backup_paths and not
in the backups/tmp allow-list), DB-dump mode + staleness block
- new GET /api/admin/system-health/backup-coverage route, same
auth + settings.view permission as /backup-integrity
- 7 integration scenarios pinning the classifier behaviour
Frontend:
- new BackupCoverageCard with auto-fetch (cheap; no recursion)
- new Coverage tab on BackupManagement next to Integrity
- en + de i18n; other locales fall back to en keys until a native
speaker reviews
Verification:
- 26/26 backup integration tests pass (Stage A 5 + Stage B 7 +
Stage C 7 + adminBackupIntegrity 4 + businessDocs 3)
- frontend build clean
- 4 pre-existing integration failures confirmed unrelated
Frontend half of the diagnostic shipped in 4812fcd. Adds:
- BackupIntegrityCard component — runs the check on demand, surfaces
the five summary counters (total / verifiedOk / existsButNoHash /
missing / hashMismatches), and expands collapsible result tables
for missing files + hash mismatches. existsButNoHash is exposed as
a separate amber-toned bucket so admins can distinguish hash-
verified evidence from existence-only at a glance — the latter is
explicitly weaker in a legal dispute and the UI says so.
- "Integrity" tab on BackupManagement, alongside the existing
Dashboard / Configuration / History / Restore tabs. Card is
portable — when the System Health page (backlog item) lands it
can lift the component without changes.
- Post-restore CTA on the RestoreWizard success card (D2 follow-
through): "Verify document integrity now" button that switches
the parent tab to Integrity. The audit trail captured at sign /
issue time is worth nothing if the documents it refers to are
missing from the restored copy — verifier surfaces that drift
in one click before the admin trusts the restored state.
i18n strings added in EN + DE (per user_languages — only those two
are native; other locales fall back to the English defaults and
should be flagged for native-speaker review per
feedback_translation_flagging if anyone picks them up).
Closes#567.
The sidebar already had a "vX.Y.Z available" indicator (#566 made it a
link to that release's page) but there was no way to read the actual
changelog inline or to grab a copy-paste upgrade command. This adds
the modal the issue spec'd, layered on top of the existing
updateCheckService / environmentService backend infrastructure that
already shipped.
## Backend
- `updateCheckService.fetchAvailableVersions` now returns full release
objects (tag, name, body, publishedAt, htmlUrl) instead of just
version strings — body data is what the changelog modal renders.
`checkForUpdates` extracts the version strings for its existing
consumers; no API change visible to callers.
- New `getReleasesSince(currentVersion, channel)` returns the list of
releases strictly newer than current, filtered to the user's
channel. Reuses the same 1-hour cache as `checkForUpdates` so the
modal opening doesn't trigger an extra GitHub round-trip.
- New `GET /admin/system/updates/changelog` route in `adminSystem.js`,
same auth + UPDATE_CHECK_ENABLED gating as the existing
/updates and /updates/instructions endpoints.
- 4 unit tests (axios mocked) pin: strictly-newer filtering,
channel-scoped, empty array on GitHub fetch failure, empty array
when already on latest.
## Frontend
- New `UpdateAvailableModal.tsx` — opens from the sidebar chip. Two
sections:
1. **How to upgrade** — fetches /updates/instructions for the
environment-detected copy-paste command (Docker compose / git /
standalone). Copy-to-clipboard button per step.
2. **Release notes** — fetches /updates/changelog for every
version between current and latest in the user's channel.
Latest is auto-expanded; older releases are collapsed by
default (click to expand). Each release also has a "View on
GitHub" link to the canonical release page.
- Renders release body markdown through the existing safe
MarkdownContent component (marked + DOMPurify allowlist).
- New `updateDismissal.ts` helper — single localStorage key holds the
last-dismissed version. Chip stays hidden until a STRICTLY newer
version appears, using the same compare semantics as the backend
(stable > beta, higher beta > lower beta, semantic numeric on
major.minor.patch). 9 unit tests pin the rules.
- `VersionInfo.tsx` — chip is now a button that opens the modal
instead of an external link (the #566 link-to-release behaviour is
preserved on the modal's per-release "View on GitHub" affordance).
Dismissal triggers an immediate re-render so the chip disappears
without waiting for the next route change.
No new dependencies — uses `marked` + `DOMPurify` that were already
present in the bundle for the contract block renderer.
Closes#566.
The admin sidebar showed the running frontend + backend versions as
plain text. Wraps each version (and the "update available" indicator)
in an anchor pointing at the corresponding GitHub release tag, opening
in a new tab so the admin session isn't disrupted.
A small githubReleaseUrl helper (extracted to its own module for
testability) does the version → URL mapping. Because release-please
tags every release as `vX.Y.Z[-beta.N]`, the version string already
carries the channel suffix and a pure template covers both stable and
beta without branching.
Three unit tests pin the URL template — stable, beta-with-suffix, and
a defensive check that the leading `v` isn't double-prefixed if a
caller accidentally passes a tag-shaped value.
- frontend: `npm test` now runs all 7 vitest suites instead of one hardcoded
file; the previously skipped ProtectedImage / Skeleton / usePublicSettings /
contrast / themeMigration / url suites are now active in CI
- frontend: wrap ThemeCustomizerEnhanced test in QueryClientProvider so the
newly-enabled run passes (component uses useQuery internally)
- root: drop unused better-sqlite3 / canvas / node-fetch + their
prebuild-install/tar-fs override (backend keeps its own copies); add dotenv
so playwright.config.ts can load on a clean install; add name/version/private
- LegalPage: scheme-validate external_url before window.location.replace so a
CMS edit can't redirect visitors to javascript:/data:
- LegalPage: force rel="noopener noreferrer" on target="_blank" anchors in
sanitized CMS HTML to block reverse-tabnabbing
Regression of #208. PR #214 (commit 02a46e0, re-merged at 9b7495e)
shipped the configurable `general_max_upload_batch_size_mb` setting so
users behind Cloudflare Tunnel and other reverse proxies with
per-request size caps could lower the chunked-upload size below their
proxy's limit. Six days later the "Merge main into beta for
release/beta-to-main" commit (28793bb) resolved its conflict by
keeping main's older tree — which silently deleted the migration
(072), the setting input on Settings → General, the i18n strings, the
`useSettingsState` field, and the read in PhotoUpload.tsx, putting the
hardcoded 500MB chunk back. Galleries fronted by Cloudflare have
quietly been broken on batch uploads since then.
Re-applying exactly the same change set:
- `backend/migrations/core/072_add_max_upload_batch_size.js`
recreated, with a comment pointing at the regression in case the
same merge accident happens again.
- `frontend/src/components/admin/PhotoUpload.tsx` line 168 now reads
the setting from query cache and falls back to 95MB (Cloudflare-safe
headroom under 100MB).
- `useSettingsState.ts`, `GeneralTab.tsx`, `en.json`, `de.json` —
added the field to the state type + defaults + load path + the
Site-Configuration input.
Existing installs are safe either way:
- Ran original 072 then lost the file: migrations table still has the
filename, so the runner skips re-applying. The setting row in
`app_settings` is also untouched (the deletion was source-only, no
down migration ran). Now the new code starts reading it again.
- Installed after the regression: migrations runner picks up the new
072 normally and seeds the setting at 95.
The dashed-border upload area in `PhotoUpload` (admin) and
`UserPhotoUpload` (gallery user-upload) is styled and labelled as a
drop zone — every locale's `upload.clickToUpload` already reads
"Click to upload or drag and drop" or its translation — but neither
component had any `onDragOver` / `onDragEnter` / `onDragLeave` /
`onDrop` handlers. Files dropped on the zone fell through to the
browser's default behaviour (open the image in a new tab), which is
what Rekoo-PS reported.
Added native HTML5 drag-and-drop wiring on both components, plumbed
through the same filter/limit/toast pipeline used by the click path
(`addFiles` helper). Visual highlight on drag-over via an `isDragOver`
flag; the listener guards against the `dragleave` strobing that fires
on every child node. Also reset the `<input>` value after onChange so
re-picking the same file still triggers an upload — matches the
new drop-then-pick mental model.
Adds back the "Show logo" / "Show company name" toggles for the
customer dashboard, scoped to /customer/* surfaces only. Lives as a
dedicated card at the bottom of Settings → Branding, gated by the
customerPortal feature flag so admins who haven't enabled the portal
don't see it.
* Backend: restored GET/PUT /admin/settings/customer-surface
endpoints, whitelisted only to the two branding keys
(customer_show_logo, customer_show_company_name). The
calendar/quotes/bills feature globals that used to live on this
endpoint are now driven by the Features tab (feature_flags table).
* customerAccountsService.getCustomerSurfaceGlobals() reads from
app_settings again so /api/customer/auth/session honours the
toggles in its branding payload.
* New CustomerDashboardBrandingCard component with its own save
flow — separate from the main BrandingPage payload so flipping a
toggle doesn't replay the full branding mutation.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
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.
The bulk-delete modal previously used a password input as a confirmation
gate, with an Enter-to-submit handler. Windows Hello / passkey flows
that target password fields were able to autofill and synthesise an
Enter keystroke, which submitted the form and triggered the destructive
delete without an explicit click on the red Delete button (Rekoo's
report in #417).
Replace the password gate with a GitHub-style typed-literal pattern:
the user types the literal "DELETE" (English, case-sensitive) into a
plain text input. The Delete button stays disabled until the input
matches, and there is no Enter-to-submit handler — only an explicit
click on the red button proceeds. Plain text inputs aren't subject to
password autofill or passkey ceremony so the auto-submit class of bug
is gone.
Server side, drop the bcrypt password verify on /admin/events/bulk-delete
and the related INVALID_PASSWORD response. The server's auth boundary
remains adminAuth + requirePermission('events.delete'); this matches
DELETE /admin/events/:id which has never required a re-entered password.
The client-side typed gate is the safeguard against accidental clicks.
i18n: drop password-related keys, add confirmLabel + confirmHelp across
en, de, nl, pt, ru. The literal "DELETE" stays English in all locales
to keep the gesture immune to translation drift and unambiguous.
Verified locally: typed-DELETE sanity spec covers the gate (wrong case
disabled, correct enables, Enter-on-input no-ops, click submits, events
deleted). Existing 03-bulk-archive smoke remains green.