From 15ac634d15ca533c94893c433cbfcb3f3b6af01b Mon Sep 17 00:00:00 2001 From: Adrian Acala Date: Sun, 18 Jan 2026 12:33:25 -0800 Subject: [PATCH] feat(auth): add password reset functionality and user model update - Introduced a `mustResetPassword` field in the User model to manage password reset requirements. - Enhanced authentication flow to support password changes, including validation and error handling. - Updated frontend components to handle password reset scenarios and integrate with the new API endpoints. - Modified authentication context and hooks to accommodate the new password reset logic. - Adjusted E2E tests to ensure proper coverage for the password reset functionality. --- README.md | 7 +- backend/dev.db | 0 .../migration.sql | 1 + backend/prisma/schema.prisma | 15 ++- backend/src/index.ts | 89 +++++++++++- e2e/playwright.config.ts | 4 +- e2e/tests/helpers/auth.ts | 38 +++++- e2e/tests/password-reset.spec.ts | 127 ++++++++++++++++++ frontend/src/api/index.ts | 16 ++- frontend/src/components/AuthGate.tsx | 2 +- frontend/src/context/AuthContext.tsx | 31 ++++- frontend/src/pages/Login.tsx | 72 ++++++++-- 12 files changed, 370 insertions(+), 32 deletions(-) delete mode 100644 backend/dev.db create mode 100644 backend/prisma/migrations/20260118000100_add_must_reset_password/migration.sql create mode 100644 e2e/tests/password-reset.spec.ts diff --git a/README.md b/README.md index 94e3603..d5ff9f5 100644 --- a/README.md +++ b/README.md @@ -179,9 +179,10 @@ AUTH_COOKIE_NAME=excalidash_auth AUTH_COOKIE_SAMESITE=lax ``` -Once logged in, admins can toggle user registration and grant other admins from -Settings. If no admin credentials are provided, the UI will prompt to create the -first admin account. +Once logged in, admins can manage user registration settings and user roles from +the Settings page. When no admin credentials are provided via environment variables, +an initial admin user is created with a randomly generated password that is logged +to the console on startup. # Development diff --git a/backend/dev.db b/backend/dev.db deleted file mode 100644 index e69de29..0000000 diff --git a/backend/prisma/migrations/20260118000100_add_must_reset_password/migration.sql b/backend/prisma/migrations/20260118000100_add_must_reset_password/migration.sql new file mode 100644 index 0000000..9417cc5 --- /dev/null +++ b/backend/prisma/migrations/20260118000100_add_must_reset_password/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "User" ADD COLUMN "mustResetPassword" BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index f2f0b82..7c67d10 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -42,13 +42,14 @@ model Library { } model User { - id String @id @default(uuid()) - username String? @unique - email String? @unique - passwordHash String - role String @default("USER") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + username String? @unique + email String? @unique + passwordHash String + mustResetPassword Boolean @default(false) + role String @default("USER") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model SystemConfig { diff --git a/backend/src/index.ts b/backend/src/index.ts index d2f6544..8befa86 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -160,6 +160,7 @@ type AuthenticatedUser = { username: string | null; email: string | null; role: string; + mustResetPassword?: boolean; }; const toAuthUser = (user: AuthenticatedUser) => ({ @@ -169,6 +170,11 @@ const toAuthUser = (user: AuthenticatedUser) => ({ role: user.role, }); +const toAuthUserWithResetFlag = (user: AuthenticatedUser & { mustResetPassword: boolean }) => ({ + ...toAuthUser(user), + mustResetPassword: user.mustResetPassword, +}); + const ensureSystemConfig = async () => { await prisma.systemConfig.upsert({ where: { id: DEFAULT_SYSTEM_CONFIG_ID }, @@ -220,6 +226,7 @@ const ensureInitialAdminUser = async () => { username: resolved.username, email: resolved.email, passwordHash, + mustResetPassword: resolved.generatedPassword, role: "ADMIN", }, }); @@ -489,12 +496,14 @@ const authExemptPaths = new Set([ "/auth/bootstrap", "/auth/registration/toggle", "/auth/admins", + "/auth/password", ]); const authNeedsSession = new Set([ "/auth/logout", "/auth/registration/toggle", "/auth/admins", + "/auth/password", ]); const fetchSessionUser = async ( @@ -503,7 +512,7 @@ const fetchSessionUser = async ( if (!session) return null; return prisma.user.findUnique({ where: { id: session.userId }, - select: { id: true, username: true, email: true, role: true }, + select: { id: true, username: true, email: true, role: true, mustResetPassword: true }, }); }; @@ -639,6 +648,15 @@ const authLoginSchema = z.object({ password: z.string().min(1).max(512), }); +const authChangePasswordSchema = z.object({ + currentPassword: z.string().min(1).max(512), + newPassword: z.string().min(1).max(512), +}); + +const authChangePasswordResponse = (user: AuthenticatedUser & { mustResetPassword: boolean }) => ({ + user: toAuthUserWithResetFlag(user), +}); + const authRegisterSchema = z.object({ username: z.string().trim().min(1).max(200).optional(), email: z.string().trim().email().max(200).optional(), @@ -669,7 +687,7 @@ app.get("/auth/status", async (req, res) => { authenticated: Boolean(user), registrationEnabled: Boolean(config?.registrationEnabled), bootstrapRequired: totalUsers === 0, - user: user ? toAuthUser(user) : null, + user: user ? toAuthUserWithResetFlag(user as AuthenticatedUser & { mustResetPassword: boolean }) : null, }); }); @@ -693,6 +711,14 @@ app.post("/auth/login", async (req, res) => { { email: identifier }, ], }, + select: { + id: true, + username: true, + email: true, + role: true, + passwordHash: true, + mustResetPassword: true, + }, }); if (!user || !verifyPassword(password, user.passwordHash)) { @@ -715,7 +741,7 @@ app.post("/auth/login", async (req, res) => { res.setHeader("Cache-Control", "no-store"); return res.json({ authenticated: true, - user: toAuthUser(user), + user: toAuthUserWithResetFlag(user as AuthenticatedUser & { mustResetPassword: boolean }), }); }); @@ -728,6 +754,56 @@ app.post("/auth/logout", (req, res) => { return res.json({ authenticated: false }); }); +app.post("/auth/password", async (req, res) => { + const session = getAuthSessionFromCookie(req.headers.cookie, authConfig); + if (!session) { + return res.status(401).json({ + error: "Unauthorized", + message: "Authentication required", + }); + } + + const parsed = authChangePasswordSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ + error: "Invalid payload", + message: "Current and new passwords are required.", + }); + } + + const { currentPassword, newPassword } = parsed.data; + if (!isPasswordValid(authConfig, newPassword)) { + return res.status(400).json({ + error: "Weak password", + message: `Password must be at least ${authConfig.minPasswordLength} characters.`, + }); + } + + const currentUser = await prisma.user.findUnique({ + where: { id: session.userId }, + select: { id: true, username: true, email: true, role: true, passwordHash: true }, + }); + + if (!currentUser || !verifyPassword(currentPassword, currentUser.passwordHash)) { + return res.status(401).json({ + error: "Unauthorized", + message: "Current password is incorrect.", + }); + } + + const updated = await prisma.user.update({ + where: { id: currentUser.id }, + data: { + passwordHash: hashPassword(newPassword), + mustResetPassword: false, + }, + select: { id: true, username: true, email: true, role: true, mustResetPassword: true }, + }); + + res.setHeader("Cache-Control", "no-store"); + return res.json(authChangePasswordResponse(updated)); +}); + app.post("/auth/register", async (req, res) => { const config = await getSystemConfig(); const existingUsers = await prisma.user.count(); @@ -821,7 +897,7 @@ app.post("/auth/register", async (req, res) => { }); return res.status(201).json({ - user: toAuthUser(user), + user: toAuthUserWithResetFlag(user as AuthenticatedUser & { mustResetPassword: boolean }), }); }); @@ -915,7 +991,7 @@ app.post("/auth/bootstrap", async (req, res) => { res.setHeader("Cache-Control", "no-store"); return res.status(201).json({ authenticated: true, - user: toAuthUser(user), + user: toAuthUserWithResetFlag(user as AuthenticatedUser & { mustResetPassword: boolean }), }); } catch (error) { console.error("Bootstrap failed:", error); @@ -1009,9 +1085,10 @@ app.post("/auth/admins", async (req, res) => { const updated = await prisma.user.update({ where: { id: target.id }, data: { role: parsed.data.role }, + select: { id: true, username: true, email: true, role: true, mustResetPassword: true }, }); - return res.json({ user: toAuthUser(updated) }); + return res.json({ user: toAuthUserWithResetFlag(updated) }); }); const filesFieldSchema = z diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 140338f..4b59c0c 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -109,7 +109,7 @@ export default defineConfig({ { command: "cd ../backend && npx prisma db push && npx ts-node src/index.ts", url: `${BACKEND_URL}/health`, - reuseExistingServer: true, + reuseExistingServer: false, timeout: 120000, stdout: "pipe", stderr: "pipe", @@ -131,7 +131,7 @@ export default defineConfig({ { command: "cd ../frontend && npm run dev -- --host", url: FRONTEND_URL, - reuseExistingServer: true, + reuseExistingServer: false, timeout: 120000, stdout: "pipe", stderr: "pipe", diff --git a/e2e/tests/helpers/auth.ts b/e2e/tests/helpers/auth.ts index 60ecd97..fc55532 100644 --- a/e2e/tests/helpers/auth.ts +++ b/e2e/tests/helpers/auth.ts @@ -7,6 +7,7 @@ type AuthStatus = { enabled: boolean; authenticated: boolean; bootstrapRequired?: boolean; + user?: { mustResetPassword?: boolean } | null; }; const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin"; @@ -43,10 +44,44 @@ const fetchAuthStatus = async (request: APIRequestContext): Promise export const ensureApiAuthenticated = async (request: APIRequestContext) => { const status = await fetchAuthStatus(request); - if (!status.enabled || status.authenticated) { + if (!status.enabled || (status.authenticated && !status.user?.mustResetPassword)) { return; } + const resetIfRequired = async () => { + if (!status.user?.mustResetPassword) return; + + let resetResponse = await request.post(`${BASE_URL}/auth/password`, { + headers: { + "Content-Type": "application/json", + ...(await getCsrfHeaders(request)), + }, + data: { + currentPassword: AUTH_PASSWORD, + newPassword: AUTH_PASSWORD, + }, + }); + + if (!resetResponse.ok() && resetResponse.status() === 403) { + await refreshCsrfToken(request); + resetResponse = await request.post(`${BASE_URL}/auth/password`, { + headers: { + "Content-Type": "application/json", + ...(await getCsrfHeaders(request)), + }, + data: { + currentPassword: AUTH_PASSWORD, + newPassword: AUTH_PASSWORD, + }, + }); + } + + if (!resetResponse.ok()) { + const text = await resetResponse.text(); + throw new Error(`Failed to reset admin password: ${resetResponse.status()} ${text}`); + } + }; + if (status.bootstrapRequired) { let response = await request.post(`${BASE_URL}/auth/bootstrap`, { headers: { @@ -78,6 +113,7 @@ export const ensureApiAuthenticated = async (request: APIRequestContext) => { throw new Error(`Failed to bootstrap test session: ${response.status()} ${text}`); } + await resetIfRequired(); return; } diff --git a/e2e/tests/password-reset.spec.ts b/e2e/tests/password-reset.spec.ts new file mode 100644 index 0000000..d0079ca --- /dev/null +++ b/e2e/tests/password-reset.spec.ts @@ -0,0 +1,127 @@ +import type { Page } from "@playwright/test"; +import { PrismaClient } from "../../backend/src/generated/client"; +import { test, expect } from "./fixtures"; + +const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin"; +const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin123"; +const DATABASE_URL = process.env.DATABASE_URL; + +const ensureLoggedOut = async (page: Page) => { + await page.context().clearCookies(); + await page.goto("/"); + const signInPrompt = page.getByText("Sign in to access your drawings"); + if (await signInPrompt.isVisible().catch(() => false)) { + return; + } + await page.goto("/settings"); + const logoutButton = page.getByRole("button", { name: /Log out/i }); + if (await logoutButton.isVisible().catch(() => false)) { + await logoutButton.click(); + } + await expect(signInPrompt).toBeVisible(); +}; + +const login = async (page: Page, password: string) => { + await page.getByLabel("Username or Email").fill(AUTH_USERNAME); + await page.getByLabel("Password").fill(password); + await page.getByRole("button", { name: "Sign in" }).click(); +}; + +const waitForResetOrDashboard = async (page: Page) => { + const resetPrompt = page.getByText("Reset the admin password"); + const dashboardReady = page.getByPlaceholder("Search drawings..."); + const settingsHeader = page.getByRole("heading", { name: "Settings" }); + + await Promise.race([ + resetPrompt.waitFor({ state: "visible", timeout: 15000 }).catch(() => null), + dashboardReady.waitFor({ state: "visible", timeout: 15000 }).catch(() => null), + settingsHeader.waitFor({ state: "visible", timeout: 15000 }).catch(() => null), + ]); + + if (await resetPrompt.isVisible().catch(() => false)) { + return "reset" as const; + } + + if (await dashboardReady.isVisible().catch(() => false)) { + return "dashboard" as const; + } + + if (await settingsHeader.isVisible().catch(() => false)) { + return "settings" as const; + } + + return "unknown" as const; +}; + +const ensureDashboard = async (page: Page) => { + await expect(page.getByPlaceholder("Search drawings...")).toBeVisible({ timeout: 30000 }); +}; + +const setMustResetPassword = async (enabled: boolean) => { + if (!DATABASE_URL) { + throw new Error("DATABASE_URL is not set for e2e test."); + } + + const prisma = new PrismaClient({ + datasources: { + db: { url: DATABASE_URL }, + }, + }); + + try { + const admin = await prisma.user.findFirst({ + where: { username: AUTH_USERNAME }, + select: { id: true }, + }); + + if (!admin) { + throw new Error(`Admin user ${AUTH_USERNAME} not found.`); + } + + await prisma.user.update({ + where: { id: admin.id }, + data: { mustResetPassword: enabled }, + }); + } finally { + await prisma.$disconnect(); + } +}; + +test.describe("Admin password reset", () => { + test.use({ skipAuth: true }); + + test("prompts and clears reset requirement for generated admin password", async ({ page }) => { + await ensureLoggedOut(page); + + await login(page, AUTH_PASSWORD); + let initialState = await waitForResetOrDashboard(page); + if (initialState === "settings") { + await page.goto("/"); + initialState = await waitForResetOrDashboard(page); + } + if (initialState === "reset") { + await page.getByLabel("Current Password").fill(AUTH_PASSWORD); + await page.getByLabel("New Password").fill(AUTH_PASSWORD); + await page.getByLabel("Confirm Password").fill(AUTH_PASSWORD); + await page.getByRole("button", { name: "Reset password" }).click(); + await expect(page.getByPlaceholder("Search drawings...")).toBeVisible({ timeout: 30000 }); + } + + await setMustResetPassword(true); + await ensureLoggedOut(page); + + await login(page, AUTH_PASSWORD); + await expect(page.getByText("Reset the admin password")).toBeVisible({ timeout: 30000 }); + await page.getByLabel("Current Password").fill(AUTH_PASSWORD); + await page.getByLabel("New Password").fill(AUTH_PASSWORD); + await page.getByLabel("Confirm Password").fill(AUTH_PASSWORD); + await page.getByRole("button", { name: "Reset password" }).click(); + + await page.goto("/"); + await ensureDashboard(page); + + await ensureLoggedOut(page); + await login(page, AUTH_PASSWORD); + await ensureDashboard(page); + }); +}); diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 577c7b4..b75a7bc 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -13,7 +13,13 @@ export type AuthStatus = { authenticated: boolean; registrationEnabled: boolean; bootstrapRequired: boolean; - user: { id: string; username: string | null; email: string | null; role: "ADMIN" | "USER" } | null; + user: { + id: string; + username: string | null; + email: string | null; + role: "ADMIN" | "USER"; + mustResetPassword?: boolean; + } | null; }; let unauthorizedHandler: (() => void) | null = null; @@ -172,6 +178,14 @@ export const updateUserRole = async (identifier: string, role: "ADMIN" | "USER") return response.data; }; +export const changePassword = async (payload: { + currentPassword: string; + newPassword: string; +}) => { + const response = await api.post<{ user: AuthStatus["user"] }>("/auth/password", payload); + return response.data; +}; + const coerceTimestamp = (value: string | number | Date): number => { if (typeof value === "number") return value; if (value instanceof Date) return value.getTime(); diff --git a/frontend/src/components/AuthGate.tsx b/frontend/src/components/AuthGate.tsx index c24db04..315524a 100644 --- a/frontend/src/components/AuthGate.tsx +++ b/frontend/src/components/AuthGate.tsx @@ -24,7 +24,7 @@ export const AuthGate: React.FC<{ children: React.ReactNode }> = ({ children }) ); } - if (state.enabled && !state.authenticated) { + if (state.enabled && (!state.authenticated || state.user?.mustResetPassword)) { return ; } diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 5fb187e..92992fb 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -14,7 +14,13 @@ type AuthState = { authenticated: boolean; registrationEnabled: boolean; bootstrapRequired: boolean; - user: { id: string; username: string | null; email: string | null; role: "ADMIN" | "USER" } | null; + user: { + id: string; + username: string | null; + email: string | null; + role: "ADMIN" | "USER"; + mustResetPassword?: boolean; + } | null; loading: boolean; statusError: string | null; }; @@ -25,6 +31,7 @@ type AuthContextValue = { logout: () => Promise; register: (payload: { username?: string; email?: string; password: string }) => Promise; bootstrapAdmin: (payload: { username?: string; email?: string; password: string }) => Promise; + changePassword: (payload: { currentPassword: string; newPassword: string }) => Promise; setRegistrationEnabled: (enabled: boolean) => Promise; updateUserRole: (identifier: string, role: "ADMIN" | "USER") => Promise; refreshStatus: () => Promise; @@ -71,6 +78,17 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => } }, []); + useEffect(() => { + const handleVisibilityChange = () => { + if (!document.hidden) { + refreshStatus(); + } + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => document.removeEventListener("visibilitychange", handleVisibilityChange); + }, [refreshStatus]); + useEffect(() => { refreshStatus(); }, [refreshStatus]); @@ -115,6 +133,14 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => [refreshStatus] ); + const changePassword = useCallback( + async (payload: { currentPassword: string; newPassword: string }) => { + await api.changePassword(payload); + await refreshStatus(); + }, + [refreshStatus] + ); + const setRegistrationEnabled = useCallback( async (enabled: boolean) => { await api.setRegistrationEnabled(enabled); @@ -138,6 +164,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => logout, register, bootstrapAdmin, + changePassword, setRegistrationEnabled, updateUserRole, refreshStatus, @@ -148,12 +175,14 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => logout, register, bootstrapAdmin, + changePassword, setRegistrationEnabled, updateUserRole, refreshStatus, ] ); + return {children}; }; diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index e2e9213..f1e0633 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,19 +1,32 @@ -import React, { useState } from "react"; +import { useEffect, useState, type FormEvent } from "react"; import { Loader2 } from "lucide-react"; import { Logo } from "../components/Logo"; import { useAuth } from "../context/AuthContext"; export const Login: React.FC = () => { - const { state, login, register, bootstrapAdmin } = useAuth(); + const { state, login, register, bootstrapAdmin, changePassword } = useAuth(); const [identifier, setIdentifier] = useState(""); + const [currentPassword, setCurrentPassword] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [showRegister, setShowRegister] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); + const mustResetPassword = Boolean(state.user?.mustResetPassword); const isBootstrap = state.bootstrapRequired; const canRegister = state.registrationEnabled; + const isPasswordReset = !isBootstrap && mustResetPassword; + + useEffect(() => { + if (!isPasswordReset) return; + setIdentifier(state.user?.username || state.user?.email || ""); + setCurrentPassword(""); + setPassword(""); + setConfirmPassword(""); + setShowRegister(false); + setError(null); + }, [isPasswordReset, state.user]); const parseIdentifier = () => { const trimmed = identifier.trim(); @@ -24,12 +37,32 @@ export const Login: React.FC = () => { return { username: trimmed, email: "" }; }; - const handleSubmit = async (event: React.FormEvent) => { + const handleSubmit = async (event: FormEvent) => { event.preventDefault(); setError(null); setIsSubmitting(true); try { + if (isPasswordReset) { + if (password !== confirmPassword) { + setError("Passwords do not match."); + return; + } + if (!currentPassword.trim()) { + setError("Enter your current password."); + return; + } + if (password.length === 0) { + setError("Enter a new password."); + return; + } + await changePassword({ currentPassword: currentPassword.trim(), newPassword: confirmPassword }); + setCurrentPassword(""); + setPassword(""); + setConfirmPassword(""); + return; + } + if (showRegister || isBootstrap) { if (password !== confirmPassword) { setError("Passwords do not match."); @@ -68,9 +101,11 @@ export const Login: React.FC = () => {

{isBootstrap ? "Create the initial admin account" - : showRegister - ? "Create a new account" - : "Sign in to access your drawings"} + : isPasswordReset + ? "Reset the admin password" + : showRegister + ? "Create a new account" + : "Sign in to access your drawings"}

@@ -88,12 +123,27 @@ export const Login: React.FC = () => { /> + {isPasswordReset && ( + + )} + - {(showRegister || isBootstrap) && ( + {(showRegister || isBootstrap || isPasswordReset) && (