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",
|
"/password/reset",
|
||||||
actix_web::web::post().to(password::api_user_request_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(
|
.route(
|
||||||
"/2fa/enable",
|
"/2fa/enable",
|
||||||
actix_web::web::post().to(totp::api_2fa_enable),
|
actix_web::web::post().to(totp::api_2fa_enable),
|
||||||
|
|||||||
@ -2,7 +2,7 @@ use crate::ApiResponse;
|
|||||||
use crate::error::ApiError;
|
use crate::error::ApiError;
|
||||||
use actix_web::{HttpResponse, Result, web};
|
use actix_web::{HttpResponse, Result, web};
|
||||||
use service::AppService;
|
use service::AppService;
|
||||||
use service::auth::password::{ChangePasswordParams, ResetPasswordParams};
|
use service::auth::password::{ChangePasswordParams, ConfirmResetPasswordParams, ResetPasswordParams};
|
||||||
use session::Session;
|
use session::Session;
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
@ -51,3 +51,26 @@ pub async fn api_user_request_password_reset(
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(crate::api_success())
|
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::me::api_auth_me,
|
||||||
crate::auth::password::api_user_change_password,
|
crate::auth::password::api_user_change_password,
|
||||||
crate::auth::password::api_user_request_password_reset,
|
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_enable,
|
||||||
crate::auth::totp::api_2fa_verify,
|
crate::auth::totp::api_2fa_verify,
|
||||||
crate::auth::totp::api_2fa_disable,
|
crate::auth::totp::api_2fa_disable,
|
||||||
@ -667,6 +668,7 @@ use utoipa::OpenApi;
|
|||||||
service::auth::login::LoginParams,
|
service::auth::login::LoginParams,
|
||||||
service::auth::register::RegisterParams,
|
service::auth::register::RegisterParams,
|
||||||
service::auth::password::ChangePasswordParams,
|
service::auth::password::ChangePasswordParams,
|
||||||
|
service::auth::password::ConfirmResetPasswordParams,
|
||||||
service::auth::password::ResetPasswordParams,
|
service::auth::password::ResetPasswordParams,
|
||||||
service::auth::captcha::CaptchaQuery,
|
service::auth::captcha::CaptchaQuery,
|
||||||
service::auth::captcha::CaptchaResponse,
|
service::auth::captcha::CaptchaResponse,
|
||||||
|
|||||||
@ -173,6 +173,69 @@ impl AppService {
|
|||||||
format!("rst_{}", token)
|
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> {
|
pub async fn auth_cleanup_expired_reset_tokens(&self) -> Result<u64, AppError> {
|
||||||
let now = chrono::Local::now().naive_local();
|
let now = chrono::Local::now().naive_local();
|
||||||
|
|
||||||
|
|||||||
@ -44,6 +44,9 @@ pub enum AppError {
|
|||||||
WorkspaceInviteExpired,
|
WorkspaceInviteExpired,
|
||||||
WorkspaceInviteAlreadyAccepted,
|
WorkspaceInviteAlreadyAccepted,
|
||||||
Conflict(String),
|
Conflict(String),
|
||||||
|
InvalidResetToken,
|
||||||
|
ResetTokenExpired,
|
||||||
|
ResetTokenUsed,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppError {
|
impl AppError {
|
||||||
@ -92,6 +95,9 @@ impl AppError {
|
|||||||
WorkspaceInviteExpired => 40007,
|
WorkspaceInviteExpired => 40007,
|
||||||
WorkspaceInviteAlreadyAccepted => 40908,
|
WorkspaceInviteAlreadyAccepted => 40908,
|
||||||
Conflict(_) => 40909,
|
Conflict(_) => 40909,
|
||||||
|
InvalidResetToken => 40008,
|
||||||
|
ResetTokenExpired => 40009,
|
||||||
|
ResetTokenUsed => 40010,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,6 +113,9 @@ impl AppError {
|
|||||||
TwoFactorNotEnabled => 400,
|
TwoFactorNotEnabled => 400,
|
||||||
WorkspaceInviteTokenInvalid => 400,
|
WorkspaceInviteTokenInvalid => 400,
|
||||||
WorkspaceInviteExpired => 400,
|
WorkspaceInviteExpired => 400,
|
||||||
|
InvalidResetToken => 400,
|
||||||
|
ResetTokenExpired => 400,
|
||||||
|
ResetTokenUsed => 400,
|
||||||
Unauthorized => 401,
|
Unauthorized => 401,
|
||||||
InvalidTwoFactorCode => 401,
|
InvalidTwoFactorCode => 401,
|
||||||
InvalidPassword => 401,
|
InvalidPassword => 401,
|
||||||
@ -178,6 +187,9 @@ impl AppError {
|
|||||||
WorkspaceInviteExpired => "workspace_invite_expired",
|
WorkspaceInviteExpired => "workspace_invite_expired",
|
||||||
WorkspaceInviteAlreadyAccepted => "workspace_invite_already_accepted",
|
WorkspaceInviteAlreadyAccepted => "workspace_invite_already_accepted",
|
||||||
Conflict(_) => "conflict",
|
Conflict(_) => "conflict",
|
||||||
|
InvalidResetToken => "invalid_reset_token",
|
||||||
|
ResetTokenExpired => "reset_token_expired",
|
||||||
|
ResetTokenUsed => "reset_token_used",
|
||||||
DoMainNotSet => "domain_not_set",
|
DoMainNotSet => "domain_not_set",
|
||||||
TxnError => "transaction_error",
|
TxnError => "transaction_error",
|
||||||
RsaGenerationError => "rsa_generation_error",
|
RsaGenerationError => "rsa_generation_error",
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import {LoginPage} from '@/app/auth/login-page';
|
|||||||
import {RegisterPage} from '@/app/auth/register-page';
|
import {RegisterPage} from '@/app/auth/register-page';
|
||||||
import {VerifyEmailPage} from '@/app/auth/verify-email-page';
|
import {VerifyEmailPage} from '@/app/auth/verify-email-page';
|
||||||
import {PasswordResetPage} from '@/app/auth/password-reset-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 {InitProject} from '@/app/init/project';
|
||||||
import {InitRepository} from '@/app/init/repository';
|
import {InitRepository} from '@/app/init/repository';
|
||||||
import {UserProfile} from '@/app/user/user';
|
import {UserProfile} from '@/app/user/user';
|
||||||
@ -96,6 +97,7 @@ function App() {
|
|||||||
<Route path="login" element={<LoginPage/>}/>
|
<Route path="login" element={<LoginPage/>}/>
|
||||||
<Route path="register" element={<RegisterPage/>}/>
|
<Route path="register" element={<RegisterPage/>}/>
|
||||||
<Route path="password/reset" element={<PasswordResetPage/>}/>
|
<Route path="password/reset" element={<PasswordResetPage/>}/>
|
||||||
|
<Route path="reset-password" element={<ConfirmPasswordResetPage/>}/>
|
||||||
<Route path="verify-email" element={<VerifyEmailPage/>}/>
|
<Route path="verify-email" element={<VerifyEmailPage/>}/>
|
||||||
<Route path="accept-workspace-invite" element={<AcceptWorkspaceInvitePage/>}/>
|
<Route path="accept-workspace-invite" element={<AcceptWorkspaceInvitePage/>}/>
|
||||||
</Route>
|
</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 { LoginPage } from "./login-page";
|
||||||
export { RegisterPage } from "./register-page";
|
export { RegisterPage } from "./register-page";
|
||||||
export { PasswordResetPage } from "./password-reset-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;
|
email: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ConfirmResetPasswordParams = {
|
||||||
|
token: string;
|
||||||
|
new_password: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ReviewCommentCreateRequest = {
|
export type ReviewCommentCreateRequest = {
|
||||||
body: string;
|
body: string;
|
||||||
review?: string | null;
|
review?: string | null;
|
||||||
@ -6323,6 +6328,39 @@ export type ApiUserRequestPasswordResetResponses = {
|
|||||||
|
|
||||||
export type ApiUserRequestPasswordResetResponse = ApiUserRequestPasswordResetResponses[keyof 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 = {
|
export type ApiAuthRegisterData = {
|
||||||
body: RegisterParams;
|
body: RegisterParams;
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user