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:
@@ -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();
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
Reference in New Issue
Block a user