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:
riyadhafraa
2026-04-20 16:28:50 +00:00
parent 4451877244
commit b915c01df7
16 changed files with 1356 additions and 5 deletions
@@ -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;
}
+348
View File
@@ -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
*/