From 6046594a15afbbb4c9b50e344bcf417a1346102f Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 17 Apr 2026 12:29:14 +0000 Subject: [PATCH 1/3] fix(GRO-642): add ARIA dialog attributes to remaining modals Add role="dialog", aria-modal="true", focus trap, Escape-to-close, and focus-restore-on-close to Invoices.tsx and Clients.tsx Modal components, and to the two inline modals in BillingPayments.tsx. Co-Authored-By: Paperclip --- apps/web/src/pages/Clients.tsx | 46 ++++++++++++++- apps/web/src/pages/Invoices.tsx | 56 +++++++++++++++++-- .../src/portal/sections/BillingPayments.tsx | 52 +++++++++++++++-- 3 files changed, 142 insertions(+), 12 deletions(-) diff --git a/apps/web/src/pages/Clients.tsx b/apps/web/src/pages/Clients.tsx index 0e40212..8f89d52 100644 --- a/apps/web/src/pages/Clients.tsx +++ b/apps/web/src/pages/Clients.tsx @@ -857,12 +857,56 @@ export function ClientsPage() { // ─── Shared UI ─────────────────────────────────────────────────────────────── function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) { + const modalRef = useRef(null); + + useEffect(() => { + const previouslyFocused = document.activeElement as HTMLElement; + const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + const focusableElements = modalRef.current?.querySelectorAll(focusableSelectors); + const firstFocusable = focusableElements?.[0]; + firstFocusable?.focus(); + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + onClose(); + return; + } + if (e.key !== "Tab") return; + if (!modalRef.current) return; + const focusables = modalRef.current.querySelectorAll(focusableSelectors); + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last?.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first?.focus(); + } + } + } + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + previouslyFocused?.focus(); + }; + }, [onClose]); + return (
{ if (e.target === e.currentTarget) onClose(); }} > -
+
{children}
diff --git a/apps/web/src/pages/Invoices.tsx b/apps/web/src/pages/Invoices.tsx index 2cbf3ae..6b9424c 100644 --- a/apps/web/src/pages/Invoices.tsx +++ b/apps/web/src/pages/Invoices.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit } from "@groombook/types"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -686,19 +686,63 @@ export function InvoicesPage() { // ─── Shared UI helpers ──────────────────────────────────────────────────────── function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) { + const modalRef = useRef(null); + + useEffect(() => { + const previouslyFocused = document.activeElement as HTMLElement; + const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + const focusableElements = modalRef.current?.querySelectorAll(focusableSelectors); + const firstFocusable = focusableElements?.[0]; + firstFocusable?.focus(); + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + onClose(); + return; + } + if (e.key !== "Tab") return; + if (!modalRef.current) return; + const focusables = modalRef.current.querySelectorAll(focusableSelectors); + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last?.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first?.focus(); + } + } + } + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + previouslyFocused?.focus(); + }; + }, [onClose]); + return (
{ if (e.target === e.currentTarget) onClose(); }} > -
+
{children}
diff --git a/apps/web/src/portal/sections/BillingPayments.tsx b/apps/web/src/portal/sections/BillingPayments.tsx index 6bcfb17..d47bea4 100644 --- a/apps/web/src/portal/sections/BillingPayments.tsx +++ b/apps/web/src/portal/sections/BillingPayments.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { loadStripe } from "@stripe/stripe-js"; import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js"; import { CreditCard, DollarSign, Package, Zap } from "lucide-react"; @@ -356,6 +356,48 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr const [isProcessing, setIsProcessing] = useState(false); const [isComplete, setIsComplete] = useState(false); const [error, setError] = useState(null); + const completeModalRef = useRef(null); + const paymentModalRef = useRef(null); + + // Focus trap + Escape-to-close for both inline modals + useEffect(() => { + const modalRef = isComplete ? completeModalRef.current : paymentModalRef.current; + if (!modalRef) return; + + const previouslyFocused = document.activeElement as HTMLElement; + const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + const focusableElements = modalRef.querySelectorAll(focusableSelectors); + const firstFocusable = focusableElements[0]; + firstFocusable?.focus(); + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + onClose(); + return; + } + if (e.key !== "Tab" || !modalRef) return; + const focusables = modalRef.querySelectorAll(focusableSelectors); + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last?.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first?.focus(); + } + } + } + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + previouslyFocused?.focus(); + }; + }, [isComplete, onClose]); const formatCents = (cents: number) => new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100); @@ -420,8 +462,8 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr if (isComplete) { return ( -
-
+
+
@@ -440,8 +482,8 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr } return ( -
-
+
+

Pay Outstanding Balance

-
+
{renderSection()}
diff --git a/apps/web/src/portal/sections/BillingPayments.tsx b/apps/web/src/portal/sections/BillingPayments.tsx index 6bcfb17..27709d1 100644 --- a/apps/web/src/portal/sections/BillingPayments.tsx +++ b/apps/web/src/portal/sections/BillingPayments.tsx @@ -130,7 +130,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
)} -
+
{([ { id: "invoices" as const, label: "Invoices", icon: DollarSign }, { id: "payment" as const, label: "Payment Methods", icon: CreditCard }, -- 2.52.0 From 1bc8a94a297de78d63540170cef8bea186f64dbb Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 17 Apr 2026 17:07:52 +0000 Subject: [PATCH 3/3] fix(GRO-769): proxy logo uploads through API server to fix mixed content The pre-signed URL flow used an internal HTTP endpoint for S3 uploads, which browsers blocked as mixed content on HTTPS pages. Instead of generating a pre-signed URL that the browser uploads to directly, the new /logo/upload endpoint receives the file via multipart POST and streams it to S3 from the API server using the internal endpoint. This resolves the mixed content error that was blocking logo uploads on dev.groombook.dev. Co-Authored-By: Paperclip --- apps/api/src/lib/s3.ts | 19 +++++++++ apps/api/src/routes/settings.ts | 73 ++++++++++++++++++++++++++++++++- apps/web/src/pages/Settings.tsx | 42 ++++++------------- 3 files changed, 103 insertions(+), 31 deletions(-) diff --git a/apps/api/src/lib/s3.ts b/apps/api/src/lib/s3.ts index c242ff9..b0793c5 100644 --- a/apps/api/src/lib/s3.ts +++ b/apps/api/src/lib/s3.ts @@ -67,3 +67,22 @@ export async function deleteObject(key: string): Promise { }) ); } + +/** Upload an object directly to S3 (server-side only, not a pre-signed URL). */ +export async function putObject( + key: string, + body: Buffer | Uint8Array | string, + contentType: string, + contentLength: number +): Promise { + const client = getS3Client(); + await client.send( + new PutObjectCommand({ + Bucket: getBucket(), + Key: key, + Body: body, + ContentType: contentType, + ContentLength: contentLength, + }) + ); +} diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index ec1f8fa..fe06b80 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -2,7 +2,7 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; import { eq, getDb, businessSettings } from "@groombook/db"; -import { getPresignedUploadUrl, getPresignedGetUrl, deleteObject } from "../lib/s3.js"; +import { getPresignedUploadUrl, getPresignedGetUrl, deleteObject, putObject } from "../lib/s3.js"; import { requireSuperUser } from "../middleware/rbac.js"; export const settingsRouter = new Hono(); @@ -100,6 +100,77 @@ settingsRouter.post( } ); +/** + * POST /api/admin/settings/logo/upload + * Proxy upload through the API server to avoid mixed-content issues with + * pre-signed URLs that use the internal HTTP endpoint. The file is uploaded + * directly to S3 from the server using the internal endpoint. + */ +settingsRouter.post("/logo/upload", requireSuperUser(), async (c) => { + const db = getDb(); + + // Parse multipart form data (file field) + const body = await c.req.parseBody({ all: true }); + const file = body["file"]; + + if (!file || !(file instanceof File)) { + return c.json({ error: "No file provided" }, 400); + } + + const contentType = file.type; + if (!ALLOWED_LOGO_TYPES.has(contentType)) { + return c.json( + { + error: + "contentType must be one of: image/png, image/svg+xml, image/jpeg, image/webp", + }, + 400 + ); + } + + const fileSizeBytes = file.size; + if (fileSizeBytes > MAX_LOGO_SIZE) { + return c.json({ error: "File must not exceed 512 KB" }, 400); + } + + const rows = await db.select().from(businessSettings).limit(1); + if (!rows[0]) { + return c.json({ error: "Settings not found" }, 404); + } + const settingsId = rows[0].id; + + const ext = contentType.split("/")[1] ?? "png"; + const key = `logos/${settingsId}/${Date.now()}.${ext}`; + + // Read file into buffer and upload directly to S3 (bypasses pre-signed URL) + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + await putObject(key, buffer, contentType, fileSizeBytes); + + // Delete previous S3 object if any + if (rows[0].logoKey) { + await deleteObject(rows[0].logoKey); + } + + // Update database with new logo key + const [updated] = await db + .update(businessSettings) + .set({ + logoKey: key, + logoBase64: null, + logoMimeType: null, + updatedAt: new Date(), + }) + .where(eq(businessSettings.id, settingsId)) + .returning(); + + if (!updated) { + return c.json({ error: "Settings not found" }, 404); + } + + return c.json({ ok: true, logoKey: updated.logoKey }); +}); + /** * POST /api/admin/settings/logo/confirm * Called after the client has successfully uploaded to the presigned URL. diff --git a/apps/web/src/pages/Settings.tsx b/apps/web/src/pages/Settings.tsx index 8d70d06..c1f01ce 100644 --- a/apps/web/src/pages/Settings.tsx +++ b/apps/web/src/pages/Settings.tsx @@ -158,46 +158,28 @@ export function SettingsPage() { } try { - // Step 1: Get presigned upload URL - const uploadRes = await fetch("/api/admin/settings/logo/upload-url", { + // Upload directly through the API server to avoid mixed-content issues + // with pre-signed URLs that use the internal HTTP endpoint + const formData = new FormData(); + formData.append("file", file); + + const uploadRes = await fetch("/api/admin/settings/logo/upload", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ contentType: file.type, fileSizeBytes: file.size }), + body: formData, }); if (!uploadRes.ok) { const err = await uploadRes.json().catch(() => null); - throw new Error(err?.error ?? "Failed to get upload URL"); + throw new Error(err?.error ?? "Failed to upload logo"); } - const { uploadUrl, key } = await uploadRes.json(); + const { logoKey } = await uploadRes.json(); - // Step 2: PUT the file directly to S3 - const putRes = await fetch(uploadUrl, { - method: "PUT", - headers: { "Content-Type": file.type }, - body: file, - }); - if (!putRes.ok) { - throw new Error("Failed to upload logo to storage"); - } - - // Step 3: Confirm the upload - const confirmRes = await fetch("/api/admin/settings/logo/confirm", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ key }), - }); - if (!confirmRes.ok) { - const err = await confirmRes.json().catch(() => null); - throw new Error(err?.error ?? "Failed to confirm logo upload"); - } - - // Step 4: Fetch the presigned GET URL for display + // Fetch the presigned GET URL for display const logoRes = await fetch("/api/admin/settings/logo"); if (logoRes.ok) { const logoData = await logoRes.json(); - setForm((f) => ({ ...f, logoKey: key, logoUrl: logoData.url, logoBase64: null, logoMimeType: null })); + setForm((f) => ({ ...f, logoKey, logoUrl: logoData.url, logoBase64: null, logoMimeType: null })); } else { - setForm((f) => ({ ...f, logoKey: key, logoUrl: null, logoBase64: null, logoMimeType: null })); + setForm((f) => ({ ...f, logoKey, logoUrl: null, logoBase64: null, logoMimeType: null })); } setMessage({ type: "success", text: "Logo uploaded." }); refresh(); -- 2.52.0