feat(auth): add password reset confirmation endpoint and page
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:
parent
1af796ac75
commit
7831d08848
@ -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),
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(¶ms.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();
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
|
||||
258
src/app/auth/confirm-password-reset-page.tsx
Normal file
258
src/app/auth/confirm-password-reset-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user