Compare commits

..

2 Commits

Author SHA1 Message Date
Flea Flicker d95a2ede36 fix(e2e): add paginated mock for /api/invoices and remove fragile networkidle wait
- Return { data: [], total: 0 } for /api/invoices instead of falling through to generic []
- Removes waitForLoadState('networkidle') from admin invoices test (fragile in CI)
- Fixes GRO-558
2026-04-11 15:42:56 +00:00
Flea Flicker fb892cadad fix flaky E2E test: add networkidle wait before admin invoices assertions
Fixes GRO-557
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-11 15:31:42 +00:00
17 changed files with 100 additions and 311 deletions
-6
View File
@@ -11,12 +11,6 @@ AUTH_DISABLED=false
OIDC_ISSUER=https://authentik.example.com OIDC_ISSUER=https://authentik.example.com
OIDC_AUDIENCE=groombook 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 ─────────────────────────────────────────────────────────────────────── # ── API ───────────────────────────────────────────────────────────────────────
PORT=3000 PORT=3000
CORS_ORIGIN=http://localhost:8080 CORS_ORIGIN=http://localhost:8080
-42
View File
@@ -418,48 +418,6 @@ describe("GET /setup/status — OOBE bootstrap logic", () => {
expect(body.showAuthProviderStep).toBe(false); // DB config already exists expect(body.showAuthProviderStep).toBe(false); // DB config already exists
expect(body.authConfigExists).toBe(true); 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", () => { describe("POST /setup/auth-provider — OOBE bootstrap", () => {
+1 -7
View File
@@ -105,13 +105,7 @@ api.use("*", resolveStaffMiddleware);
// Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes // Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes
// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths // authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths
const authRouter = new Hono(); const authRouter = new Hono();
authRouter.all("/*", (c) => { authRouter.all("/*", (c) => getAuth().handler(c.req.raw));
try {
return getAuth().handler(c.req.raw);
} catch {
return c.json({ error: "Authentication not configured" }, 503);
}
});
api.route("/auth", authRouter); api.route("/auth", authRouter);
// ── Role guards ──────────────────────────────────────────────────────────────── // ── Role guards ────────────────────────────────────────────────────────────────
+12 -76
View File
@@ -3,7 +3,6 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { genericOAuth } from "better-auth/plugins"; import { genericOAuth } from "better-auth/plugins";
import { getDb, authProviderConfig, eq } from "@groombook/db"; import { getDb, authProviderConfig, eq } from "@groombook/db";
import { decryptSecret } 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_SECRET = process.env.BETTER_AUTH_SECRET;
const BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000"; const BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
@@ -91,12 +90,6 @@ export async function initAuth(): Promise<void> {
database: drizzleAdapter(getDb(), { provider: "pg" }), database: drizzleAdapter(getDb(), { provider: "pg" }),
secret: BETTER_AUTH_SECRET ?? "placeholder-secret-do-not-use-in-prod", secret: BETTER_AUTH_SECRET ?? "placeholder-secret-do-not-use-in-prod",
baseURL: BETTER_AUTH_URL, baseURL: BETTER_AUTH_URL,
rateLimit: {
enabled: true,
max: 10,
window: 60,
storage: "database",
},
plugins: [ plugins: [
genericOAuth({ genericOAuth({
config: [ config: [
@@ -177,51 +170,7 @@ export async function initAuth(): Promise<void> {
const hasGoogle = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET); 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 hasGitHub = !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
// Fetch OIDC discovery document to derive canonical provider URLs. const callbackBase = `${BETTER_AUTH_URL}/api/auth/callback`;
// 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 // Build Better-Auth instance using resolved config
authInstance = betterAuth({ authInstance = betterAuth({
@@ -230,28 +179,6 @@ export async function initAuth(): Promise<void> {
}), }),
secret: BETTER_AUTH_SECRET, secret: BETTER_AUTH_SECRET,
baseURL: BETTER_AUTH_URL, baseURL: BETTER_AUTH_URL,
rateLimit: {
enabled: true,
max: 10,
window: 60,
storage: "database",
},
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: [ plugins: [
genericOAuth({ genericOAuth({
config: [ config: [
@@ -259,8 +186,15 @@ export async function initAuth(): Promise<void> {
providerId: providerConfig.providerId, providerId: providerConfig.providerId,
clientId: providerConfig.clientId, clientId: providerConfig.clientId,
clientSecret: providerConfig.clientSecret, clientSecret: providerConfig.clientSecret,
discoveryUrl: discoveryUrlStr, ...(providerConfig.internalBaseUrl
...(Object.keys(oidcConfig).length > 0 ? oidcConfig : {}), ? {
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`,
}),
scopes: providerConfig.scopes.split(" ").filter(Boolean), scopes: providerConfig.scopes.split(" ").filter(Boolean),
}, },
], ],
@@ -271,12 +205,14 @@ export async function initAuth(): Promise<void> {
google: { google: {
clientId: process.env.GOOGLE_CLIENT_ID!, clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
redirectURI: `${callbackBase}/google`,
}, },
} : {}), } : {}),
...(hasGitHub ? { ...(hasGitHub ? {
github: { github: {
clientId: process.env.GITHUB_CLIENT_ID!, clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!, clientSecret: process.env.GITHUB_CLIENT_SECRET!,
redirectURI: `${callbackBase}/github`,
}, },
} : {}), } : {}),
}, },
+2 -8
View File
@@ -23,6 +23,7 @@ if (process.env.AUTH_DISABLED === "true") {
} }
export const authMiddleware: MiddlewareHandler = async (c, next) => { 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/")) { if (c.req.path.startsWith("/api/auth/")) {
await next(); await next();
return; return;
@@ -36,14 +37,7 @@ export const authMiddleware: MiddlewareHandler = async (c, next) => {
return; return;
} }
let auth; const session = await getAuth().api.getSession({
try {
auth = getAuth();
} catch {
return c.json({ error: "Authentication not configured" }, 503);
}
const session = await auth.api.getSession({
headers: c.req.raw.headers, headers: c.req.raw.headers,
}); });
+20 -1
View File
@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from "hono"; import type { MiddlewareHandler } from "hono";
import { eq, getDb, staff } from "@groombook/db"; import { and, eq, getDb, isNull, staff } from "@groombook/db";
export type StaffRole = "groomer" | "receptionist" | "manager"; export type StaffRole = "groomer" | "receptionist" | "manager";
export type StaffRow = typeof staff.$inferSelect; export type StaffRow = typeof staff.$inferSelect;
@@ -90,6 +90,25 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
.from(staff) .from(staff)
.where(eq(staff.oidcSub, jwt.sub)); .where(eq(staff.oidcSub, jwt.sub));
if (!fallbackRow) { 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( return c.json(
{ error: "Forbidden: no staff record found for authenticated user" }, { error: "Forbidden: no staff record found for authenticated user" },
403 403
+16 -12
View File
@@ -1,7 +1,7 @@
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 { eq, getDb, staff, businessSettings, authProviderConfig, encryptSecret } from "@groombook/db"; import { and, eq, getDb, sql, staff, businessSettings, authProviderConfig, encryptSecret } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js"; import type { AppEnv } from "../middleware/rbac.js";
export const setupRouter = new Hono<AppEnv>(); export const setupRouter = new Hono<AppEnv>();
@@ -9,17 +9,6 @@ export const setupRouter = new Hono<AppEnv>();
// GET /api/setup/status — public (no auth), returns whether setup is needed // GET /api/setup/status — public (no auth), returns whether setup is needed
// and whether the auth provider bootstrap step should be shown // and whether the auth provider bootstrap step should be shown
setupRouter.get("/status", async (c) => { 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(); const db = getDb();
// Check if any super user exists // Check if any super user exists
@@ -108,6 +97,21 @@ setupRouter.post("/", zValidator("json", setupSchema), async (c) => {
} }
} }
if (!resolvedStaff && jwt.email) {
// Try auto-link by email: staff record exists with matching email but no userId
const [byEmail] = await tx
.select()
.from(staff)
.where(and(eq(staff.email, jwt.email), sql`${staff.userId} IS NULL`));
if (byEmail) {
await tx
.update(staff)
.set({ userId: jwt.sub })
.where(eq(staff.id, byEmail.id));
resolvedStaff = { ...byEmail, userId: jwt.sub };
}
}
if (!resolvedStaff) { if (!resolvedStaff) {
// Brand new user during OOBE — create staff record // Brand new user during OOBE — create staff record
if (!jwt.email) { if (!jwt.email) {
-30
View File
@@ -18,10 +18,6 @@ const createStaffSchema = z.object({
const updateStaffSchema = createStaffSchema.partial().omit({ email: true }); const updateStaffSchema = createStaffSchema.partial().omit({ email: true });
const linkUserSchema = z.object({
userId: z.string().min(1),
});
staffRouter.get("/me", async (c) => { staffRouter.get("/me", async (c) => {
const staffRow = c.get("staff"); const staffRow = c.get("staff");
return c.json(staffRow); return c.json(staffRow);
@@ -110,32 +106,6 @@ staffRouter.patch("/:id", zValidator("json", updateStaffSchema), async (c) => {
return c.json(row); 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) => { staffRouter.delete("/:id", async (c) => {
const db = getDb(); const db = getDb();
const id = c.req.param("id"); const id = c.req.param("id");
-14
View File
@@ -12,7 +12,6 @@ import {
services, services,
staff, staff,
reminderLogs, reminderLogs,
session,
} from "@groombook/db"; } from "@groombook/db";
import { import {
buildReminderEmail, buildReminderEmail,
@@ -156,19 +155,6 @@ export function startReminderScheduler(): void {
runReminderCheck().catch((err) => { runReminderCheck().catch((err) => {
console.error("[reminders] Error during reminder check:", 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"); 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));
}
-1
View File
@@ -85,7 +85,6 @@ test("admin staff page loads", async ({ page }) => {
test("admin invoices page loads", async ({ page }) => { test("admin invoices page loads", async ({ page }) => {
await page.goto("/admin/invoices"); await page.goto("/admin/invoices");
await page.waitForLoadState("domcontentloaded");
await expect(page.getByText("GroomBook")).toBeVisible(); await expect(page.getByText("GroomBook")).toBeVisible();
await expect(page.getByRole("link", { name: "Invoices" })).toBeVisible(); await expect(page.getByRole("link", { name: "Invoices" })).toBeVisible();
}); });
+1 -1
View File
@@ -15,7 +15,7 @@
"dependencies": { "dependencies": {
"@groombook/types": "workspace:*", "@groombook/types": "workspace:*",
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
"better-auth": "^1.5.6", "better-auth": "^1.0.0",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
+36 -80
View File
@@ -1,4 +1,4 @@
import { Routes, Route, Link, useLocation, Navigate, useNavigate } from "react-router-dom"; import { Routes, Route, Link, useLocation, Navigate } from "react-router-dom";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { AppointmentsPage } from "./pages/Appointments.js"; import { AppointmentsPage } from "./pages/Appointments.js";
import { ClientsPage } from "./pages/Clients.js"; import { ClientsPage } from "./pages/Clients.js";
@@ -18,31 +18,22 @@ import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
import { DevSessionIndicator } from "./components/DevSessionIndicator.js"; import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
import { BrandingProvider, useBranding } from "./BrandingContext.js"; import { BrandingProvider, useBranding } from "./BrandingContext.js";
import { GlobalSearch } from "./components/GlobalSearch.js"; import { GlobalSearch } from "./components/GlobalSearch.js";
import { useSession, signIn, signOut } from "./lib/auth-client.js"; import { useSession, signIn } from "./lib/auth-client.js";
function LoginPage() { function LoginPage() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [providers, setProviders] = useState<string[]>([]); const [providers, setProviders] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
fetch("/api/auth/providers") fetch("/api/auth/providers")
.then((r) => r.json()) .then((r) => r.json())
.then((data) => setProviders(data.providers ?? [])) .then((data) => setProviders(data.providers ?? []))
.catch(() => setProviders([])); .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) => { const handleSocialLogin = async (provider: string) => {
setIsLoading(true); setIsLoading(true);
setError(null); await signIn.social({ provider, callbackURL: window.location.origin });
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"); const isGoogle = providers.includes("google");
@@ -74,11 +65,6 @@ function LoginPage() {
<p style={{ color: "#6b7280", marginBottom: "1.5rem", fontSize: 14 }}> <p style={{ color: "#6b7280", marginBottom: "1.5rem", fontSize: 14 }}>
Sign in to continue Sign in to continue
</p> </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 && ( {isGoogle && (
<button <button
onClick={() => handleSocialLogin("google")} onClick={() => handleSocialLogin("google")}
@@ -181,7 +167,6 @@ const NAV_LINKS = [
function AdminLayout() { function AdminLayout() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate();
const { branding } = useBranding(); const { branding } = useBranding();
const logoSrc = branding.logoBase64 && branding.logoMimeType const logoSrc = branding.logoBase64 && branding.logoMimeType
@@ -210,7 +195,6 @@ function AdminLayout() {
alignItems: "center", alignItems: "center",
gap: 8, gap: 8,
marginRight: "1.25rem", marginRight: "1.25rem",
flexShrink: 0,
}}> }}>
{logoSrc && ( {logoSrc && (
<img src={logoSrc} alt="" style={{ width: 24, height: 24, objectFit: "contain" }} /> <img src={logoSrc} alt="" style={{ width: 24, height: 24, objectFit: "contain" }} />
@@ -224,73 +208,45 @@ function AdminLayout() {
</strong> </strong>
</div> </div>
<GlobalSearch /> <GlobalSearch />
<div style={{ <Link
display: "flex", to="/admin/book"
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={{ style={{
flexShrink: 0,
padding: "0.4rem 0.85rem", padding: "0.4rem 0.85rem",
borderRadius: 6, borderRadius: 6,
border: "1px solid #e2e8f0", textDecoration: "none",
background: "#fff",
color: "#4b5563",
fontSize: 13, fontSize: 13,
fontWeight: 500, fontWeight: 600,
cursor: "pointer", color: "#fff",
background: branding.primaryColor,
marginRight: "0.5rem",
boxShadow: "0 1px 2px rgba(79, 138, 111, 0.3)",
}} }}
> >
Logout Book
</button> </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>
);
})}
</nav> </nav>
<main style={{ padding: "1.25rem 1.5rem" }}> <main style={{ padding: "1.25rem 1.5rem" }}>
<Routes> <Routes>
+1 -1
View File
@@ -4,4 +4,4 @@ export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_API_URL ?? "", baseURL: import.meta.env.VITE_API_URL ?? "",
}); });
export const { signIn, signOut, useSession, changePassword } = authClient; export const { signIn, signOut, useSession } = authClient;
@@ -1,7 +1,6 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { User, Lock, PawPrint, FileCheck, Plus, Archive } from "lucide-react"; import { User, Lock, PawPrint, FileCheck, Plus, Archive } from "lucide-react";
import { PetForm } from "./PetForm.js"; import { PetForm } from "./PetForm.js";
import { authClient } from "../../lib/auth-client.js";
interface Props { interface Props {
sessionId: string | null; sessionId: string | null;
@@ -149,11 +148,9 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
const passwordsMatch = newPassword === confirmPassword; const passwordsMatch = newPassword === confirmPassword;
const canSubmit = newPassword.length > 0 && passwordsMatch && !loading; const canSubmit = currentPassword.length > 0 && newPassword.length > 0 && passwordsMatch;
if (readOnly) { if (readOnly) {
return ( return (
@@ -163,34 +160,17 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
); );
} }
async function handleSubmit() { function handleSubmit() {
if (!canSubmit) return; if (!canSubmit) return;
if (newPassword !== confirmPassword) { if (newPassword !== confirmPassword) {
setError("Passwords do not match."); setError("Passwords do not match.");
return; return;
} }
// TODO: Wire up to actual password-change API endpoint once backend support exists
setError(null); setError(null);
setLoading(true); setCurrentPassword("");
try { setNewPassword("");
// eslint-disable-next-line @typescript-eslint/no-explicit-any setConfirmPassword("");
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 ( return (
@@ -225,13 +205,12 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
/> />
</div> </div>
{error && <p className="text-sm text-red-500">{error}</p>} {error && <p className="text-sm text-red-500">{error}</p>}
{success && <p className="text-sm text-green-600">Password updated successfully.</p>}
<button <button
onClick={handleSubmit} onClick={handleSubmit}
disabled={!canSubmit} 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" 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"
> >
{loading ? "Updating..." : "Update Password"} Update Password
</button> </button>
</div> </div>
</div> </div>
+2 -2
View File
@@ -41,11 +41,11 @@ export default defineConfig({
workbox: { workbox: {
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"], globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
navigateFallbackDenylist: [ navigateFallbackDenylist: [
/^\/api\/auth\//, /^\/api\/auth\/oauth2\/callback\//,
], ],
runtimeCaching: [ runtimeCaching: [
{ {
urlPattern: /^http.*\/api\/(?!auth\/).*/i, urlPattern: /^http.*\/api\/.*/i,
handler: "NetworkFirst", handler: "NetworkFirst",
options: { options: {
cacheName: "api-cache", cacheName: "api-cache",
+1 -1
Submodule infra updated: d6c0d13d02...49575eb4f6
+1 -1
View File
@@ -87,7 +87,7 @@ importers:
specifier: ^4.2.2 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)) 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: better-auth:
specifier: ^1.5.6 specifier: ^1.0.0
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)) 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: lucide-react:
specifier: ^0.577.0 specifier: ^0.577.0