Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae873215c0 | |||
| 9d37053580 | |||
| fdaf4db0d5 | |||
| 4f6a1e8149 | |||
| be3cfa9a54 | |||
| 06e7ddaa61 | |||
| 15131b72f0 | |||
| bc1f11a901 | |||
| f4e34f2826 | |||
| 2396eaab4d | |||
| 97b71d5396 | |||
| bbe95df9ca | |||
| 1380d5a9d3 | |||
| 41dff6f0e2 | |||
| 8002a3db96 | |||
| 88e6845027 | |||
| 085c8b9cfa | |||
| 1d76c63137 |
@@ -11,6 +11,12 @@ AUTH_DISABLED=false
|
||||
OIDC_ISSUER=https://authentik.example.com
|
||||
OIDC_AUDIENCE=groombook
|
||||
|
||||
# ── Setup Wizard ─────────────────────────────────────────────────────────────
|
||||
# When SKIP_OOBE=true, the setup wizard is bypassed regardless of whether a
|
||||
# super user exists in the database. Useful in dev/test environments where the
|
||||
# database has data but the setup wizard would otherwise block access.
|
||||
SKIP_OOBE=false
|
||||
|
||||
# ── API ───────────────────────────────────────────────────────────────────────
|
||||
PORT=3000
|
||||
CORS_ORIGIN=http://localhost:8080
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"hono": "^4.6.17",
|
||||
"node-cron": "^3.0.3",
|
||||
"nodemailer": "^6.9.16",
|
||||
"stripe": "^22.0.0",
|
||||
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -418,6 +418,48 @@ describe("GET /setup/status — OOBE bootstrap logic", () => {
|
||||
expect(body.showAuthProviderStep).toBe(false); // DB config already exists
|
||||
expect(body.authConfigExists).toBe(true);
|
||||
});
|
||||
|
||||
it("SKIP_OOBE=true bypasses setup check regardless of DB state", async () => {
|
||||
dbStaffRows = []; // no super user
|
||||
dbAuthConfigRows = [];
|
||||
process.env.SKIP_OOBE = "true";
|
||||
|
||||
const app = makeApp();
|
||||
const { status, body } = await getStatus(app);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.needsSetup).toBe(false);
|
||||
expect(body.showAuthProviderStep).toBe(false);
|
||||
expect(body.authConfigExists).toBe(false);
|
||||
expect(body.authEnvVarsSet).toBe(false);
|
||||
expect(body.skipped).toBe(true);
|
||||
});
|
||||
|
||||
it("SKIP_OOBE=1 also bypasses setup check", async () => {
|
||||
dbStaffRows = [];
|
||||
dbAuthConfigRows = [];
|
||||
process.env.SKIP_OOBE = "1";
|
||||
|
||||
const app = makeApp();
|
||||
const { status, body } = await getStatus(app);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.needsSetup).toBe(false);
|
||||
expect(body.skipped).toBe(true);
|
||||
});
|
||||
|
||||
it("SKIP_OOBE=yes also bypasses setup check", async () => {
|
||||
dbStaffRows = [];
|
||||
dbAuthConfigRows = [];
|
||||
process.env.SKIP_OOBE = "yes";
|
||||
|
||||
const app = makeApp();
|
||||
const { status, body } = await getStatus(app);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.needsSetup).toBe(false);
|
||||
expect(body.skipped).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /setup/auth-provider — OOBE bootstrap", () => {
|
||||
|
||||
+11
-1
@@ -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);
|
||||
|
||||
@@ -105,7 +109,13 @@ api.use("*", resolveStaffMiddleware);
|
||||
// Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes
|
||||
// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths
|
||||
const authRouter = new Hono();
|
||||
authRouter.all("/*", (c) => getAuth().handler(c.req.raw));
|
||||
authRouter.all("/*", (c) => {
|
||||
try {
|
||||
return getAuth().handler(c.req.raw);
|
||||
} catch {
|
||||
return c.json({ error: "Authentication not configured" }, 503);
|
||||
}
|
||||
});
|
||||
api.route("/auth", authRouter);
|
||||
|
||||
// ── Role guards ────────────────────────────────────────────────────────────────
|
||||
|
||||
+76
-12
@@ -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";
|
||||
@@ -90,6 +91,12 @@ export async function initAuth(): Promise<void> {
|
||||
database: drizzleAdapter(getDb(), { provider: "pg" }),
|
||||
secret: BETTER_AUTH_SECRET ?? "placeholder-secret-do-not-use-in-prod",
|
||||
baseURL: BETTER_AUTH_URL,
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
max: 10,
|
||||
window: 60,
|
||||
storage: "memory",
|
||||
},
|
||||
plugins: [
|
||||
genericOAuth({
|
||||
config: [
|
||||
@@ -170,7 +177,51 @@ 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);
|
||||
|
||||
const callbackBase = `${BETTER_AUTH_URL}/api/auth/callback`;
|
||||
// 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({
|
||||
@@ -179,6 +230,28 @@ export async function initAuth(): Promise<void> {
|
||||
}),
|
||||
secret: BETTER_AUTH_SECRET,
|
||||
baseURL: BETTER_AUTH_URL,
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
max: 10,
|
||||
window: 60,
|
||||
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: [
|
||||
@@ -186,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),
|
||||
},
|
||||
],
|
||||
@@ -205,14 +271,12 @@ export async function initAuth(): Promise<void> {
|
||||
google: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
redirectURI: `${callbackBase}/google`,
|
||||
},
|
||||
} : {}),
|
||||
...(hasGitHub ? {
|
||||
github: {
|
||||
clientId: process.env.GITHUB_CLIENT_ID!,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
||||
redirectURI: `${callbackBase}/github`,
|
||||
},
|
||||
} : {}),
|
||||
},
|
||||
|
||||
@@ -23,7 +23,6 @@ if (process.env.AUTH_DISABLED === "true") {
|
||||
}
|
||||
|
||||
export const authMiddleware: MiddlewareHandler = async (c, next) => {
|
||||
// Better-Auth's own routes handle their own auth (OAuth callbacks, session mgmt)
|
||||
if (c.req.path.startsWith("/api/auth/")) {
|
||||
await next();
|
||||
return;
|
||||
@@ -37,7 +36,14 @@ export const authMiddleware: MiddlewareHandler = async (c, next) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await getAuth().api.getSession({
|
||||
let auth;
|
||||
try {
|
||||
auth = getAuth();
|
||||
} catch {
|
||||
return c.json({ error: "Authentication not configured" }, 503);
|
||||
}
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { and, eq, getDb, isNull, staff } from "@groombook/db";
|
||||
import { eq, getDb, staff } from "@groombook/db";
|
||||
|
||||
export type StaffRole = "groomer" | "receptionist" | "manager";
|
||||
export type StaffRow = typeof staff.$inferSelect;
|
||||
@@ -90,25 +90,6 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
||||
.from(staff)
|
||||
.where(eq(staff.oidcSub, jwt.sub));
|
||||
if (!fallbackRow) {
|
||||
// Auto-link: staff record exists with matching email but no userId — link it now
|
||||
if (jwt.email) {
|
||||
const [linkedStaff] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(and(eq(staff.email, jwt.email), isNull(staff.userId)));
|
||||
if (linkedStaff) {
|
||||
await db
|
||||
.update(staff)
|
||||
.set({ userId: jwt.sub })
|
||||
.where(eq(staff.id, linkedStaff.id));
|
||||
console.log(
|
||||
`[rbac] Auto-linked staff ${linkedStaff.id} to Better-Auth user ${jwt.sub} via email ${jwt.email}`
|
||||
);
|
||||
c.set("staff", linkedStaff);
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
}
|
||||
return c.json(
|
||||
{ error: "Forbidden: no staff record found for authenticated user" },
|
||||
403
|
||||
|
||||
@@ -13,8 +13,9 @@ import {
|
||||
clients,
|
||||
sql,
|
||||
} from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const invoicesRouter = new Hono();
|
||||
export const invoicesRouter = new Hono<AppEnv>();
|
||||
|
||||
const createInvoiceSchema = z.object({
|
||||
appointmentId: z.string().uuid().optional(),
|
||||
@@ -338,3 +339,41 @@ invoicesRouter.patch(
|
||||
return c.json({ ...updated, lineItems });
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Refund ───────────────────────────────────────────────────────────────────
|
||||
|
||||
import { processRefund } from "../services/payment.js";
|
||||
|
||||
const refundSchema = z.object({
|
||||
amountCents: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
|
||||
invoicesRouter.post(
|
||||
"/:id/refund",
|
||||
zValidator("json", refundSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const staff = c.get("staff");
|
||||
if (!staff) return c.json({ error: "Forbidden" }, 403);
|
||||
if (staff.role !== "manager" && !staff.isSuperUser) {
|
||||
return c.json({ error: "Manager role required" }, 403);
|
||||
}
|
||||
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id));
|
||||
if (!invoice) return c.json({ error: "Not found" }, 404);
|
||||
if (invoice.status !== "paid") {
|
||||
return c.json({ error: "Refund only allowed on paid invoices" }, 422);
|
||||
}
|
||||
if (!invoice.stripePaymentIntentId) {
|
||||
return c.json({ error: "No Stripe payment intent found for this invoice" }, 422);
|
||||
}
|
||||
|
||||
const result = await processRefund(id, body.amountCents);
|
||||
if (!result) return c.json({ error: "Refund failed" }, 500);
|
||||
|
||||
return c.json({ refundId: result.refundId });
|
||||
}
|
||||
);
|
||||
|
||||
@@ -448,6 +448,145 @@ portalRouter.delete("/waitlist/:id", async (c) => {
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── Payment routes ───────────────────────────────────────────────────────────
|
||||
|
||||
import {
|
||||
createPaymentIntent,
|
||||
listPaymentMethods,
|
||||
detachPaymentMethod,
|
||||
createSetupIntent,
|
||||
getOrCreateStripeCustomer,
|
||||
} from "../services/payment.js";
|
||||
|
||||
const payInvoiceSchema = z.object({
|
||||
invoiceId: z.string().uuid(),
|
||||
});
|
||||
|
||||
portalRouter.post(
|
||||
"/invoices/:id/pay",
|
||||
zValidator("json", payInvoiceSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const invoiceId = c.req.param("id");
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const [invoice] = await db
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq(invoices.id, invoiceId))
|
||||
.limit(1);
|
||||
|
||||
if (!invoice) return c.json({ error: "Not found" }, 404);
|
||||
if (invoice.clientId !== clientId) return c.json({ error: "Forbidden" }, 403);
|
||||
if (invoice.status === "draft" || invoice.status === "void") {
|
||||
return c.json({ error: "Cannot pay a draft or void invoice" }, 422);
|
||||
}
|
||||
if (invoice.status === "paid") {
|
||||
return c.json({ error: "Invoice is already paid" }, 422);
|
||||
}
|
||||
|
||||
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
||||
const result = await createPaymentIntent(invoiceId, clientId);
|
||||
if (!result) return c.json({ error: "Payment service unavailable" }, 503);
|
||||
|
||||
return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey });
|
||||
}
|
||||
);
|
||||
|
||||
const payMultipleSchema = z.object({
|
||||
invoiceIds: z.array(z.string().uuid()).min(1),
|
||||
});
|
||||
|
||||
portalRouter.post(
|
||||
"/invoices/pay-multiple",
|
||||
zValidator("json", payMultipleSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const invoiceRows = await db
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(inArray(invoices.id, body.invoiceIds));
|
||||
|
||||
if (invoiceRows.length !== body.invoiceIds.length) {
|
||||
return c.json({ error: "One or more invoices not found" }, 404);
|
||||
}
|
||||
|
||||
for (const inv of invoiceRows) {
|
||||
if (inv.clientId !== clientId) return c.json({ error: "Forbidden" }, 403);
|
||||
if (inv.status === "draft" || inv.status === "void") {
|
||||
return c.json({ error: `Invoice ${inv.id} cannot be paid (draft or void)` }, 422);
|
||||
}
|
||||
if (inv.status === "paid") {
|
||||
return c.json({ error: `Invoice ${inv.id} is already paid` }, 422);
|
||||
}
|
||||
}
|
||||
|
||||
const firstInvoice = invoiceRows[0];
|
||||
if (!firstInvoice) return c.json({ error: "No invoices found" }, 400);
|
||||
const allSameClient = invoiceRows.every(inv => inv.clientId === firstInvoice.clientId);
|
||||
if (!allSameClient) {
|
||||
return c.json({ error: "All invoices must belong to the same client" }, 422);
|
||||
}
|
||||
|
||||
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
||||
const result = await createPaymentIntent(body.invoiceIds, clientId);
|
||||
if (!result) return c.json({ error: "Payment service unavailable" }, 503);
|
||||
|
||||
return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey });
|
||||
}
|
||||
);
|
||||
|
||||
portalRouter.get("/payment-methods", async (c) => {
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const methods = await listPaymentMethods(clientId);
|
||||
if (methods === null) return c.json({ error: "Payment service unavailable" }, 503);
|
||||
return c.json(methods);
|
||||
});
|
||||
|
||||
portalRouter.post("/payment-methods", async (c) => {
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
||||
const customerId = await getOrCreateStripeCustomer(clientId);
|
||||
if (!customerId) return c.json({ error: "Could not create customer" }, 500);
|
||||
|
||||
const result = await createSetupIntent(customerId);
|
||||
if (!result) return c.json({ error: "Payment service unavailable" }, 503);
|
||||
|
||||
return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey });
|
||||
});
|
||||
|
||||
portalRouter.delete("/payment-methods/:id", async (c) => {
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const paymentMethodId = c.req.param("id");
|
||||
const ok = await detachPaymentMethod(paymentMethodId);
|
||||
if (!ok) return c.json({ error: "Failed to detach payment method" }, 500);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── Config endpoint ─────────────────────────────────────────────────────────
|
||||
|
||||
portalRouter.get("/config", (c) => {
|
||||
return c.json({
|
||||
stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY ?? "",
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Dev-mode session creation ──────────────────────────────────────────────
|
||||
// Allows the dev login selector to vend an impersonation session for a client
|
||||
// without requiring manager auth. Only available when AUTH_DISABLED=true.
|
||||
|
||||
@@ -9,6 +9,17 @@ export const setupRouter = new Hono<AppEnv>();
|
||||
// GET /api/setup/status — public (no auth), returns whether setup is needed
|
||||
// and whether the auth provider bootstrap step should be shown
|
||||
setupRouter.get("/status", async (c) => {
|
||||
const skipOobe = ["true", "1", "yes"].includes((process.env.SKIP_OOBE || "").toLowerCase());
|
||||
if (skipOobe) {
|
||||
return c.json({
|
||||
needsSetup: false,
|
||||
showAuthProviderStep: false,
|
||||
authConfigExists: false,
|
||||
authEnvVarsSet: false,
|
||||
skipped: true,
|
||||
});
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Check if any super user exists
|
||||
|
||||
@@ -18,6 +18,10 @@ const createStaffSchema = z.object({
|
||||
|
||||
const updateStaffSchema = createStaffSchema.partial().omit({ email: true });
|
||||
|
||||
const linkUserSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
});
|
||||
|
||||
staffRouter.get("/me", async (c) => {
|
||||
const staffRow = c.get("staff");
|
||||
return c.json(staffRow);
|
||||
@@ -106,6 +110,32 @@ staffRouter.patch("/:id", zValidator("json", updateStaffSchema), async (c) => {
|
||||
return c.json(row);
|
||||
});
|
||||
|
||||
staffRouter.patch("/:id/link-user", zValidator("json", linkUserSchema), async (c) => {
|
||||
const db = getDb();
|
||||
const targetId = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
const currentStaff = c.get("staff");
|
||||
|
||||
if (currentStaff.role !== "manager" && !currentStaff.isSuperUser) {
|
||||
return c.json({ error: "Forbidden: only managers or super users can link staff to users" }, 403);
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.id, targetId))
|
||||
.limit(1);
|
||||
if (!existing) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
const [updated] = await db
|
||||
.update(staff)
|
||||
.set({ userId: body.userId, updatedAt: new Date() })
|
||||
.where(eq(staff.id, targetId))
|
||||
.returning();
|
||||
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
staffRouter.delete("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import Stripe from "stripe";
|
||||
import { getDb, clients, eq, invoices } from "@groombook/db";
|
||||
|
||||
let _stripe: Stripe | null | undefined;
|
||||
|
||||
function getStripeClient(): Stripe | null {
|
||||
if (_stripe === undefined) {
|
||||
const secretKey = process.env.STRIPE_SECRET_KEY;
|
||||
if (!secretKey) return null;
|
||||
_stripe = new Stripe(secretKey);
|
||||
}
|
||||
return _stripe;
|
||||
}
|
||||
|
||||
export async function getOrCreateStripeCustomer(clientId: string): Promise<string | null> {
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return null;
|
||||
|
||||
const db = getDb();
|
||||
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
|
||||
if (!client) return null;
|
||||
|
||||
if (client.stripeCustomerId) return client.stripeCustomerId;
|
||||
|
||||
const customer = await stripe.customers.create({
|
||||
metadata: { groombook_client_id: clientId },
|
||||
});
|
||||
|
||||
await db
|
||||
.update(clients)
|
||||
.set({ stripeCustomerId: customer.id, updatedAt: new Date() })
|
||||
.where(eq(clients.id, clientId));
|
||||
|
||||
return customer.id;
|
||||
}
|
||||
|
||||
export async function createPaymentIntent(
|
||||
invoiceIdOrIds: string | string[],
|
||||
clientId: string
|
||||
): Promise<{ clientSecret: string; paymentIntentId: string } | null> {
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return null;
|
||||
|
||||
const db = getDb();
|
||||
const invoiceIds = Array.isArray(invoiceIdOrIds) ? invoiceIdOrIds : [invoiceIdOrIds];
|
||||
const firstInvoiceId = invoiceIds[0];
|
||||
if (!firstInvoiceId) return null;
|
||||
|
||||
const invoiceRows = await db
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq(invoices.id, firstInvoiceId));
|
||||
|
||||
const [invoice] = invoiceRows;
|
||||
if (!invoice) return null;
|
||||
|
||||
let totalCents = invoice.totalCents;
|
||||
if (invoiceIds.length > 1) {
|
||||
const allInvoices = await db
|
||||
.select({ totalCents: invoices.totalCents })
|
||||
.from(invoices)
|
||||
.where(eq(invoices.id, firstInvoiceId));
|
||||
totalCents = allInvoices.reduce((sum, inv) => sum + inv.totalCents, totalCents);
|
||||
}
|
||||
|
||||
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
||||
if (!stripeCustomerId) return null;
|
||||
|
||||
const paymentIntent = await stripe.paymentIntents.create({
|
||||
amount: totalCents,
|
||||
currency: "usd",
|
||||
customer: stripeCustomerId,
|
||||
metadata: {
|
||||
groombook_invoice_ids: invoiceIds.join(","),
|
||||
groombook_client_id: clientId,
|
||||
},
|
||||
automatic_payment_methods: { enabled: true },
|
||||
});
|
||||
|
||||
for (const invId of invoiceIds) {
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({ stripePaymentIntentId: paymentIntent.id, updatedAt: new Date() })
|
||||
.where(eq(invoices.id, invId));
|
||||
}
|
||||
|
||||
const clientSecret = paymentIntent.client_secret;
|
||||
if (!clientSecret) return null;
|
||||
|
||||
return { clientSecret, paymentIntentId: paymentIntent.id };
|
||||
}
|
||||
|
||||
export async function processRefund(
|
||||
invoiceId: string,
|
||||
amountCents?: number
|
||||
): Promise<{ refundId: string } | null> {
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return null;
|
||||
|
||||
const db = getDb();
|
||||
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, invoiceId)).limit(1);
|
||||
if (!invoice?.stripePaymentIntentId) return null;
|
||||
|
||||
const refund = await stripe.refunds.create({
|
||||
payment_intent: invoice.stripePaymentIntentId,
|
||||
amount: amountCents,
|
||||
});
|
||||
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({ stripeRefundId: refund.id, updatedAt: new Date() })
|
||||
.where(eq(invoices.id, invoiceId));
|
||||
|
||||
return { refundId: refund.id };
|
||||
}
|
||||
|
||||
export async function listPaymentMethods(clientId: string): Promise<Stripe.PaymentMethod[] | null> {
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return null;
|
||||
|
||||
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
||||
if (!stripeCustomerId) return null;
|
||||
|
||||
const methods = await stripe.paymentMethods.list({
|
||||
customer: stripeCustomerId,
|
||||
type: "card",
|
||||
});
|
||||
|
||||
return methods.data;
|
||||
}
|
||||
|
||||
export async function attachPaymentMethod(
|
||||
clientId: string,
|
||||
paymentMethodId: string
|
||||
): Promise<boolean> {
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return false;
|
||||
|
||||
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
||||
if (!stripeCustomerId) return false;
|
||||
|
||||
await stripe.paymentMethods.attach(paymentMethodId, { customer: stripeCustomerId });
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function detachPaymentMethod(paymentMethodId: string): Promise<boolean> {
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return false;
|
||||
|
||||
await stripe.paymentMethods.detach(paymentMethodId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function createSetupIntent(customerId: string): Promise<{ clientSecret: string } | null> {
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return null;
|
||||
|
||||
const setupIntent = await stripe.setupIntents.create({
|
||||
customer: customerId,
|
||||
payment_method_types: ["card"],
|
||||
});
|
||||
|
||||
return { clientSecret: setupIntent.client_secret! };
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ test("admin staff page loads", async ({ page }) => {
|
||||
|
||||
test("admin invoices page loads", async ({ page }) => {
|
||||
await page.goto("/admin/invoices");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await expect(page.getByText("GroomBook")).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Invoices" })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"dependencies": {
|
||||
"@groombook/types": "workspace:*",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"better-auth": "^1.0.0",
|
||||
"better-auth": "^1.5.6",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
+80
-36
@@ -1,4 +1,4 @@
|
||||
import { Routes, Route, Link, useLocation, Navigate } from "react-router-dom";
|
||||
import { Routes, Route, Link, useLocation, Navigate, useNavigate } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppointmentsPage } from "./pages/Appointments.js";
|
||||
import { ClientsPage } from "./pages/Clients.js";
|
||||
@@ -18,22 +18,31 @@ import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
||||
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
||||
import { BrandingProvider, useBranding } from "./BrandingContext.js";
|
||||
import { GlobalSearch } from "./components/GlobalSearch.js";
|
||||
import { useSession, signIn } from "./lib/auth-client.js";
|
||||
import { useSession, signIn, signOut } from "./lib/auth-client.js";
|
||||
|
||||
function LoginPage() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [providers, setProviders] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/auth/providers")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setProviders(data.providers ?? []))
|
||||
.catch(() => setProviders([]));
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const authError = params.get("error");
|
||||
if (authError) setError(authError.replace(/_/g, " "));
|
||||
}, []);
|
||||
|
||||
const handleSocialLogin = async (provider: string) => {
|
||||
setIsLoading(true);
|
||||
await signIn.social({ provider, callbackURL: window.location.origin });
|
||||
setError(null);
|
||||
const result = await signIn.social({ provider, callbackURL: window.location.origin });
|
||||
if (result?.error) {
|
||||
setError(result.error.message ?? "Sign-in failed");
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isGoogle = providers.includes("google");
|
||||
@@ -65,6 +74,11 @@ function LoginPage() {
|
||||
<p style={{ color: "#6b7280", marginBottom: "1.5rem", fontSize: 14 }}>
|
||||
Sign in to continue
|
||||
</p>
|
||||
{error && (
|
||||
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 6, padding: "0.5rem 0.75rem", marginBottom: "1rem", color: "#991b1b", fontSize: 13 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{isGoogle && (
|
||||
<button
|
||||
onClick={() => handleSocialLogin("google")}
|
||||
@@ -167,6 +181,7 @@ const NAV_LINKS = [
|
||||
|
||||
function AdminLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { branding } = useBranding();
|
||||
|
||||
const logoSrc = branding.logoBase64 && branding.logoMimeType
|
||||
@@ -195,6 +210,7 @@ function AdminLayout() {
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginRight: "1.25rem",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{logoSrc && (
|
||||
<img src={logoSrc} alt="" style={{ width: 24, height: 24, objectFit: "contain" }} />
|
||||
@@ -208,45 +224,73 @@ function AdminLayout() {
|
||||
</strong>
|
||||
</div>
|
||||
<GlobalSearch />
|
||||
<Link
|
||||
to="/admin/book"
|
||||
<div style={{
|
||||
display: "flex",
|
||||
overflowX: "auto",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: "0.25rem",
|
||||
}}>
|
||||
<Link
|
||||
to="/admin/book"
|
||||
style={{
|
||||
padding: "0.4rem 0.85rem",
|
||||
borderRadius: 6,
|
||||
textDecoration: "none",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "#fff",
|
||||
background: branding.primaryColor,
|
||||
boxShadow: "0 1px 2px rgba(79, 138, 111, 0.3)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
Book
|
||||
</Link>
|
||||
{NAV_LINKS.map(({ to, label }) => {
|
||||
const active =
|
||||
to === "/admin"
|
||||
? location.pathname === "/admin"
|
||||
: location.pathname.startsWith(to);
|
||||
return (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
style={{
|
||||
padding: "0.4rem 0.75rem",
|
||||
borderRadius: 6,
|
||||
textDecoration: "none",
|
||||
fontSize: 13,
|
||||
fontWeight: active ? 600 : 500,
|
||||
color: active ? "#2d6a4f" : "#4b5563",
|
||||
background: active ? "#ecfdf5" : "transparent",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await signOut();
|
||||
navigate("/login");
|
||||
}}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
padding: "0.4rem 0.85rem",
|
||||
borderRadius: 6,
|
||||
textDecoration: "none",
|
||||
border: "1px solid #e2e8f0",
|
||||
background: "#fff",
|
||||
color: "#4b5563",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "#fff",
|
||||
background: branding.primaryColor,
|
||||
marginRight: "0.5rem",
|
||||
boxShadow: "0 1px 2px rgba(79, 138, 111, 0.3)",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Book
|
||||
</Link>
|
||||
{NAV_LINKS.map(({ to, label }) => {
|
||||
const active =
|
||||
to === "/admin"
|
||||
? location.pathname === "/admin"
|
||||
: location.pathname.startsWith(to);
|
||||
return (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
style={{
|
||||
padding: "0.4rem 0.75rem",
|
||||
borderRadius: 6,
|
||||
textDecoration: "none",
|
||||
fontSize: 13,
|
||||
fontWeight: active ? 600 : 500,
|
||||
color: active ? "#2d6a4f" : "#4b5563",
|
||||
background: active ? "#ecfdf5" : "transparent",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
Logout
|
||||
</button>
|
||||
</nav>
|
||||
<main style={{ padding: "1.25rem 1.5rem" }}>
|
||||
<Routes>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -41,11 +41,11 @@ export default defineConfig({
|
||||
workbox: {
|
||||
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
|
||||
navigateFallbackDenylist: [
|
||||
/^\/api\/auth\/oauth2\/callback\//,
|
||||
/^\/api\/auth\//,
|
||||
],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^http.*\/api\/.*/i,
|
||||
urlPattern: /^http.*\/api\/(?!auth\/).*/i,
|
||||
handler: "NetworkFirst",
|
||||
options: {
|
||||
cacheName: "api-cache",
|
||||
|
||||
+1
-1
Submodule infra updated: 49575eb4f6...d6c0d13d02
@@ -0,0 +1,6 @@
|
||||
-- Better-Auth rate limiting table (GRO-574)
|
||||
CREATE TABLE "rate_limit" (
|
||||
key TEXT NOT NULL PRIMARY KEY,
|
||||
count INTEGER NOT NULL,
|
||||
last_request BIGINT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE "clients" ADD COLUMN "stripe_customer_id" text;
|
||||
ALTER TABLE "clients" ADD CONSTRAINT "idx_clients_stripe_customer_id" UNIQUE("stripe_customer_id");
|
||||
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");
|
||||
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"id": "0026_stripe_payment",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"authProviderConfig": {
|
||||
"name": "auth_provider_config",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
||||
"providerId": { "name": "provider_id", "type": "text", "isNullable": false },
|
||||
"displayName": { "name": "display_name", "type": "text", "isNullable": false },
|
||||
"issuerUrl": { "name": "issuer_url", "type": "text", "isNullable": false },
|
||||
"internalBaseUrl": { "name": "internal_base_url", "type": "text", "isNullable": true },
|
||||
"clientId": { "name": "client_id", "type": "text", "isNullable": false },
|
||||
"clientSecret": { "name": "client_secret", "type": "text", "isNullable": false },
|
||||
"scopes": { "name": "scopes", "type": "text", "isNullable": false, "default": "'openid profile email'" },
|
||||
"enabled": { "name": "enabled", "type": "boolean", "isNullable": false, "default": "true" },
|
||||
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
||||
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {}
|
||||
},
|
||||
"businessSettings": {
|
||||
"name": "business_settings",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
||||
"businessName": { "name": "business_name", "type": "text", "isNullable": false, "default": "'GroomBook'" },
|
||||
"logoBase64": { "name": "logo_base64", "type": "text", "isNullable": true },
|
||||
"logoMimeType": { "name": "logo_mime_type", "type": "text", "isNullable": true },
|
||||
"logoKey": { "name": "logo_key", "type": "text", "isNullable": true },
|
||||
"primaryColor": { "name": "primary_color", "type": "text", "isNullable": false, "default": "'#4f8a6f'" },
|
||||
"accentColor": { "name": "accent_color", "type": "text", "isNullable": false, "default": "'#8b7355'" },
|
||||
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
||||
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {}
|
||||
},
|
||||
"clients": {
|
||||
"name": "clients",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
||||
"name": { "name": "name", "type": "text", "isNullable": false },
|
||||
"email": { "name": "email", "type": "text", "isNullable": true },
|
||||
"phone": { "name": "phone", "type": "text", "isNullable": true },
|
||||
"address": { "name": "address", "type": "text", "isNullable": true },
|
||||
"notes": { "name": "notes", "type": "text", "isNullable": true },
|
||||
"emailOptOut": { "name": "email_opt_out", "type": "boolean", "isNullable": false, "default": "false" },
|
||||
"smsOptIn": { "name": "sms_opt_in", "type": "boolean", "isNullable": false, "default": "false" },
|
||||
"smsConsentDate": { "name": "sms_consent_date", "type": "timestamp", "isNullable": true },
|
||||
"smsOptOutDate": { "name": "sms_opt_out_date", "type": "timestamp", "isNullable": true },
|
||||
"smsConsentText": { "name": "sms_consent_text", "type": "text", "isNullable": true },
|
||||
"stripeCustomerId": { "name": "stripe_customer_id", "type": "text", "isNullable": true },
|
||||
"status": { "name": "status", "type": "client_status", "isNullable": false, "default": "'active'" },
|
||||
"disabledAt": { "name": "disabled_at", "type": "timestamp", "isNullable": true },
|
||||
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
||||
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": { "idx_clients_stripe_customer_id": { "columns": ["stripe_customer_id"] } }
|
||||
},
|
||||
"invoices": {
|
||||
"name": "invoices",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
||||
"appointmentId": { "name": "appointment_id", "type": "uuid", "isNullable": true },
|
||||
"clientId": { "name": "client_id", "type": "uuid", "isNullable": false },
|
||||
"subtotalCents": { "name": "subtotal_cents", "type": "integer", "isNullable": false },
|
||||
"taxCents": { "name": "tax_cents", "type": "integer", "isNullable": false, "default": "0" },
|
||||
"tipCents": { "name": "tip_cents", "type": "integer", "isNullable": false, "default": "0" },
|
||||
"totalCents": { "name": "total_cents", "type": "integer", "isNullable": false },
|
||||
"status": { "name": "status", "type": "invoice_status", "isNullable": false, "default": "'draft'" },
|
||||
"paymentMethod": { "name": "payment_method", "type": "payment_method", "isNullable": true },
|
||||
"paidAt": { "name": "paid_at", "type": "timestamp", "isNullable": true },
|
||||
"stripePaymentIntentId": { "name": "stripe_payment_intent_id", "type": "text", "isNullable": true },
|
||||
"stripeRefundId": { "name": "stripe_refund_id", "type": "text", "isNullable": true },
|
||||
"paymentFailureReason": { "name": "payment_failure_reason", "type": "text", "isNullable": true },
|
||||
"notes": { "name": "notes", "type": "text", "isNullable": true },
|
||||
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
||||
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
||||
},
|
||||
"indexes": { "idx_invoices_client_id": { "columns": ["client_id"] }, "idx_invoices_status": { "columns": ["status"] }, "idx_invoices_created_at": { "columns": ["created_at"] } },
|
||||
"foreignKeys": { "invoices_appointment_id_fkey": { "columns": ["appointmentId"], "reference": { "table": "appointments", "columns": ["id"] } }, "invoices_client_id_fkey": { "columns": ["clientId"], "reference": { "table": "clients", "columns": ["id"] } } },
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": { "idx_invoices_stripe_payment_intent_id": { "columns": ["stripe_payment_intent_id"] } }
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"appointment_status": { "name": "appointment_status", "values": ["scheduled", "confirmed", "in_progress", "completed", "cancelled", "no_show"] },
|
||||
"client_status": { "name": "client_status", "values": ["active", "disabled"] },
|
||||
"impersonation_session_status": { "name": "impersonation_session_status", "values": ["active", "ended", "expired"] },
|
||||
"invoice_status": { "name": "invoice_status", "values": ["draft", "pending", "paid", "void"] },
|
||||
"payment_method": { "name": "payment_method", "values": ["cash", "card", "check", "other"] },
|
||||
"staff_role": { "name": "staff_role", "values": ["groomer", "receptionist", "manager"] },
|
||||
"waitlist_status": { "name": "waitlist_status", "values": ["active", "notified", "expired", "cancelled"] }
|
||||
},
|
||||
"nativeEnums": {}
|
||||
}
|
||||
@@ -176,6 +176,20 @@
|
||||
"when": 1775396067192,
|
||||
"tag": "0024_invoice_indexes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 25,
|
||||
"version": "7",
|
||||
"when": 1775482467192,
|
||||
"tag": "0025_rate_limit",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 26,
|
||||
"version": "7",
|
||||
"when": 1775568867192,
|
||||
"tag": "0026_stripe_payment",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -71,6 +71,7 @@ export function buildClient(overrides: Partial<ClientRow> = {}): ClientRow {
|
||||
address: "1 Main St, Springfield, CA 90000",
|
||||
notes: null,
|
||||
emailOptOut: false,
|
||||
stripeCustomerId: null,
|
||||
status: "active",
|
||||
disabledAt: null,
|
||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||
|
||||
@@ -109,8 +109,8 @@ export const clients = pgTable("clients", {
|
||||
phone: text("phone"),
|
||||
address: text("address"),
|
||||
notes: text("notes"),
|
||||
// Set to true if the client has opted out of email reminders/notifications
|
||||
emailOptOut: boolean("email_opt_out").notNull().default(false),
|
||||
stripeCustomerId: text("stripe_customer_id"),
|
||||
status: clientStatusEnum("status").notNull().default("active"),
|
||||
disabledAt: timestamp("disabled_at"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
@@ -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),
|
||||
index("idx_invoices_stripe_payment_intent_id").on(t.stripePaymentIntentId),
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
Generated
+17
-1
@@ -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
|
||||
@@ -87,7 +90,7 @@ importers:
|
||||
specifier: ^4.2.2
|
||||
version: 4.2.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
|
||||
better-auth:
|
||||
specifier: ^1.0.0
|
||||
specifier: ^1.5.6
|
||||
version: 1.5.6(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
|
||||
lucide-react:
|
||||
specifier: ^0.577.0
|
||||
@@ -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