Merge pull request #313 from groombook/feature/gro-628-frontend-error-handling
fix(GRO-628): implement frontend error handling and code quality fixes
This commit was merged in pull request #313.
This commit is contained in:
+62
-2
@@ -72,6 +72,60 @@ app.route("/api/webhooks/stripe", webhooksRouter);
|
|||||||
// Dev/demo routes — config is always public, users endpoint is guarded internally
|
// Dev/demo routes — config is always public, users endpoint is guarded internally
|
||||||
app.route("/api/dev", devRouter);
|
app.route("/api/dev", devRouter);
|
||||||
|
|
||||||
|
// Magic bytes for allowed image types
|
||||||
|
const ALLOWED_IMAGE_TYPES: Record<string, Uint8Array> = {
|
||||||
|
"image/png": new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||||
|
"image/jpeg": new Uint8Array([0xff, 0xd8, 0xff]),
|
||||||
|
"image/gif": new Uint8Array([0x47, 0x49, 0x46, 0x38]),
|
||||||
|
"image/webp": new Uint8Array([0x52, 0x49, 0x46, 0x46]), // followed by size then WEBP
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that the given base64 content matches the declared MIME type
|
||||||
|
* by checking magic bytes. Returns null if valid, or the field to clear if not.
|
||||||
|
*/
|
||||||
|
function validateLogoMagicBytes(
|
||||||
|
logoBase64: string | null,
|
||||||
|
logoMimeType: string | null
|
||||||
|
): "logoBase64" | "logoMimeType" | null {
|
||||||
|
if (!logoBase64 || !logoMimeType) return null;
|
||||||
|
|
||||||
|
const expectedMagic = ALLOWED_IMAGE_TYPES[logoMimeType];
|
||||||
|
if (!expectedMagic) return "logoMimeType"; // unknown MIME type — reject
|
||||||
|
|
||||||
|
try {
|
||||||
|
const binary = Buffer.from(logoBase64, "base64");
|
||||||
|
// WebP needs a special check (RIFF....WEBP at offset 0, size at offset 4)
|
||||||
|
if (logoMimeType === "image/webp") {
|
||||||
|
if (binary.length < 12) return "logoBase64";
|
||||||
|
const webpMagic = binary.slice(0, 4);
|
||||||
|
const webpSig = binary.slice(8, 12);
|
||||||
|
if (
|
||||||
|
webpMagic[0] !== 0x52 ||
|
||||||
|
webpMagic[1] !== 0x49 ||
|
||||||
|
webpMagic[2] !== 0x46 ||
|
||||||
|
webpMagic[3] !== 0x46 ||
|
||||||
|
webpSig[0] !== 0x57 ||
|
||||||
|
webpSig[1] !== 0x45 ||
|
||||||
|
webpSig[2] !== 0x42 ||
|
||||||
|
webpSig[3] !== 0x50
|
||||||
|
) {
|
||||||
|
return "logoBase64";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All other types: check prefix
|
||||||
|
if (binary.length < expectedMagic.length) return "logoBase64";
|
||||||
|
for (let i = 0; i < expectedMagic.length; i++) {
|
||||||
|
if (binary[i] !== expectedMagic[i]) return "logoBase64";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return "logoBase64";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Public branding endpoint — no auth required, returns business name/colors/logo
|
// Public branding endpoint — no auth required, returns business name/colors/logo
|
||||||
app.get("/api/branding", async (c) => {
|
app.get("/api/branding", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
@@ -87,13 +141,19 @@ app.get("/api/branding", async (c) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Defensive: validate magic bytes to prevent MIME type confusion attacks
|
||||||
|
// via the legacy base64 logo fields
|
||||||
|
const badField = validateLogoMagicBytes(settings.logoBase64 ?? null, settings.logoMimeType ?? null);
|
||||||
|
const safeLogoBase64 = badField === "logoBase64" ? null : settings.logoBase64;
|
||||||
|
const safeLogoMimeType = badField === "logoMimeType" ? null : settings.logoMimeType;
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
businessName: settings.businessName,
|
businessName: settings.businessName,
|
||||||
primaryColor: settings.primaryColor,
|
primaryColor: settings.primaryColor,
|
||||||
accentColor: settings.accentColor,
|
accentColor: settings.accentColor,
|
||||||
logoUrl,
|
logoUrl,
|
||||||
logoBase64: settings.logoBase64,
|
logoBase64: safeLogoBase64,
|
||||||
logoMimeType: settings.logoMimeType,
|
logoMimeType: safeLogoMimeType,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { MiddlewareHandler } from "hono";
|
||||||
|
import { getDb, impersonationAuditLogs } from "@groombook/db";
|
||||||
|
import type { PortalEnv } from "./portalSession.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side audit logging middleware for portal routes.
|
||||||
|
* Applied after validatePortalSession in the middleware chain.
|
||||||
|
*
|
||||||
|
* After the route handler completes (await next()), inserts an audit log entry
|
||||||
|
* into impersonationAuditLogs:
|
||||||
|
* - sessionId: from c.get("portalSessionId")
|
||||||
|
* - action: "{METHOD} {routePath}" (e.g., "GET /portal/appointments")
|
||||||
|
* - pageVisited: c.req.path
|
||||||
|
* - metadata: { method, statusCode: c.res.status }
|
||||||
|
*
|
||||||
|
* Log entries are written for both success and error responses.
|
||||||
|
* Does NOT throw if audit logging fails — errors are logged but the user's
|
||||||
|
* request is not affected.
|
||||||
|
*/
|
||||||
|
export const portalAudit: MiddlewareHandler<PortalEnv> = async (c, next) => {
|
||||||
|
await next();
|
||||||
|
|
||||||
|
const sessionId = c.get("portalSessionId");
|
||||||
|
if (!sessionId) return;
|
||||||
|
|
||||||
|
const method = c.req.method;
|
||||||
|
const routePath = c.req.path;
|
||||||
|
const pageVisited = c.req.path;
|
||||||
|
const statusCode = c.res.status;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
await db
|
||||||
|
.insert(impersonationAuditLogs)
|
||||||
|
.values({
|
||||||
|
sessionId,
|
||||||
|
action: `${method} ${routePath}`,
|
||||||
|
pageVisited,
|
||||||
|
metadata: { method, statusCode },
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[portalAudit] Failed to write audit log:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { MiddlewareHandler } from "hono";
|
||||||
|
import { and, eq, getDb, impersonationSessions } from "@groombook/db";
|
||||||
|
|
||||||
|
export interface PortalEnv {
|
||||||
|
Variables: {
|
||||||
|
portalClientId: string;
|
||||||
|
portalSessionId: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the X-Impersonation-Session-Id header against the impersonationSessions table.
|
||||||
|
* Must be applied to all portal routes.
|
||||||
|
*
|
||||||
|
* Reads x-session-id from request headers, queries impersonationSessions for a row where
|
||||||
|
* id = sessionId AND status = 'active', and checks session.expiresAt > new Date().
|
||||||
|
* Returns 401 if session is invalid/missing/expired.
|
||||||
|
* On success, sets c.set("portalClientId", session.clientId) and c.set("portalSessionId", session.id).
|
||||||
|
*/
|
||||||
|
export const validatePortalSession: MiddlewareHandler<PortalEnv> = async (c, next) => {
|
||||||
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
if (!sessionId) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const [session] = await db
|
||||||
|
.select()
|
||||||
|
.from(impersonationSessions)
|
||||||
|
.where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active")))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!session || session.expiresAt <= new Date()) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
c.set("portalClientId", session.clientId);
|
||||||
|
c.set("portalSessionId", session.id);
|
||||||
|
await next();
|
||||||
|
};
|
||||||
+23
-122
@@ -1,33 +1,22 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod/v3";
|
import { z } from "zod/v3";
|
||||||
import { and, eq, inArray } from "@groombook/db";
|
import { eq, inArray } from "@groombook/db";
|
||||||
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
|
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
import { validatePortalSession } from "../middleware/portalSession.js";
|
||||||
|
import { portalAudit } from "../middleware/portalAudit.js";
|
||||||
|
import type { PortalEnv } from "../middleware/portalSession.js";
|
||||||
|
|
||||||
export const portalRouter = new Hono<AppEnv>();
|
export const portalRouter = new Hono<PortalEnv>();
|
||||||
|
|
||||||
// ─── Session helper ───────────────────────────────────────────────────────────
|
// Apply middleware to all portal routes
|
||||||
|
portalRouter.use("/*", validatePortalSession, portalAudit);
|
||||||
async function getClientIdFromSession(sessionId: string | null | undefined): Promise<string | null> {
|
|
||||||
if (!sessionId) return null;
|
|
||||||
const db = getDb();
|
|
||||||
const [session] = await db
|
|
||||||
.select()
|
|
||||||
.from(impersonationSessions)
|
|
||||||
.where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active")))
|
|
||||||
.limit(1);
|
|
||||||
if (!session || session.expiresAt <= new Date()) return null;
|
|
||||||
return session.clientId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── GET routes ──────────────────────────────────────────────────────────────
|
// ─── GET routes ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
portalRouter.get("/me", async (c) => {
|
portalRouter.get("/me", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
const clientId = c.get("portalClientId");
|
||||||
const clientId = await getClientIdFromSession(sessionId);
|
|
||||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
|
|
||||||
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
|
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
|
||||||
if (!client) return c.json({ error: "Not found" }, 404);
|
if (!client) return c.json({ error: "Not found" }, 404);
|
||||||
@@ -49,9 +38,7 @@ portalRouter.get("/services", async (c) => {
|
|||||||
|
|
||||||
portalRouter.get("/appointments", async (c) => {
|
portalRouter.get("/appointments", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
const clientId = c.get("portalClientId");
|
||||||
const clientId = await getClientIdFromSession(sessionId);
|
|
||||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const allAppts = await db
|
const allAppts = await db
|
||||||
@@ -101,9 +88,7 @@ portalRouter.get("/appointments", async (c) => {
|
|||||||
|
|
||||||
portalRouter.get("/pets", async (c) => {
|
portalRouter.get("/pets", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
const clientId = c.get("portalClientId");
|
||||||
const clientId = await getClientIdFromSession(sessionId);
|
|
||||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
|
|
||||||
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId));
|
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId));
|
||||||
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes })));
|
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes })));
|
||||||
@@ -111,9 +96,7 @@ portalRouter.get("/pets", async (c) => {
|
|||||||
|
|
||||||
portalRouter.get("/invoices", async (c) => {
|
portalRouter.get("/invoices", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
const clientId = c.get("portalClientId");
|
||||||
const clientId = await getClientIdFromSession(sessionId);
|
|
||||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
|
|
||||||
const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId));
|
const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId));
|
||||||
const invoiceIds = clientInvoices.map(i => i.id);
|
const invoiceIds = clientInvoices.map(i => i.id);
|
||||||
@@ -148,12 +131,7 @@ portalRouter.patch(
|
|||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
|
const clientId = c.get("portalClientId");
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
|
||||||
const clientId = await getClientIdFromSession(sessionId);
|
|
||||||
if (!clientId) {
|
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [appt] = await db
|
const [appt] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -196,12 +174,7 @@ portalRouter.patch(
|
|||||||
portalRouter.post("/appointments/:id/confirm", async (c) => {
|
portalRouter.post("/appointments/:id/confirm", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
|
const clientId = c.get("portalClientId");
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
|
||||||
const clientId = await getClientIdFromSession(sessionId);
|
|
||||||
if (!clientId) {
|
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [appt] = await db
|
const [appt] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -250,12 +223,7 @@ portalRouter.post("/appointments/:id/confirm", async (c) => {
|
|||||||
portalRouter.post("/appointments/:id/cancel", async (c) => {
|
portalRouter.post("/appointments/:id/cancel", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
|
const clientId = c.get("portalClientId");
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
|
||||||
const clientId = await getClientIdFromSession(sessionId);
|
|
||||||
if (!clientId) {
|
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [appt] = await db
|
const [appt] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -319,28 +287,7 @@ portalRouter.post(
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
const clientId = c.get("portalClientId");
|
||||||
|
|
||||||
let clientId: string | null = null;
|
|
||||||
if (sessionId) {
|
|
||||||
const [session] = await db
|
|
||||||
.select()
|
|
||||||
.from(impersonationSessions)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(impersonationSessions.id, sessionId),
|
|
||||||
eq(impersonationSessions.status, "active")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
if (session && session.expiresAt > new Date()) {
|
|
||||||
clientId = session.clientId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!clientId) {
|
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [entry] = await db
|
const [entry] = await db
|
||||||
.insert(waitlistEntries)
|
.insert(waitlistEntries)
|
||||||
@@ -364,26 +311,7 @@ portalRouter.patch(
|
|||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
const clientId = c.get("portalClientId");
|
||||||
|
|
||||||
if (!sessionId) {
|
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [session] = await db
|
|
||||||
.select()
|
|
||||||
.from(impersonationSessions)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(impersonationSessions.id, sessionId),
|
|
||||||
eq(impersonationSessions.status, "active")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!session || session.expiresAt <= new Date()) {
|
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -392,7 +320,7 @@ portalRouter.patch(
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!existing) return c.json({ error: "Not found" }, 404);
|
if (!existing) return c.json({ error: "Not found" }, 404);
|
||||||
if (existing.clientId !== session.clientId) {
|
if (existing.clientId !== clientId) {
|
||||||
return c.json({ error: "Forbidden" }, 403);
|
return c.json({ error: "Forbidden" }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,26 +342,7 @@ portalRouter.patch(
|
|||||||
portalRouter.delete("/waitlist/:id", async (c) => {
|
portalRouter.delete("/waitlist/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
const clientId = c.get("portalClientId");
|
||||||
|
|
||||||
if (!sessionId) {
|
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [session] = await db
|
|
||||||
.select()
|
|
||||||
.from(impersonationSessions)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(impersonationSessions.id, sessionId),
|
|
||||||
eq(impersonationSessions.status, "active")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!session || session.expiresAt <= new Date()) {
|
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [entry] = await db
|
const [entry] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -442,7 +351,7 @@ portalRouter.delete("/waitlist/:id", async (c) => {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!entry) return c.json({ error: "Not found" }, 404);
|
if (!entry) return c.json({ error: "Not found" }, 404);
|
||||||
if (entry.clientId !== session.clientId) {
|
if (entry.clientId !== clientId) {
|
||||||
return c.json({ error: "Forbidden" }, 403);
|
return c.json({ error: "Forbidden" }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,9 +384,7 @@ portalRouter.post(
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
const clientId = c.get("portalClientId");
|
||||||
const clientId = await getClientIdFromSession(sessionId);
|
|
||||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
|
|
||||||
const invoiceRows = await db
|
const invoiceRows = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -514,9 +421,7 @@ portalRouter.post(
|
|||||||
);
|
);
|
||||||
|
|
||||||
portalRouter.get("/payment-methods", async (c) => {
|
portalRouter.get("/payment-methods", async (c) => {
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
const clientId = c.get("portalClientId");
|
||||||
const clientId = await getClientIdFromSession(sessionId);
|
|
||||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
|
|
||||||
const methods = await listPaymentMethods(clientId);
|
const methods = await listPaymentMethods(clientId);
|
||||||
if (methods === null) return c.json({ error: "Payment service unavailable" }, 503);
|
if (methods === null) return c.json({ error: "Payment service unavailable" }, 503);
|
||||||
@@ -524,9 +429,7 @@ portalRouter.get("/payment-methods", async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
portalRouter.post("/payment-methods", async (c) => {
|
portalRouter.post("/payment-methods", async (c) => {
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
const clientId = c.get("portalClientId");
|
||||||
const clientId = await getClientIdFromSession(sessionId);
|
|
||||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
|
|
||||||
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
||||||
const customerId = await getOrCreateStripeCustomer(clientId);
|
const customerId = await getOrCreateStripeCustomer(clientId);
|
||||||
@@ -539,9 +442,7 @@ portalRouter.post("/payment-methods", async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
portalRouter.delete("/payment-methods/:id", async (c) => {
|
portalRouter.delete("/payment-methods/:id", async (c) => {
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
const clientId = c.get("portalClientId");
|
||||||
const clientId = await getClientIdFromSession(sessionId);
|
|
||||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
|
|
||||||
const paymentMethodId = c.req.param("id");
|
const paymentMethodId = c.req.param("id");
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { SettingsPage } from "./pages/Settings.js";
|
|||||||
import { BookingConfirmedPage } from "./pages/BookingConfirmed.js";
|
import { BookingConfirmedPage } from "./pages/BookingConfirmed.js";
|
||||||
import { BookingCancelledPage } from "./pages/BookingCancelled.js";
|
import { BookingCancelledPage } from "./pages/BookingCancelled.js";
|
||||||
import { BookingErrorPage } from "./pages/BookingError.js";
|
import { BookingErrorPage } from "./pages/BookingError.js";
|
||||||
import { SetupWizard } from "./pages/SetupWizard.jsx";
|
import { SetupWizard } from "./pages/SetupWizard.tsx";
|
||||||
import { CustomerPortal } from "./portal/CustomerPortal.js";
|
import { CustomerPortal } from "./portal/CustomerPortal.js";
|
||||||
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
||||||
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export function GlobalSearch() {
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [results, setResults] = useState<SearchResults | null>(null);
|
const [results, setResults] = useState<SearchResults | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -45,15 +46,18 @@ export function GlobalSearch() {
|
|||||||
|
|
||||||
debounceRef.current = setTimeout(async () => {
|
debounceRef.current = setTimeout(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/search?q=${encodeURIComponent(trimmed)}`);
|
const res = await fetch(`/api/search?q=${encodeURIComponent(trimmed)}`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data: SearchResults = await res.json();
|
const data: SearchResults = await res.json();
|
||||||
setResults(data);
|
setResults(data);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
|
} else {
|
||||||
|
setError("Search failed. Please try again.");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {
|
||||||
console.warn("GlobalSearch: fetch error", err);
|
setError("Search failed. Please try again.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -160,7 +164,13 @@ export function GlobalSearch() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !hasResults && (
|
{!loading && error && (
|
||||||
|
<div style={{ padding: "12px 16px", fontSize: 13, color: "#dc2626" }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && !hasResults && (
|
||||||
<div style={{ padding: "12px 16px", fontSize: 13, color: "#6b7280" }}>
|
<div style={{ padding: "12px 16px", fontSize: 13, color: "#6b7280" }}>
|
||||||
No results found
|
No results found
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -71,6 +71,12 @@ export function PetPhotoUpload({ petId, onUploaded }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleFile(file: File) {
|
async function handleFile(file: File) {
|
||||||
|
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
setState({ status: "error", message: "File exceeds 50MB limit. Please choose a smaller image." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!ACCEPTED_TYPES.includes(file.type)) {
|
if (!ACCEPTED_TYPES.includes(file.type)) {
|
||||||
setState({ status: "error", message: "Please select a JPEG, PNG, WebP, or GIF image." });
|
setState({ status: "error", message: "Please select a JPEG, PNG, WebP, or GIF image." });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
import type { Appointment, Client, Pet, Service, Staff } from "@groombook/types";
|
import type { Appointment, Client, Pet, Service, Staff } from "@groombook/types";
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
@@ -273,7 +273,15 @@ export function AppointmentsPage() {
|
|||||||
cascade !== "this_only"
|
cascade !== "this_only"
|
||||||
? `/api/appointments/${id}?cascade=${cascade}`
|
? `/api/appointments/${id}?cascade=${cascade}`
|
||||||
: `/api/appointments/${id}`;
|
: `/api/appointments/${id}`;
|
||||||
await fetch(url, { method: "DELETE" });
|
try {
|
||||||
|
const res = await fetch(url, { method: "DELETE" });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (await res.json()) as { error?: string };
|
||||||
|
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
alert(e instanceof Error ? e.message : "Failed to delete appointment");
|
||||||
|
}
|
||||||
setSelectedAppt(null);
|
setSelectedAppt(null);
|
||||||
await loadAppointments();
|
await loadAppointments();
|
||||||
}
|
}
|
||||||
@@ -819,8 +827,49 @@ function AppointmentDetail({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const previouslyFocused = document.activeElement as HTMLElement;
|
||||||
|
const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||||
|
const focusableElements = modalRef.current?.querySelectorAll<HTMLElement>(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<HTMLElement>(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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
@@ -833,6 +882,7 @@ function Modal({ children, onClose }: { children: React.ReactNode; onClose: () =
|
|||||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
ref={modalRef}
|
||||||
style={{
|
style={{
|
||||||
background: "#fff",
|
background: "#fff",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
|
|||||||
@@ -211,6 +211,15 @@ function InvoiceDetailModal({
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const tipCents = Math.round(parseFloat(tipStr) * 100) || 0;
|
const tipCents = Math.round(parseFloat(tipStr) * 100) || 0;
|
||||||
|
// Real-time validation: prevent submit if tip splits don't sum to 100%
|
||||||
|
if (showSplits && tipCents > 0 && tipSplits.length > 0) {
|
||||||
|
const totalPct = tipSplits.reduce((s, r) => s + r.pct, 0);
|
||||||
|
if (Math.abs(totalPct - 100) >= 0.01) {
|
||||||
|
setError("Tip split percentages must sum to 100%");
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/invoices/${invoice.id}`, {
|
const res = await fetch(`/api/invoices/${invoice.id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
|
|||||||
@@ -199,11 +199,11 @@ export function ReportsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [summData, revData, apptData, svcData, clientData] = await Promise.all([
|
const [summData, revData, apptData, svcData, clientData] = await Promise.all([
|
||||||
summRes.json() as Promise<Summary>,
|
summRes.ok ? summRes.json() as Promise<Summary> : summRes.text().then(() => { throw new Error("summary response not ok"); }),
|
||||||
revRes.json() as Promise<{ byPeriod: RevenuePeriod[]; byGroomer: RevenueByGroomer[] }>,
|
revRes.ok ? revRes.json() as Promise<{ byPeriod: RevenuePeriod[]; byGroomer: RevenueByGroomer[] }> : revRes.text().then(() => { throw new Error("revenue response not ok"); }),
|
||||||
apptRes.json() as Promise<{ byPeriod: ApptPeriod[] }>,
|
apptRes.ok ? apptRes.json() as Promise<{ byPeriod: ApptPeriod[] }> : apptRes.text().then(() => { throw new Error("appointments response not ok"); }),
|
||||||
svcRes.json() as Promise<{ rows: ServiceRow[] }>,
|
svcRes.ok ? svcRes.json() as Promise<{ rows: ServiceRow[] }> : svcRes.text().then(() => { throw new Error("services response not ok"); }),
|
||||||
clientRes.json() as Promise<ClientReport>,
|
clientRes.ok ? clientRes.json() as Promise<ClientReport> : clientRes.text().then(() => { throw new Error("clients response not ok"); }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setSummary(summData);
|
setSummary(summData);
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ interface AuthProviderForm {
|
|||||||
|
|
||||||
const REDACTED = "••••••••";
|
const REDACTED = "••••••••";
|
||||||
|
|
||||||
|
const ALLOWED_LOGO_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
||||||
|
|
||||||
interface CurrentUser {
|
interface CurrentUser {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -149,9 +151,9 @@ export function SettingsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const validTypes = ["image/png", "image/svg+xml", "image/jpeg", "image/webp"];
|
const validTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||||
if (!validTypes.includes(file.type)) {
|
if (!validTypes.includes(file.type)) {
|
||||||
setMessage({ type: "error", text: "Logo must be PNG, SVG, JPEG, or WebP." });
|
setMessage({ type: "error", text: "Logo must be PNG, JPEG, GIF, or WebP." });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,7 +328,7 @@ issuerUrl: authForm.issuerUrl,
|
|||||||
|
|
||||||
if (!loaded) return <p>Loading settings...</p>;
|
if (!loaded) return <p>Loading settings...</p>;
|
||||||
|
|
||||||
const logoSrc = form.logoUrl ?? (form.logoBase64 && form.logoMimeType ? `data:${form.logoMimeType};base64,${form.logoBase64}` : null);
|
const logoSrc = form.logoUrl ?? (form.logoBase64 && form.logoMimeType && ALLOWED_LOGO_TYPES.has(form.logoMimeType) ? `data:${form.logoMimeType};base64,${form.logoBase64}` : null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 600 }}>
|
<div style={{ maxWidth: 600 }}>
|
||||||
@@ -393,7 +395,7 @@ issuerUrl: authForm.issuerUrl,
|
|||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/png,image/svg+xml,image/jpeg,image/webp"
|
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||||
onChange={handleLogoChange}
|
onChange={handleLogoChange}
|
||||||
style={{ display: "none" }}
|
style={{ display: "none" }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
export { SetupWizard } from "./SetupWizard.jsx";
|
export { SetupWizard } from "./SetupWizard.tsx";
|
||||||
|
|||||||
@@ -2,16 +2,39 @@ import { useState, useEffect } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useBranding } from "../BrandingContext.js";
|
import { useBranding } from "../BrandingContext.js";
|
||||||
|
|
||||||
export function SetupWizard({ onSetupComplete }) {
|
interface SetupStatus {
|
||||||
|
showAuthProviderStep?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TestResult {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthFormState {
|
||||||
|
providerId: string;
|
||||||
|
displayName: string;
|
||||||
|
issuerUrl: string;
|
||||||
|
internalBaseUrl: string;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
scopes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Step {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { refresh: refreshBranding } = useBranding();
|
const { refresh: refreshBranding } = useBranding();
|
||||||
|
|
||||||
// Fetch setup status to determine if auth provider step is needed
|
const [setupStatus, setSetupStatus] = useState<SetupStatus | null>(null);
|
||||||
const [setupStatus, setSetupStatus] = useState(null); // null = loading
|
|
||||||
const [loadingStatus, setLoadingStatus] = useState(true);
|
const [loadingStatus, setLoadingStatus] = useState(true);
|
||||||
|
|
||||||
// Auth provider form state
|
const [authForm, setAuthForm] = useState<AuthFormState>({
|
||||||
const [authForm, setAuthForm] = useState({
|
|
||||||
providerId: "authentik",
|
providerId: "authentik",
|
||||||
displayName: "",
|
displayName: "",
|
||||||
issuerUrl: "",
|
issuerUrl: "",
|
||||||
@@ -21,16 +44,16 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
scopes: "openid profile email",
|
scopes: "openid profile email",
|
||||||
});
|
});
|
||||||
const [testingConnection, setTestingConnection] = useState(false);
|
const [testingConnection, setTestingConnection] = useState(false);
|
||||||
const [testResult, setTestResult] = useState(null); // {ok: boolean, error?: string}
|
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||||
|
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
const [businessName, setBusinessName] = useState("");
|
const [businessName, setBusinessName] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/setup/status")
|
fetch("/api/setup/status")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json() as Promise<SetupStatus>)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setSetupStatus(data);
|
setSetupStatus(data);
|
||||||
setLoadingStatus(false);
|
setLoadingStatus(false);
|
||||||
@@ -40,8 +63,7 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Build steps dynamically based on setup status
|
const STEPS: Step[] = setupStatus?.showAuthProviderStep
|
||||||
const STEPS = setupStatus?.showAuthProviderStep
|
|
||||||
? [
|
? [
|
||||||
{ id: "welcome", title: "Welcome", description: "Welcome to GroomBook! Let's get your business set up." },
|
{ id: "welcome", title: "Welcome", description: "Welcome to GroomBook! Let's get your business set up." },
|
||||||
{ id: "auth", title: "Auth Provider", description: "Configure your authentication provider to secure your GroomBook instance." },
|
{ id: "auth", title: "Auth Provider", description: "Configure your authentication provider to secure your GroomBook instance." },
|
||||||
@@ -63,9 +85,8 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
const isFirst = step === 0;
|
const isFirst = step === 0;
|
||||||
const canGoBack = step > 0 && step < STEPS.length - 1;
|
const canGoBack = step > 0 && step < STEPS.length - 1;
|
||||||
|
|
||||||
// Determine if we can proceed - depends on which step we're on
|
|
||||||
const canGoNext = (() => {
|
const canGoNext = (() => {
|
||||||
if (step === STEPS.length - 1) return true; // done step
|
if (step === STEPS.length - 1) return true;
|
||||||
if (current?.id === "business") return businessName.trim().length > 0;
|
if (current?.id === "business") return businessName.trim().length > 0;
|
||||||
if (current?.id === "auth") {
|
if (current?.id === "auth") {
|
||||||
return (
|
return (
|
||||||
@@ -94,9 +115,9 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
scopes: authForm.scopes,
|
scopes: authForm.scopes,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = (await res.json()) as TestResult;
|
||||||
setTestResult(data);
|
setTestResult(data);
|
||||||
} catch (e) {
|
} catch {
|
||||||
setTestResult({ ok: false, error: "Network error. Please try again." });
|
setTestResult({ ok: false, error: "Network error. Please try again." });
|
||||||
} finally {
|
} finally {
|
||||||
setTestingConnection(false);
|
setTestingConnection(false);
|
||||||
@@ -105,12 +126,10 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
|
|
||||||
const handleNext = async () => {
|
const handleNext = async () => {
|
||||||
if (step === STEPS.length - 1) {
|
if (step === STEPS.length - 1) {
|
||||||
// Done - redirect to admin
|
|
||||||
navigate("/admin");
|
navigate("/admin");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit auth provider config
|
|
||||||
if (current?.id === "auth") {
|
if (current?.id === "auth") {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -129,12 +148,12 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json();
|
const data = (await res.json()) as { error?: string };
|
||||||
setError(data.error || "Failed to save auth provider configuration. Please try again.");
|
setError(data.error || "Failed to save auth provider configuration. Please try again.");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
setError("Network error. Please try again.");
|
setError("Network error. Please try again.");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
@@ -142,7 +161,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit business name and complete setup
|
|
||||||
if (current?.id === "business" && businessName.trim()) {
|
if (current?.id === "business" && businessName.trim()) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -153,16 +171,14 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
body: JSON.stringify({ businessName: businessName.trim() }),
|
body: JSON.stringify({ businessName: businessName.trim() }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json();
|
const data = (await res.json()) as { error?: string };
|
||||||
setError(data.error || "Setup failed. Please try again.");
|
setError(data.error || "Setup failed. Please try again.");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Refresh branding so the nav bar shows the new business name
|
|
||||||
refreshBranding();
|
refreshBranding();
|
||||||
// Clear needsSetup state in App so the redirect to /admin sticks
|
|
||||||
if (onSetupComplete) onSetupComplete();
|
if (onSetupComplete) onSetupComplete();
|
||||||
} catch (e) {
|
} catch {
|
||||||
setError("Network error. Please try again.");
|
setError("Network error. Please try again.");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
@@ -192,7 +208,7 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputStyle = {
|
const inputStyle: React.CSSProperties = {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
padding: "0.6rem 0.85rem",
|
padding: "0.6rem 0.85rem",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
@@ -220,7 +236,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
maxWidth: 480,
|
maxWidth: 480,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
}}>
|
}}>
|
||||||
{/* Progress dots */}
|
|
||||||
<div style={{ display: "flex", gap: 6, marginBottom: "2rem", justifyContent: "center" }}>
|
<div style={{ display: "flex", gap: 6, marginBottom: "2rem", justifyContent: "center" }}>
|
||||||
{STEPS.map((_, i) => (
|
{STEPS.map((_, i) => (
|
||||||
<div
|
<div
|
||||||
@@ -237,38 +252,32 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Step indicator */}
|
|
||||||
<p style={{ margin: "0 0 0.5rem", fontSize: 13, color: "#6b7280", fontWeight: 500 }}>
|
<p style={{ margin: "0 0 0.5rem", fontSize: 13, color: "#6b7280", fontWeight: 500 }}>
|
||||||
Step {step + 1} of {STEPS.length}
|
Step {step + 1} of {STEPS.length}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<h2 style={{ margin: "0 0 0.75rem", fontSize: 22, fontWeight: 700, color: "#1a202c" }}>
|
<h2 style={{ margin: "0 0 0.75rem", fontSize: 22, fontWeight: 700, color: "#1a202c" }}>
|
||||||
{current?.title}
|
{current?.title}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<p style={{ margin: "0 0 1.5rem", fontSize: 15, color: "#4b5563", lineHeight: 1.6 }}>
|
<p style={{ margin: "0 0 1.5rem", fontSize: 15, color: "#4b5563", lineHeight: 1.6 }}>
|
||||||
{current?.description}
|
{current?.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Step: Business name input */}
|
|
||||||
{current?.id === "business" && (
|
{current?.id === "business" && (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="e.g. Happy Paws Grooming"
|
placeholder="e.g. Happy Paws Grooming"
|
||||||
value={businessName}
|
value={businessName}
|
||||||
onChange={(e) => setBusinessName(e.target.value)}
|
onChange={(e) => setBusinessName(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && canGoNext && handleNext()}
|
onKeyDown={(e) => e.key === "Enter" && canGoNext && void handleNext()}
|
||||||
autoFocus
|
autoFocus
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step: Auth provider config form */}
|
|
||||||
{current?.id === "auth" && (
|
{current?.id === "auth" && (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.85rem" }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.85rem" }}>
|
||||||
{/* Provider ID */}
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||||
Provider ID
|
Provider ID
|
||||||
@@ -282,7 +291,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Display Name */}
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||||
Display Name
|
Display Name
|
||||||
@@ -296,7 +304,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Issuer URL */}
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||||
Issuer URL
|
Issuer URL
|
||||||
@@ -310,7 +317,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Internal Base URL (optional) */}
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||||
Internal Base URL <span style={{ fontWeight: 400, color: "#6b7280" }}>(optional, for hairpin NAT)</span>
|
Internal Base URL <span style={{ fontWeight: 400, color: "#6b7280" }}>(optional, for hairpin NAT)</span>
|
||||||
@@ -324,7 +330,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Client ID */}
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||||
Client ID
|
Client ID
|
||||||
@@ -338,7 +343,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Client Secret */}
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||||
Client Secret
|
Client Secret
|
||||||
@@ -352,7 +356,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scopes */}
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||||
Scopes
|
Scopes
|
||||||
@@ -366,10 +369,9 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Test Connection button */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleTestConnection}
|
onClick={() => { void handleTestConnection(); }}
|
||||||
disabled={testingConnection || !authForm.issuerUrl || !authForm.clientId}
|
disabled={testingConnection || !authForm.issuerUrl || !authForm.clientId}
|
||||||
style={{
|
style={{
|
||||||
padding: "0.45rem 0.85rem",
|
padding: "0.45rem 0.85rem",
|
||||||
@@ -387,7 +389,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
{testingConnection ? "Testing..." : "Test Connection"}
|
{testingConnection ? "Testing..." : "Test Connection"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Test result */}
|
|
||||||
{testResult && (
|
{testResult && (
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: "0.5rem 0.75rem",
|
padding: "0.5rem 0.75rem",
|
||||||
@@ -405,7 +406,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step: Super user info */}
|
|
||||||
{current?.id === "superuser" && (
|
{current?.id === "superuser" && (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: "#f0fdf4",
|
background: "#f0fdf4",
|
||||||
@@ -420,7 +420,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step: Second admin info */}
|
|
||||||
{current?.id === "admin" && (
|
{current?.id === "admin" && (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: "#fffbeb",
|
background: "#fffbeb",
|
||||||
@@ -434,7 +433,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error message */}
|
|
||||||
{error && (
|
{error && (
|
||||||
<p style={{
|
<p style={{
|
||||||
margin: "0.5rem 0 0",
|
margin: "0.5rem 0 0",
|
||||||
@@ -449,7 +447,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Navigation buttons */}
|
|
||||||
<div style={{
|
<div style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
gap: "0.75rem",
|
gap: "0.75rem",
|
||||||
@@ -476,7 +473,7 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleNext}
|
onClick={() => { void handleNext(); }}
|
||||||
disabled={(!canGoNext && !isLast) || loading}
|
disabled={(!canGoNext && !isLast) || loading}
|
||||||
style={{
|
style={{
|
||||||
padding: "0.55rem 1.25rem",
|
padding: "0.55rem 1.25rem",
|
||||||
@@ -16,6 +16,7 @@ import { AuditLogViewer } from "./AuditLogViewer.js";
|
|||||||
import { useBranding } from "../BrandingContext.js";
|
import { useBranding } from "../BrandingContext.js";
|
||||||
import { getDevUser } from "../pages/DevLoginSelector.js";
|
import { getDevUser } from "../pages/DevLoginSelector.js";
|
||||||
import type { ImpersonationSession } from "@groombook/types";
|
import type { ImpersonationSession } from "@groombook/types";
|
||||||
|
import type { Appointment as PortalAppointment } from "./sections/Appointments.js";
|
||||||
|
|
||||||
type Section = "dashboard" | "appointments" | "pets" | "reports" | "billing" | "messages" | "settings";
|
type Section = "dashboard" | "appointments" | "pets" | "reports" | "billing" | "messages" | "settings";
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ export function CustomerPortal() {
|
|||||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||||
const [showAuditLog, setShowAuditLog] = useState(false);
|
const [showAuditLog, setShowAuditLog] = useState(false);
|
||||||
const [showReschedule, setShowReschedule] = useState(false);
|
const [showReschedule, setShowReschedule] = useState(false);
|
||||||
const [rescheduleAppointment, setRescheduleAppointment] = useState<Record<string, unknown> | null>(null);
|
const [rescheduleAppointment, setRescheduleAppointment] = useState<PortalAppointment | null>(null);
|
||||||
const [session, setSession] = useState<ImpersonationSession | null>(null);
|
const [session, setSession] = useState<ImpersonationSession | null>(null);
|
||||||
const [sessionExtended, setSessionExtended] = useState(false);
|
const [sessionExtended, setSessionExtended] = useState(false);
|
||||||
const [clientName, setClientName] = useState<string>("");
|
const [clientName, setClientName] = useState<string>("");
|
||||||
@@ -149,7 +150,7 @@ export function CustomerPortal() {
|
|||||||
const handleReschedule = useCallback((appointmentId: string) => {
|
const handleReschedule = useCallback((appointmentId: string) => {
|
||||||
// Look up the full appointment from Dashboard's displayed data
|
// Look up the full appointment from Dashboard's displayed data
|
||||||
// The appointment was already fetched by Dashboard, so we use the ID to find it
|
// The appointment was already fetched by Dashboard, so we use the ID to find it
|
||||||
setRescheduleAppointment({ id: appointmentId } as Record<string, unknown>);
|
setRescheduleAppointment({ id: appointmentId } as PortalAppointment);
|
||||||
setShowReschedule(true);
|
setShowReschedule(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -227,7 +228,7 @@ export function CustomerPortal() {
|
|||||||
|
|
||||||
{showReschedule && rescheduleAppointment && (
|
{showReschedule && rescheduleAppointment && (
|
||||||
<RescheduleFlow
|
<RescheduleFlow
|
||||||
appointment={rescheduleAppointment as any}
|
appointment={rescheduleAppointment}
|
||||||
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
|
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
|
||||||
sessionId={session?.id ?? null}
|
sessionId={session?.id ?? null}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Loader2 } from 'lucide-react';
|
import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
interface Appointment {
|
export interface Appointment {
|
||||||
id: string;
|
id: string;
|
||||||
petId: string;
|
petId: string;
|
||||||
serviceId: string;
|
serviceId: string;
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export interface Pet {
|
|||||||
shampooPreference: string | null;
|
shampooPreference: string | null;
|
||||||
specialCareNotes: string | null;
|
specialCareNotes: string | null;
|
||||||
customFields: Record<string, string>;
|
customFields: Record<string, string>;
|
||||||
|
photoKey?: string;
|
||||||
|
photoUploadedAt?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user