Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00470ad148 | |||
| 9cce0bc5d9 | |||
| 856096a531 |
@@ -11,6 +11,12 @@ 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
|
||||||
|
|||||||
@@ -418,6 +418,48 @@ 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", () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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";
|
||||||
@@ -176,6 +177,52 @@ 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.
|
||||||
|
// 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({
|
||||||
database: drizzleAdapter(db, {
|
database: drizzleAdapter(db, {
|
||||||
@@ -192,6 +239,19 @@ export async function initAuth(): Promise<void> {
|
|||||||
account: {
|
account: {
|
||||||
storeStateStrategy: "cookie" as const,
|
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: [
|
||||||
@@ -199,15 +259,8 @@ export async function initAuth(): Promise<void> {
|
|||||||
providerId: providerConfig.providerId,
|
providerId: providerConfig.providerId,
|
||||||
clientId: providerConfig.clientId,
|
clientId: providerConfig.clientId,
|
||||||
clientSecret: providerConfig.clientSecret,
|
clientSecret: providerConfig.clientSecret,
|
||||||
...(providerConfig.internalBaseUrl
|
discoveryUrl: discoveryUrlStr,
|
||||||
? {
|
...(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),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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 { 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";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
export const setupRouter = new Hono<AppEnv>();
|
export const setupRouter = new Hono<AppEnv>();
|
||||||
@@ -9,6 +9,17 @@ 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
|
||||||
@@ -97,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) {
|
if (!resolvedStaff) {
|
||||||
// Brand new user during OOBE — create staff record
|
// Brand new user during OOBE — create staff record
|
||||||
if (!jwt.email) {
|
if (!jwt.email) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
services,
|
services,
|
||||||
staff,
|
staff,
|
||||||
reminderLogs,
|
reminderLogs,
|
||||||
|
session,
|
||||||
} from "@groombook/db";
|
} from "@groombook/db";
|
||||||
import {
|
import {
|
||||||
buildReminderEmail,
|
buildReminderEmail,
|
||||||
@@ -155,6 +156,19 @@ 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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 } = authClient;
|
export const { signIn, signOut, useSession, changePassword } = authClient;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
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;
|
||||||
@@ -148,9 +149,11 @@ 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 = currentPassword.length > 0 && newPassword.length > 0 && passwordsMatch;
|
const canSubmit = newPassword.length > 0 && passwordsMatch && !loading;
|
||||||
|
|
||||||
if (readOnly) {
|
if (readOnly) {
|
||||||
return (
|
return (
|
||||||
@@ -160,17 +163,34 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit() {
|
async 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);
|
||||||
setCurrentPassword("");
|
setLoading(true);
|
||||||
setNewPassword("");
|
try {
|
||||||
setConfirmPassword("");
|
// 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 (
|
return (
|
||||||
@@ -205,12 +225,13 @@ 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"
|
||||||
>
|
>
|
||||||
Update Password
|
{loading ? "Updating..." : "Update Password"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user