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.
This commit is contained in:
Adrian Acala
2026-01-18 12:33:25 -08:00
parent 1a52fe80f3
commit 15ac634d15
12 changed files with 370 additions and 32 deletions
+4 -3
View File
@@ -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
View File
@@ -0,0 +1 @@
ALTER TABLE "User" ADD COLUMN "mustResetPassword" BOOLEAN NOT NULL DEFAULT false;
+8 -7
View File
@@ -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 {
+83 -6
View File
@@ -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
+2 -2
View File
@@ -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",
+37 -1
View File
@@ -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<AuthStatus>
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;
}
+127
View File
@@ -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);
});
});
+15 -1
View File
@@ -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();
+1 -1
View File
@@ -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 <Login />;
}
+30 -1
View File
@@ -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<void>;
register: (payload: { username?: string; email?: string; password: string }) => Promise<void>;
bootstrapAdmin: (payload: { username?: string; email?: string; password: string }) => Promise<void>;
changePassword: (payload: { currentPassword: string; newPassword: string }) => Promise<void>;
setRegistrationEnabled: (enabled: boolean) => Promise<void>;
updateUserRole: (identifier: string, role: "ADMIN" | "USER") => Promise<void>;
refreshStatus: () => Promise<void>;
@@ -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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
+62 -10
View File
@@ -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<string | null>(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 = () => {
<p className="mt-2 text-sm text-slate-500 dark:text-neutral-400 font-medium">
{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"}
</p>
</div>
@@ -88,12 +123,27 @@ export const Login: React.FC = () => {
/>
</label>
{isPasswordReset && (
<label className="block text-sm font-semibold text-slate-700 dark:text-neutral-200">
Current Password
<input
type="password"
name="current-password"
autoComplete="current-password"
required
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
className="mt-2 w-full rounded-xl border-2 border-black dark:border-neutral-700 bg-white dark:bg-neutral-800 px-4 py-3 text-base text-slate-900 dark:text-white shadow-[3px_3px_0px_0px_rgba(0,0,0,1)] dark:shadow-[3px_3px_0px_0px_rgba(255,255,255,0.2)] focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</label>
)}
<label className="block text-sm font-semibold text-slate-700 dark:text-neutral-200">
Password
{isPasswordReset ? "New Password" : "Password"}
<input
type="password"
name="password"
autoComplete={showRegister || isBootstrap ? "new-password" : "current-password"}
autoComplete={showRegister || isBootstrap || isPasswordReset ? "new-password" : "current-password"}
required
value={password}
onChange={(event) => setPassword(event.target.value)}
@@ -101,7 +151,7 @@ export const Login: React.FC = () => {
/>
</label>
{(showRegister || isBootstrap) && (
{(showRegister || isBootstrap || isPasswordReset) && (
<label className="block text-sm font-semibold text-slate-700 dark:text-neutral-200">
Confirm Password
<input
@@ -131,6 +181,8 @@ export const Login: React.FC = () => {
<Loader2 className="h-5 w-5 animate-spin" />
) : isBootstrap ? (
"Create Admin"
) : isPasswordReset ? (
"Reset password"
) : showRegister ? (
"Create account"
) : (
@@ -138,7 +190,7 @@ export const Login: React.FC = () => {
)}
</button>
{!isBootstrap && (
{!isBootstrap && !isPasswordReset && (
<button
type="button"
onClick={() => {