Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7e98c0582 | |||
| 4f6a1e8149 | |||
| be3cfa9a54 | |||
| 06e7ddaa61 | |||
| bc1f11a901 |
@@ -22,6 +22,7 @@
|
||||
"hono": "^4.6.17",
|
||||
"node-cron": "^3.0.3",
|
||||
"nodemailer": "^6.9.16",
|
||||
"stripe": "^22.0.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -28,6 +28,7 @@ import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSup
|
||||
import { devRouter } from "./routes/dev.js";
|
||||
import { adminSeedRouter } from "./routes/admin/seed.js";
|
||||
import { startReminderScheduler } from "./services/reminders.js";
|
||||
import { webhooksRouter } from "./routes/stripe-webhooks.js";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -50,6 +51,9 @@ app.route("/api/book", bookRouter);
|
||||
// Public portal routes — client-facing, authenticated via impersonation session header
|
||||
app.route("/api/portal", portalRouter);
|
||||
|
||||
// Public Stripe webhook endpoint — signature-verified, no auth required
|
||||
app.route("/api/webhooks/stripe", webhooksRouter);
|
||||
|
||||
// Dev/demo routes — config is always public, users endpoint is guarded internally
|
||||
app.route("/api/dev", devRouter);
|
||||
|
||||
|
||||
+64
-11
@@ -3,6 +3,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { genericOAuth } from "better-auth/plugins";
|
||||
import { getDb, authProviderConfig, eq } from "@groombook/db";
|
||||
import { decryptSecret } from "@groombook/db";
|
||||
import { sendEmail } from "../services/email.js";
|
||||
|
||||
const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
|
||||
const BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
|
||||
@@ -94,7 +95,7 @@ export async function initAuth(): Promise<void> {
|
||||
enabled: true,
|
||||
max: 10,
|
||||
window: 60,
|
||||
storage: "database",
|
||||
storage: "memory",
|
||||
},
|
||||
plugins: [
|
||||
genericOAuth({
|
||||
@@ -176,6 +177,52 @@ export async function initAuth(): Promise<void> {
|
||||
const hasGoogle = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
||||
const hasGitHub = !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
|
||||
|
||||
// Fetch OIDC discovery document to derive canonical provider URLs.
|
||||
// Replace the host of token/userinfo endpoints with internalBaseUrl when set,
|
||||
// while keeping authorizationUrl public for browser redirects.
|
||||
const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`;
|
||||
let oidcConfig: Record<string, string> = {};
|
||||
try {
|
||||
const discoveryRes = await fetch(discoveryUrlStr);
|
||||
if (discoveryRes.ok) {
|
||||
const discovery = await discoveryRes.json() as {
|
||||
authorization_endpoint?: string;
|
||||
token_endpoint?: string;
|
||||
userinfo_endpoint?: string;
|
||||
};
|
||||
const replaceHost = (url: string, newHost: string) => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const newParsed = new URL(newHost);
|
||||
return `${newParsed.origin}${parsed.pathname}${parsed.search}`;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
const authzUrl = discovery.authorization_endpoint;
|
||||
const tokenUrl = discovery.token_endpoint;
|
||||
const userInfoUrl = discovery.userinfo_endpoint;
|
||||
if (authzUrl && tokenUrl && userInfoUrl) {
|
||||
oidcConfig = {
|
||||
authorizationUrl: authzUrl,
|
||||
tokenUrl: providerConfig.internalBaseUrl
|
||||
? replaceHost(tokenUrl, providerConfig.internalBaseUrl)
|
||||
: tokenUrl,
|
||||
userInfoUrl: providerConfig.internalBaseUrl
|
||||
? replaceHost(userInfoUrl, providerConfig.internalBaseUrl)
|
||||
: userInfoUrl,
|
||||
};
|
||||
console.log("[auth] OIDC discovery successful, provider:", providerConfig.providerId);
|
||||
} else {
|
||||
console.warn("[auth] OIDC discovery missing required endpoints, using discoveryUrl only");
|
||||
}
|
||||
} else {
|
||||
console.warn(`[auth] OIDC discovery failed (${discoveryRes.status}), using discoveryUrl only`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[auth] OIDC discovery fetch failed: ${err}, using discoveryUrl only`);
|
||||
}
|
||||
|
||||
// Build Better-Auth instance using resolved config
|
||||
authInstance = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
@@ -187,11 +234,24 @@ export async function initAuth(): Promise<void> {
|
||||
enabled: true,
|
||||
max: 10,
|
||||
window: 60,
|
||||
storage: "database",
|
||||
storage: "memory",
|
||||
},
|
||||
account: {
|
||||
storeStateStrategy: "cookie" as const,
|
||||
},
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
emailVerification: {
|
||||
sendVerificationEmail: async ({ user, url }: { user: { email: string }; url: string }) => {
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Verify your GroomBook email",
|
||||
text: `Click the link to verify your email: ${url}`,
|
||||
html: `<p>Click the link to verify your email:</p><a href="${url}">${url}</a>`,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
genericOAuth({
|
||||
config: [
|
||||
@@ -199,15 +259,8 @@ export async function initAuth(): Promise<void> {
|
||||
providerId: providerConfig.providerId,
|
||||
clientId: providerConfig.clientId,
|
||||
clientSecret: providerConfig.clientSecret,
|
||||
...(providerConfig.internalBaseUrl
|
||||
? {
|
||||
authorizationUrl: `${new URL(providerConfig.issuerUrl).origin}/application/o/authorize/`,
|
||||
tokenUrl: `${providerConfig.internalBaseUrl}/application/o/token/`,
|
||||
userInfoUrl: `${providerConfig.internalBaseUrl}/application/o/userinfo/`,
|
||||
}
|
||||
: {
|
||||
discoveryUrl: `${providerConfig.issuerUrl}/.well-known/openid-configuration`,
|
||||
}),
|
||||
discoveryUrl: discoveryUrlStr,
|
||||
...(Object.keys(oidcConfig).length > 0 ? oidcConfig : {}),
|
||||
scopes: providerConfig.scopes.split(" ").filter(Boolean),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Hono } from "hono";
|
||||
import Stripe from "stripe";
|
||||
import { eq, getDb, invoices } from "@groombook/db";
|
||||
|
||||
export const webhooksRouter = new Hono();
|
||||
|
||||
webhooksRouter.post("/stripe", async (c) => {
|
||||
const secret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
if (!secret) {
|
||||
return c.json({ error: "Webhook secret not configured" }, 503);
|
||||
}
|
||||
|
||||
const signature = c.req.header("stripe-signature");
|
||||
if (!signature) {
|
||||
return c.json({ error: "Missing signature" }, 401);
|
||||
}
|
||||
|
||||
let rawBody: string;
|
||||
try {
|
||||
rawBody = await c.req.text();
|
||||
} catch {
|
||||
return c.json({ error: "Could not read body" }, 400);
|
||||
}
|
||||
|
||||
const stripe = new Stripe(secret, { apiVersion: "2026-03-25.dahlia" });
|
||||
|
||||
let event: Stripe.Event;
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(rawBody, signature, secret);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Invalid signature";
|
||||
return c.json({ error: message }, 401);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
if (event.type === "payment_intent.succeeded") {
|
||||
const pi = event.data.object as Stripe.PaymentIntent;
|
||||
if (pi.metadata?.groombook_invoice_ids) {
|
||||
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
||||
for (const invoiceId of invoiceIds) {
|
||||
if (!invoiceId) continue;
|
||||
const [inv] = await db
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq(invoices.id, invoiceId))
|
||||
.limit(1);
|
||||
if (!inv) continue;
|
||||
if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue;
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({
|
||||
status: "paid",
|
||||
paymentMethod: "card",
|
||||
paidAt: new Date(),
|
||||
stripePaymentIntentId: pi.id,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invoices.id, invoiceId));
|
||||
}
|
||||
}
|
||||
} else if (event.type === "payment_intent.payment_failed") {
|
||||
const pi = event.data.object as Stripe.PaymentIntent;
|
||||
if (pi.metadata?.groombook_invoice_ids) {
|
||||
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
||||
for (const invoiceId of invoiceIds) {
|
||||
if (!invoiceId) continue;
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({
|
||||
paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invoices.id, invoiceId));
|
||||
}
|
||||
}
|
||||
} else if (event.type === "charge.refunded") {
|
||||
const charge = event.data.object as Stripe.Charge;
|
||||
if (typeof charge.payment_intent === "string" && charge.payment_intent) {
|
||||
const [inv] = await db
|
||||
.select({ id: invoices.id })
|
||||
.from(invoices)
|
||||
.where(eq(invoices.stripePaymentIntentId, charge.payment_intent))
|
||||
.limit(1);
|
||||
if (inv) {
|
||||
const refundId =
|
||||
typeof charge.refunded === "boolean" && charge.refunded
|
||||
? `ch_${charge.id}_refund`
|
||||
: null;
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({
|
||||
status: "void",
|
||||
stripeRefundId: refundId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invoices.id, inv.id));
|
||||
}
|
||||
}
|
||||
} else if (event.type === "charge.dispute.created") {
|
||||
const dispute = event.data.object as Stripe.Dispute;
|
||||
console.error(
|
||||
`[Stripe Webhook] Dispute created for payment intent: ${dispute.payment_intent}`
|
||||
);
|
||||
}
|
||||
|
||||
return c.json({ received: true });
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
services,
|
||||
staff,
|
||||
reminderLogs,
|
||||
session,
|
||||
} from "@groombook/db";
|
||||
import {
|
||||
buildReminderEmail,
|
||||
@@ -155,6 +156,19 @@ export function startReminderScheduler(): void {
|
||||
runReminderCheck().catch((err) => {
|
||||
console.error("[reminders] Error during reminder check:", err);
|
||||
});
|
||||
runSessionCleanup().catch((err) => {
|
||||
console.error("[reminders] Error during session cleanup:", err);
|
||||
});
|
||||
});
|
||||
console.log("[reminders] Reminder scheduler started");
|
||||
}
|
||||
|
||||
// Deletes expired sessions from the database.
|
||||
// Runs every minute alongside reminder checks.
|
||||
export async function runSessionCleanup(): Promise<void> {
|
||||
const db = getDb();
|
||||
const now = new Date();
|
||||
await db
|
||||
.delete(session)
|
||||
.where(lt(session.expiresAt, now));
|
||||
}
|
||||
|
||||
@@ -4,4 +4,4 @@ export const authClient = createAuthClient({
|
||||
baseURL: import.meta.env.VITE_API_URL ?? "",
|
||||
});
|
||||
|
||||
export const { signIn, signOut, useSession } = authClient;
|
||||
export const { signIn, signOut, useSession, changePassword } = authClient;
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { User, Lock, PawPrint, FileCheck, Plus, Archive } from "lucide-react";
|
||||
import { PetForm } from "./PetForm.js";
|
||||
import { authClient } from "../../lib/auth-client.js";
|
||||
|
||||
interface Props {
|
||||
sessionId: string | null;
|
||||
@@ -148,9 +149,11 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const passwordsMatch = newPassword === confirmPassword;
|
||||
const canSubmit = currentPassword.length > 0 && newPassword.length > 0 && passwordsMatch;
|
||||
const canSubmit = newPassword.length > 0 && passwordsMatch && !loading;
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
@@ -160,17 +163,34 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
async function handleSubmit() {
|
||||
if (!canSubmit) return;
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
// TODO: Wire up to actual password-change API endpoint once backend support exists
|
||||
setError(null);
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setLoading(true);
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await (authClient as any).changePassword({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
});
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? "Failed to change password.");
|
||||
} else {
|
||||
setSuccess(true);
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setTimeout(() => setSuccess(false), 4000);
|
||||
}
|
||||
} catch {
|
||||
setError("An unexpected error occurred.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -205,12 +225,13 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
{success && <p className="text-sm text-green-600">Password updated successfully.</p>}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Update Password
|
||||
{loading ? "Updating..." : "Update Password"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE "invoices" ADD COLUMN "stripe_payment_intent_id" text;
|
||||
ALTER TABLE "invoices" ADD COLUMN "stripe_refund_id" text;
|
||||
ALTER TABLE "invoices" ADD COLUMN "payment_failure_reason" text;
|
||||
ALTER TABLE "invoices" ADD CONSTRAINT "idx_invoices_stripe_payment_intent_id" UNIQUE("stripe_payment_intent_id");
|
||||
@@ -251,6 +251,9 @@ export const invoices = pgTable(
|
||||
status: invoiceStatusEnum("status").notNull().default("draft"),
|
||||
paymentMethod: paymentMethodEnum("payment_method"),
|
||||
paidAt: timestamp("paid_at"),
|
||||
stripePaymentIntentId: text("stripe_payment_intent_id"),
|
||||
stripeRefundId: text("stripe_refund_id"),
|
||||
paymentFailureReason: text("payment_failure_reason"),
|
||||
notes: text("notes"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
@@ -259,6 +262,7 @@ export const invoices = pgTable(
|
||||
index("idx_invoices_client_id").on(t.clientId),
|
||||
index("idx_invoices_status").on(t.status),
|
||||
index("idx_invoices_created_at").on(t.createdAt),
|
||||
unique("idx_invoices_stripe_payment_intent_id").on(t.stripePaymentIntentId),
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
Generated
+16
@@ -40,6 +40,9 @@ importers:
|
||||
nodemailer:
|
||||
specifier: ^6.9.16
|
||||
version: 6.10.1
|
||||
stripe:
|
||||
specifier: ^22.0.0
|
||||
version: 22.0.1(@types/node@22.19.15)
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
@@ -4124,6 +4127,15 @@ packages:
|
||||
strip-literal@3.1.0:
|
||||
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
||||
|
||||
stripe@22.0.1:
|
||||
resolution: {integrity: sha512-Yw764pZ6s8Xu4CtUZdD5uWOkw6gc9xzO9OKylCuj1gMhMDLbyGbDtaPNNSFE4mB6njYSHESYIVbF1iIzUfAl2g==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
strnum@2.2.1:
|
||||
resolution: {integrity: sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==}
|
||||
|
||||
@@ -8774,6 +8786,10 @@ snapshots:
|
||||
dependencies:
|
||||
js-tokens: 9.0.1
|
||||
|
||||
stripe@22.0.1(@types/node@22.19.15):
|
||||
optionalDependencies:
|
||||
'@types/node': 22.19.15
|
||||
|
||||
strnum@2.2.1: {}
|
||||
|
||||
supports-color@7.2.0:
|
||||
|
||||
Reference in New Issue
Block a user