feat(auth): add password reset confirmation endpoint and page
Some checks are pending
CI / Rust Lint & Check (push) Waiting to run
CI / Rust Tests (push) Waiting to run
CI / Frontend Lint & Type Check (push) Waiting to run
CI / Frontend Build (push) Blocked by required conditions

Add the second half of the password reset flow: /password/confirm API
endpoint with token validation, transactional password update, and a
frontend confirm-password-reset-page with proper error handling for
expired/used tokens. Updates generated SDK/client bindings.
This commit is contained in:
ZhenYi 2026-04-18 23:02:24 +08:00
parent 1af796ac75
commit 7831d08848
11 changed files with 417 additions and 4 deletions

View File

@ -33,6 +33,10 @@ pub fn init_auth_routes(cfg: &mut actix_web::web::ServiceConfig) {
"/password/reset",
actix_web::web::post().to(password::api_user_request_password_reset),
)
.route(
"/password/confirm",
actix_web::web::post().to(password::api_user_confirm_password_reset),
)
.route(
"/2fa/enable",
actix_web::web::post().to(totp::api_2fa_enable),

View File

@ -2,7 +2,7 @@ use crate::ApiResponse;
use crate::error::ApiError;
use actix_web::{HttpResponse, Result, web};
use service::AppService;
use service::auth::password::{ChangePasswordParams, ResetPasswordParams};
use service::auth::password::{ChangePasswordParams, ConfirmResetPasswordParams, ResetPasswordParams};
use session::Session;
#[utoipa::path(
@ -51,3 +51,26 @@ pub async fn api_user_request_password_reset(
.await?;
Ok(crate::api_success())
}
#[utoipa::path(
post,
path = "/api/auth/password/confirm",
request_body = ConfirmResetPasswordParams,
responses(
(status = 200, description = "Password reset confirmed", body = ApiResponse<String>),
(status = 400, description = "Invalid or expired token", body = ApiResponse<ApiError>),
(status = 404, description = "User not found", body = ApiResponse<ApiError>),
(status = 500, description = "Internal server error", body = ApiResponse<ApiError>),
),
tag = "Auth"
)]
pub async fn api_user_confirm_password_reset(
service: web::Data<AppService>,
_session: Session,
params: web::Json<ConfirmResetPasswordParams>,
) -> Result<HttpResponse, ApiError> {
service
.auth_confirm_password_reset(params.into_inner())
.await?;
Ok(crate::api_success())
}

View File

@ -22,6 +22,7 @@ use utoipa::OpenApi;
crate::auth::me::api_auth_me,
crate::auth::password::api_user_change_password,
crate::auth::password::api_user_request_password_reset,
crate::auth::password::api_user_confirm_password_reset,
crate::auth::totp::api_2fa_enable,
crate::auth::totp::api_2fa_verify,
crate::auth::totp::api_2fa_disable,
@ -667,6 +668,7 @@ use utoipa::OpenApi;
service::auth::login::LoginParams,
service::auth::register::RegisterParams,
service::auth::password::ChangePasswordParams,
service::auth::password::ConfirmResetPasswordParams,
service::auth::password::ResetPasswordParams,
service::auth::captcha::CaptchaQuery,
service::auth::captcha::CaptchaResponse,

View File

@ -173,6 +173,69 @@ impl AppService {
format!("rst_{}", token)
}
pub async fn auth_confirm_password_reset(
&self,
params: ConfirmResetPasswordParams,
) -> Result<(), AppError> {
let reset_token = user_password_reset::Entity::find()
.filter(user_password_reset::Column::Token.eq(params.token.clone()))
.one(&self.db)
.await
.map_err(|_| AppError::InvalidResetToken)?
.ok_or(AppError::InvalidResetToken)?;
if reset_token.used {
return Err(AppError::ResetTokenUsed);
}
let now = chrono::Utc::now();
if reset_token.expires_at < now {
return Err(AppError::ResetTokenExpired);
}
Self::validate_password_strength(&params.new_password)?;
let user_password = user_password::Entity::find_by_id(reset_token.user_uid)
.one(&self.db)
.await
.map_err(|_| AppError::DatabaseError("query failed".to_string()))?
.ok_or(AppError::UserNotFound)?;
let salt = SaltString::generate(&mut rsa::rand_core::OsRng::default());
let new_password_hash = Argon2::default()
.hash_password(params.new_password.as_bytes(), Salt::from_b64(&*salt.to_string())?)
.map_err(|_| AppError::PasswordHashError("hash failed".to_string()))?
.to_string();
let txn = self.db.begin().await.map_err(|_| AppError::TxnError)?;
let mut active_password: user_password::ActiveModel = user_password.into();
active_password.password_hash = Set(new_password_hash);
active_password.password_salt = Set(Some(salt.to_string()));
active_password.update(&txn).await.map_err(|_| AppError::TxnError)?;
let mut used_token: user_password_reset::ActiveModel = reset_token.clone().into();
used_token.used = Set(true);
used_token.update(&txn).await.map_err(|_| AppError::TxnError)?;
let _ = user_activity_log::ActiveModel {
user_uid: Set(Some(reset_token.user_uid)),
action: Set("password_reset".to_string()),
ip_address: Set(None),
user_agent: Set(None),
details: Set(serde_json::json!({"method": "reset_password"})),
created_at: Set(chrono::Utc::now()),
..Default::default()
}
.insert(&txn)
.await;
txn.commit().await.map_err(|_| AppError::TxnError)?;
slog::info!(self.logs, "Password reset confirmed"; "user_uid" => %reset_token.user_uid);
Ok(())
}
pub async fn auth_cleanup_expired_reset_tokens(&self) -> Result<u64, AppError> {
let now = chrono::Local::now().naive_local();

View File

@ -44,6 +44,9 @@ pub enum AppError {
WorkspaceInviteExpired,
WorkspaceInviteAlreadyAccepted,
Conflict(String),
InvalidResetToken,
ResetTokenExpired,
ResetTokenUsed,
}
impl AppError {
@ -92,6 +95,9 @@ impl AppError {
WorkspaceInviteExpired => 40007,
WorkspaceInviteAlreadyAccepted => 40908,
Conflict(_) => 40909,
InvalidResetToken => 40008,
ResetTokenExpired => 40009,
ResetTokenUsed => 40010,
}
}
@ -107,6 +113,9 @@ impl AppError {
TwoFactorNotEnabled => 400,
WorkspaceInviteTokenInvalid => 400,
WorkspaceInviteExpired => 400,
InvalidResetToken => 400,
ResetTokenExpired => 400,
ResetTokenUsed => 400,
Unauthorized => 401,
InvalidTwoFactorCode => 401,
InvalidPassword => 401,
@ -178,6 +187,9 @@ impl AppError {
WorkspaceInviteExpired => "workspace_invite_expired",
WorkspaceInviteAlreadyAccepted => "workspace_invite_already_accepted",
Conflict(_) => "conflict",
InvalidResetToken => "invalid_reset_token",
ResetTokenExpired => "reset_token_expired",
ResetTokenUsed => "reset_token_used",
DoMainNotSet => "domain_not_set",
TxnError => "transaction_error",
RsaGenerationError => "rsa_generation_error",

View File

@ -3,6 +3,7 @@ import {LoginPage} from '@/app/auth/login-page';
import {RegisterPage} from '@/app/auth/register-page';
import {VerifyEmailPage} from '@/app/auth/verify-email-page';
import {PasswordResetPage} from '@/app/auth/password-reset-page';
import {ConfirmPasswordResetPage} from '@/app/auth/confirm-password-reset-page';
import {InitProject} from '@/app/init/project';
import {InitRepository} from '@/app/init/repository';
import {UserProfile} from '@/app/user/user';
@ -96,6 +97,7 @@ function App() {
<Route path="login" element={<LoginPage/>}/>
<Route path="register" element={<RegisterPage/>}/>
<Route path="password/reset" element={<PasswordResetPage/>}/>
<Route path="reset-password" element={<ConfirmPasswordResetPage/>}/>
<Route path="verify-email" element={<VerifyEmailPage/>}/>
<Route path="accept-workspace-invite" element={<AcceptWorkspaceInvitePage/>}/>
</Route>

View File

@ -0,0 +1,258 @@
import {useState, useEffect} from "react";
import {Link, useSearchParams} from "react-router-dom";
import {ArrowLeft, CheckCircle2, Eye, EyeOff, Loader2, ShieldAlert} from "lucide-react";
import {toast} from "sonner";
import {apiUserConfirmPasswordReset} from "@/client";
import {getApiErrorMessage} from "@/lib/api-error";
import {AuthLayout} from "@/components/auth/auth-layout";
import {Button} from "@/components/ui/button";
import {Input} from "@/components/ui/input";
import {AnimatePresence, motion} from "framer-motion";
export function ConfirmPasswordResetPage() {
const [searchParams] = useSearchParams();
const token = searchParams.get("token") ?? "";
const [form, setForm] = useState({new_password: "", confirm_password: ""});
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [done, setDone] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!token) {
setError("Missing reset token. Please use the link from your email.");
}
}, [token]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!form.new_password) {
setError("New password is required.");
return;
}
if (form.new_password.length < 8) {
setError("Password must be at least 8 characters.");
return;
}
if (!/[A-Z]/.test(form.new_password)) {
setError("Password must contain at least one uppercase letter.");
return;
}
if (!/[a-z]/.test(form.new_password)) {
setError("Password must contain at least one lowercase letter.");
return;
}
if (!/[0-9]/.test(form.new_password)) {
setError("Password must contain at least one digit.");
return;
}
if (form.new_password !== form.confirm_password) {
setError("Passwords do not match.");
return;
}
setIsLoading(true);
try {
const resp = await apiUserConfirmPasswordReset({
body: {token, new_password: form.new_password},
});
if (resp.data?.code !== 0) {
throw new Error(resp.data?.message || "Failed to reset password.");
}
setDone(true);
toast.success("Password reset successful!");
} catch (err: unknown) {
const msg = getApiErrorMessage(err, "Failed to reset password. The link may have expired.");
setError(msg);
toast.error(msg);
} finally {
setIsLoading(false);
}
};
return (
<AuthLayout>
<motion.div
initial={{opacity: 0, y: 10}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.4}}
className="w-full max-w-[380px] mx-auto"
>
<AnimatePresence mode="wait">
{!done ? (
<motion.div
key="reset-form"
initial={{opacity: 0, x: -10}}
animate={{opacity: 1, x: 0}}
exit={{opacity: 0, x: 10}}
>
{/* Header */}
<div className="flex flex-col items-center text-center mb-8 space-y-4">
<div
className="h-12 w-12 bg-zinc-900 dark:bg-zinc-100 rounded-2xl flex items-center justify-center shadow-sm">
<ShieldAlert className="h-6 w-6 text-white dark:text-zinc-900"/>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">
Set new password
</h1>
<p className="mt-2 text-sm text-zinc-500 dark:text-zinc-400">
Create a strong password for your account.
</p>
</div>
</div>
{/* Card form */}
<div
className="bg-white dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-800/80 rounded-3xl p-6 md:p-8 shadow-[0_8px_30px_rgb(0,0,0,0.02)]">
<form onSubmit={handleSubmit} className="space-y-5">
{/* Token error (no token in URL) */}
{!token && (
<motion.div
initial={{opacity: 0, scale: 0.98}}
animate={{opacity: 1, scale: 1}}
className="flex items-start gap-2 p-3 text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/20 border border-red-100 dark:border-red-900/30 rounded-xl"
>
<ShieldAlert className="size-4 mt-0.5 shrink-0"/>
<p>Missing reset token. Please use the link from your email.</p>
</motion.div>
)}
{/* New password */}
<div className="space-y-2">
<label htmlFor="new_password"
className="text-sm font-medium text-zinc-700 dark:text-zinc-300 ml-0.5">
New password
</label>
<div className="relative">
<Input
id="new_password"
type={showPassword ? "text" : "password"}
placeholder="At least 8 characters"
value={form.new_password}
onChange={(e) => {
setForm({...form, new_password: e.target.value});
if (error) setError(null);
}}
className="h-10 pr-10 bg-zinc-50/50 dark:bg-zinc-900/50 border-zinc-200 dark:border-zinc-800 rounded-xl focus-visible:ring-1 focus-visible:ring-zinc-400"
disabled={isLoading || !token}
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 transition-colors"
>
{showPassword ? (
<EyeOff className="size-4"/>
) : (
<Eye className="size-4"/>
)}
</button>
</div>
</div>
{/* Confirm password */}
<div className="space-y-2">
<label htmlFor="confirm_password"
className="text-sm font-medium text-zinc-700 dark:text-zinc-300 ml-0.5">
Confirm password
</label>
<Input
id="confirm_password"
type={showPassword ? "text" : "password"}
placeholder="Repeat your password"
value={form.confirm_password}
onChange={(e) => {
setForm({...form, confirm_password: e.target.value});
if (error) setError(null);
}}
className="h-10 bg-zinc-50/50 dark:bg-zinc-900/50 border-zinc-200 dark:border-zinc-800 rounded-xl focus-visible:ring-1 focus-visible:ring-zinc-400"
disabled={isLoading || !token}
/>
</div>
{/* Error message */}
{error && (
<motion.div
initial={{opacity: 0, scale: 0.98}}
animate={{opacity: 1, scale: 1}}
className="flex items-start gap-2 p-3 text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/20 border border-red-100 dark:border-red-900/30 rounded-xl"
>
<ShieldAlert className="size-4 mt-0.5 shrink-0"/>
<p>{error}</p>
</motion.div>
)}
<Button
type="submit"
disabled={isLoading || !token}
className="w-full h-11 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 hover:bg-zinc-800 dark:hover:bg-white rounded-xl font-bold transition-all group shadow-sm"
>
{isLoading ? (
<Loader2 className="size-4 animate-spin"/>
) : (
"Reset Password"
)}
</Button>
</form>
</div>
{/* Footer link */}
<div className="mt-8 text-center">
<Link
to="/auth/login"
className="inline-flex items-center gap-2 text-sm font-medium text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-200 transition-colors"
>
<ArrowLeft className="size-4"/>
Back to sign in
</Link>
</div>
</motion.div>
) : (
<motion.div
key="done-state"
initial={{opacity: 0, scale: 0.95}}
animate={{opacity: 1, scale: 1}}
className="text-center"
>
<div
className="bg-white dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-800/80 rounded-3xl p-8 md:p-10 shadow-[0_8px_30px_rgb(0,0,0,0.02)]">
<div
className="inline-flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/30 mb-6">
<CheckCircle2 className="h-8 w-8 text-emerald-600 dark:text-emerald-400"/>
</div>
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100 mb-3">
Password reset complete
</h1>
<p className="text-zinc-500 dark:text-zinc-400 mb-8 leading-relaxed">
Your password has been updated. You can now sign in with your new password.
</p>
<Link
to="/auth/login"
className="w-full h-11 flex items-center justify-center rounded-xl font-bold transition-all bg-zinc-900 text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-white text-sm"
>
Sign in
</Link>
</div>
<div className="mt-10 flex items-center justify-center gap-6 opacity-30 grayscale">
<div
className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-zinc-500">
<CheckCircle2 className="size-3"/> Password Updated
</div>
<div
className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-zinc-500">
<CheckCircle2 className="size-3"/> Secure Connection
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</AuthLayout>
);
}

View File

@ -1,3 +1,4 @@
export { LoginPage } from "./login-page";
export { RegisterPage } from "./register-page";
export { PasswordResetPage } from "./password-reset-page";
export { ConfirmPasswordResetPage } from "./confirm-password-reset-page";

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -4205,6 +4205,11 @@ export type ResetPasswordParams = {
email: string;
};
export type ConfirmResetPasswordParams = {
token: string;
new_password: string;
};
export type ReviewCommentCreateRequest = {
body: string;
review?: string | null;
@ -6323,6 +6328,39 @@ export type ApiUserRequestPasswordResetResponses = {
export type ApiUserRequestPasswordResetResponse = ApiUserRequestPasswordResetResponses[keyof ApiUserRequestPasswordResetResponses];
export type ApiUserConfirmPasswordResetData = {
body: ConfirmResetPasswordParams;
path?: never;
query?: never;
url: '/api/auth/password/confirm';
};
export type ApiUserConfirmPasswordResetErrors = {
/**
* Invalid or expired token
*/
400: ApiResponseApiError;
/**
* User not found
*/
404: ApiResponseApiError;
/**
* Internal server error
*/
500: ApiResponseApiError;
};
export type ApiUserConfirmPasswordResetError = ApiUserConfirmPasswordResetErrors[keyof ApiUserConfirmPasswordResetErrors];
export type ApiUserConfirmPasswordResetResponses = {
/**
* Password reset confirmed
*/
200: ApiResponseString;
};
export type ApiUserConfirmPasswordResetResponse = ApiUserConfirmPasswordResetResponses[keyof ApiUserConfirmPasswordResetResponses];
export type ApiAuthRegisterData = {
body: RegisterParams;
path?: never;