Compare commits

..

3 Commits

Author SHA1 Message Date
Paperclip 00470ad148 feat(GRO-566): add SKIP_OOBE env var to bypass setup wizard
SKIP_OOBE=true|1|yes causes GET /api/setup/status to return
needsSetup: false regardless of DB state. Useful in dev/test
environments where the DB has data but OOBE would block access.

Changes:
- apps/api/src/routes/setup.ts: SKIP_OOBE check in GET /status
- .env.example: document SKIP_OOBE=false
- apps/api/src/__tests__/setup.test.ts: cover SKIP_OOBE=true|1|yes

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 02:36:17 +00:00
Paperclip 9cce0bc5d9 feat(GRO-565): Better Auth Phase 3 - password change, OIDC discovery, session cleanup, email verification
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 02:23:44 +00:00
Paperclip 856096a531 fix(GRO-564): remove automatic staff-user email linking
Automatic linking during OOBE login is a security risk - staff records
should only be linked to auth users via explicit admin action using
PATCH /api/staff/:id/link-user.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 02:14:42 +00:00
8 changed files with 110 additions and 50 deletions
+62 -9
View File
@@ -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";
@@ -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, {
@@ -192,6 +239,19 @@ export async function initAuth(): Promise<void> {
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),
},
],
+1 -16
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { and, eq, getDb, sql, staff, businessSettings, authProviderConfig, encryptSecret } from "@groombook/db";
import { eq, getDb, staff, businessSettings, authProviderConfig, encryptSecret } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
export const setupRouter = new Hono<AppEnv>();
@@ -108,21 +108,6 @@ 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) {
// Brand new user during OOBE — create staff record
if (!jwt.email) {
+14
View File
@@ -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));
}
@@ -3,13 +3,13 @@ kind: Kustomization
namespace: groombook-uat
images:
- name: ghcr.io/groombook/api
newTag: "2026.04.12-15131b7"
newTag: "2026.04.03-90be1be"
- name: ghcr.io/groombook/web
newTag: "2026.04.12-15131b7"
newTag: "2026.04.03-90be1be"
- name: ghcr.io/groombook/migrate
newTag: "2026.04.12-15131b7"
newTag: "2026.04.03-90be1be"
- name: ghcr.io/groombook/seed
newTag: "2026.04.12-15131b7"
newTag: "2026.04.03-90be1be"
resources:
- ../../base
- postgres-sealed-secret.yaml
+1 -1
View File
@@ -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>
@@ -1,6 +0,0 @@
-- 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
);
@@ -176,13 +176,6 @@
"when": 1775396067192,
"tag": "0024_invoice_indexes",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1775482467192,
"tag": "0025_rate_limit",
"breakpoints": true
}
]
}