Add forgot-password flow with admin-mediated reset links
Task #18: self-service "Forgot password?" flow on the sign-in page, plus an admin-mediated delivery path so tokens actually reach users without email infrastructure. Changes: - New password_reset_tokens table (SHA-256 hashed token, 1h TTL, single-use) added via schema + raw SQL. - Public endpoints: POST /auth/forgot-password (identical response for valid/invalid identifiers, no account enumeration), POST /auth/reset-password/verify, POST /auth/reset-password. Raw tokens are never returned or logged — only id + expiry. - Admin-only endpoint: POST /auth/admin/users/:id/issue-reset-link returns a one-time reset URL (origin + hex token) so admins can share it with the user out-of-band until email delivery lands. - Frontend: "Forgot password?" link on login, new /forgot-password and /reset-password pages, admin Users list gets a KeyRound button opening a modal with the generated URL and a Copy button. Public routes /forgot-password and /reset-password registered in AuthContext. - Bilingual EN/AR copy for all new screens and admin modal. Verification: full end-to-end test passed — admin generated link, user reset password via link, logged in with new password, and reused token was rejected as invalid (single-use enforced). Follow-ups proposed: #25 transactional email delivery, #26 rate limiting on the public reset endpoints. Replit-Task-Id: e7628acb-8901-4b62-a7ee-a1149d9e993f
This commit is contained in:
@@ -33,6 +33,34 @@ export interface LoginBody {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface ForgotPasswordBody {
|
||||
/** Username or email */
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
export interface ForgotPasswordResponse {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface ResetPasswordBody {
|
||||
token: string;
|
||||
/** @minLength 6 */
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
export interface VerifyResetTokenBody {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface AdminResetLinkResponse {
|
||||
resetUrl: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface VerifyResetTokenResponse {
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateLanguageBody {
|
||||
language: string;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import type {
|
||||
AdminResetLinkResponse,
|
||||
AdminStats,
|
||||
App,
|
||||
AppSettings,
|
||||
@@ -26,6 +27,8 @@ import type {
|
||||
CreateConversationBody,
|
||||
CreateServiceBody,
|
||||
ErrorResponse,
|
||||
ForgotPasswordBody,
|
||||
ForgotPasswordResponse,
|
||||
GetAdminStatsParams,
|
||||
HealthStatus,
|
||||
HomeStats,
|
||||
@@ -35,6 +38,7 @@ import type {
|
||||
RegisterBody,
|
||||
RequestUploadUrlBody,
|
||||
RequestUploadUrlResponse,
|
||||
ResetPasswordBody,
|
||||
SendMessageBody,
|
||||
Service,
|
||||
ServiceCategory,
|
||||
@@ -46,6 +50,8 @@ import type {
|
||||
UpdateServiceBody,
|
||||
UpdateUserBody,
|
||||
UserProfile,
|
||||
VerifyResetTokenBody,
|
||||
VerifyResetTokenResponse,
|
||||
} from "./api.schemas";
|
||||
|
||||
import { customFetch } from "../custom-fetch";
|
||||
@@ -385,6 +391,348 @@ export const useLogout = <
|
||||
return useMutation(getLogoutMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Request a password reset link
|
||||
*/
|
||||
export const getForgotPasswordUrl = () => {
|
||||
return `/api/auth/forgot-password`;
|
||||
};
|
||||
|
||||
export const forgotPassword = async (
|
||||
forgotPasswordBody: ForgotPasswordBody,
|
||||
options?: RequestInit,
|
||||
): Promise<ForgotPasswordResponse> => {
|
||||
return customFetch<ForgotPasswordResponse>(getForgotPasswordUrl(), {
|
||||
...options,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||
body: JSON.stringify(forgotPasswordBody),
|
||||
});
|
||||
};
|
||||
|
||||
export const getForgotPasswordMutationOptions = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof forgotPassword>>,
|
||||
TError,
|
||||
{ data: BodyType<ForgotPasswordBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof forgotPassword>>,
|
||||
TError,
|
||||
{ data: BodyType<ForgotPasswordBody> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["forgotPassword"];
|
||||
const { mutation: mutationOptions, request: requestOptions } = options
|
||||
? options.mutation &&
|
||||
"mutationKey" in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey }, request: undefined };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof forgotPassword>>,
|
||||
{ data: BodyType<ForgotPasswordBody> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return forgotPassword(data, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type ForgotPasswordMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof forgotPassword>>
|
||||
>;
|
||||
export type ForgotPasswordMutationBody = BodyType<ForgotPasswordBody>;
|
||||
export type ForgotPasswordMutationError = ErrorType<ErrorResponse>;
|
||||
|
||||
/**
|
||||
* @summary Request a password reset link
|
||||
*/
|
||||
export const useForgotPassword = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof forgotPassword>>,
|
||||
TError,
|
||||
{ data: BodyType<ForgotPasswordBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof forgotPassword>>,
|
||||
TError,
|
||||
{ data: BodyType<ForgotPasswordBody> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getForgotPasswordMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Set a new password using a reset token
|
||||
*/
|
||||
export const getResetPasswordUrl = () => {
|
||||
return `/api/auth/reset-password`;
|
||||
};
|
||||
|
||||
export const resetPassword = async (
|
||||
resetPasswordBody: ResetPasswordBody,
|
||||
options?: RequestInit,
|
||||
): Promise<SuccessResponse> => {
|
||||
return customFetch<SuccessResponse>(getResetPasswordUrl(), {
|
||||
...options,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||
body: JSON.stringify(resetPasswordBody),
|
||||
});
|
||||
};
|
||||
|
||||
export const getResetPasswordMutationOptions = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof resetPassword>>,
|
||||
TError,
|
||||
{ data: BodyType<ResetPasswordBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof resetPassword>>,
|
||||
TError,
|
||||
{ data: BodyType<ResetPasswordBody> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["resetPassword"];
|
||||
const { mutation: mutationOptions, request: requestOptions } = options
|
||||
? options.mutation &&
|
||||
"mutationKey" in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey }, request: undefined };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof resetPassword>>,
|
||||
{ data: BodyType<ResetPasswordBody> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return resetPassword(data, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type ResetPasswordMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof resetPassword>>
|
||||
>;
|
||||
export type ResetPasswordMutationBody = BodyType<ResetPasswordBody>;
|
||||
export type ResetPasswordMutationError = ErrorType<ErrorResponse>;
|
||||
|
||||
/**
|
||||
* @summary Set a new password using a reset token
|
||||
*/
|
||||
export const useResetPassword = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof resetPassword>>,
|
||||
TError,
|
||||
{ data: BodyType<ResetPasswordBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof resetPassword>>,
|
||||
TError,
|
||||
{ data: BodyType<ResetPasswordBody> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getResetPasswordMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Check whether a reset token is valid
|
||||
*/
|
||||
export const getVerifyResetTokenUrl = () => {
|
||||
return `/api/auth/reset-password/verify`;
|
||||
};
|
||||
|
||||
export const verifyResetToken = async (
|
||||
verifyResetTokenBody: VerifyResetTokenBody,
|
||||
options?: RequestInit,
|
||||
): Promise<VerifyResetTokenResponse> => {
|
||||
return customFetch<VerifyResetTokenResponse>(getVerifyResetTokenUrl(), {
|
||||
...options,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||
body: JSON.stringify(verifyResetTokenBody),
|
||||
});
|
||||
};
|
||||
|
||||
export const getVerifyResetTokenMutationOptions = <
|
||||
TError = ErrorType<unknown>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof verifyResetToken>>,
|
||||
TError,
|
||||
{ data: BodyType<VerifyResetTokenBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof verifyResetToken>>,
|
||||
TError,
|
||||
{ data: BodyType<VerifyResetTokenBody> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["verifyResetToken"];
|
||||
const { mutation: mutationOptions, request: requestOptions } = options
|
||||
? options.mutation &&
|
||||
"mutationKey" in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey }, request: undefined };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof verifyResetToken>>,
|
||||
{ data: BodyType<VerifyResetTokenBody> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return verifyResetToken(data, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type VerifyResetTokenMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof verifyResetToken>>
|
||||
>;
|
||||
export type VerifyResetTokenMutationBody = BodyType<VerifyResetTokenBody>;
|
||||
export type VerifyResetTokenMutationError = ErrorType<unknown>;
|
||||
|
||||
/**
|
||||
* @summary Check whether a reset token is valid
|
||||
*/
|
||||
export const useVerifyResetToken = <
|
||||
TError = ErrorType<unknown>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof verifyResetToken>>,
|
||||
TError,
|
||||
{ data: BodyType<VerifyResetTokenBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof verifyResetToken>>,
|
||||
TError,
|
||||
{ data: BodyType<VerifyResetTokenBody> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getVerifyResetTokenMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Admin generates a one-time password reset link for a user
|
||||
*/
|
||||
export const getAdminIssueResetLinkUrl = (id: number) => {
|
||||
return `/api/auth/admin/users/${id}/issue-reset-link`;
|
||||
};
|
||||
|
||||
export const adminIssueResetLink = async (
|
||||
id: number,
|
||||
options?: RequestInit,
|
||||
): Promise<AdminResetLinkResponse> => {
|
||||
return customFetch<AdminResetLinkResponse>(getAdminIssueResetLinkUrl(id), {
|
||||
...options,
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
export const getAdminIssueResetLinkMutationOptions = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof adminIssueResetLink>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof adminIssueResetLink>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["adminIssueResetLink"];
|
||||
const { mutation: mutationOptions, request: requestOptions } = options
|
||||
? options.mutation &&
|
||||
"mutationKey" in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey }, request: undefined };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof adminIssueResetLink>>,
|
||||
{ id: number }
|
||||
> = (props) => {
|
||||
const { id } = props ?? {};
|
||||
|
||||
return adminIssueResetLink(id, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type AdminIssueResetLinkMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof adminIssueResetLink>>
|
||||
>;
|
||||
|
||||
export type AdminIssueResetLinkMutationError = ErrorType<ErrorResponse>;
|
||||
|
||||
/**
|
||||
* @summary Admin generates a one-time password reset link for a user
|
||||
*/
|
||||
export const useAdminIssueResetLink = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof adminIssueResetLink>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof adminIssueResetLink>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getAdminIssueResetLinkMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Get current user
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user