Skip to content

Commit 920925f

Browse files
author
secus
committed
feat: add password reset page and simplify frontend URL generation
- Add ResetPasswordPage component with proper validation and UI - Simplify get_frontend_url() to always return production domain https://decenter.run - Add /reset-password route to App.tsx for handling password reset links from email - Remove complex environment detection logic for consistent production URLs
1 parent 837cf0d commit 920925f

3 files changed

Lines changed: 180 additions & 31 deletions

File tree

apps/container-engine-frontend/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { AuthProvider } from './context/AuthContext';
44
import { NotificationProvider } from './context/NotificationContext';
55
import LandingPage from './pages/LandingPage';
66
import AuthPage from './pages/AuthPage';
7+
import ResetPasswordPage from './pages/ResetPasswordPage';
78
import DashboardPage from './pages/DashboardPage';
89
import DeploymentsPage from './pages/DeploymentsPage';
910
import NewDeploymentPage from './pages/NewDeploymentPage';
@@ -30,6 +31,7 @@ function App() {
3031
<Routes>
3132
<Route path="/" element={<LandingPage />} />
3233
<Route path="/auth" element={<AuthPage />} />
34+
<Route path="/reset-password" element={<ResetPasswordPage />} />
3335
<Route path="/features" element={<FeaturesPage />} />
3436
<Route path="/documentation" element={<DocumentationPage />} />
3537
<Route path="/privacy" element={<PrivacyPolicyPage />} />
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import React, { useState, useEffect } from 'react';
2+
import { useSearchParams, useNavigate } from 'react-router-dom';
3+
import { toast } from 'react-toastify';
4+
import api from '../api/api';
5+
6+
const ResetPasswordPage: React.FC = () => {
7+
const [searchParams] = useSearchParams();
8+
const navigate = useNavigate();
9+
const [formData, setFormData] = useState({
10+
newPassword: '',
11+
confirmPassword: '',
12+
});
13+
const [loading, setLoading] = useState(false);
14+
const [isValidToken, setIsValidToken] = useState<boolean | null>(null);
15+
const token = searchParams.get('token');
16+
17+
useEffect(() => {
18+
if (!token) {
19+
toast.error('Invalid reset link');
20+
navigate('/auth');
21+
return;
22+
}
23+
setIsValidToken(true);
24+
}, [token, navigate]);
25+
26+
const handleSubmit = async (e: React.FormEvent) => {
27+
e.preventDefault();
28+
29+
if (!token) {
30+
toast.error('Invalid reset token');
31+
return;
32+
}
33+
34+
if (formData.newPassword !== formData.confirmPassword) {
35+
toast.error('Passwords do not match');
36+
return;
37+
}
38+
39+
if (formData.newPassword.length < 8) {
40+
toast.error('Password must be at least 8 characters long');
41+
return;
42+
}
43+
44+
setLoading(true);
45+
try {
46+
await api.post('/v1/auth/reset-password', {
47+
token,
48+
new_password: formData.newPassword,
49+
confirm_password: formData.confirmPassword,
50+
});
51+
52+
toast.success('Password reset successfully! You can now log in with your new password.');
53+
navigate('/auth');
54+
} catch (error: any) {
55+
const errorMessage = error.response?.data?.message || 'Failed to reset password';
56+
toast.error(errorMessage);
57+
} finally {
58+
setLoading(false);
59+
}
60+
};
61+
62+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
63+
setFormData({
64+
...formData,
65+
[e.target.name]: e.target.value,
66+
});
67+
};
68+
69+
if (isValidToken === null) {
70+
return (
71+
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center">
72+
<div className="text-center">
73+
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
74+
<p className="mt-4 text-gray-600">Validating reset link...</p>
75+
</div>
76+
</div>
77+
);
78+
}
79+
80+
return (
81+
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
82+
<div className="max-w-md w-full space-y-8">
83+
<div className="bg-white rounded-xl shadow-lg p-8">
84+
<div className="text-center mb-8">
85+
<img
86+
src="/open-container-engine-logo.png"
87+
alt="Open Container Engine"
88+
className="h-12 w-auto mx-auto mb-4"
89+
/>
90+
<h2 className="text-3xl font-bold text-gray-900">Reset Password</h2>
91+
<p className="mt-2 text-gray-600">
92+
Enter your new password below
93+
</p>
94+
</div>
95+
96+
<form onSubmit={handleSubmit} className="space-y-6">
97+
<div>
98+
<label htmlFor="newPassword" className="block text-sm font-medium text-gray-700 mb-2">
99+
New Password
100+
</label>
101+
<input
102+
id="newPassword"
103+
name="newPassword"
104+
type="password"
105+
required
106+
value={formData.newPassword}
107+
onChange={handleChange}
108+
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
109+
placeholder="Enter your new password"
110+
minLength={8}
111+
/>
112+
</div>
113+
114+
<div>
115+
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-2">
116+
Confirm Password
117+
</label>
118+
<input
119+
id="confirmPassword"
120+
name="confirmPassword"
121+
type="password"
122+
required
123+
value={formData.confirmPassword}
124+
onChange={handleChange}
125+
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
126+
placeholder="Confirm your new password"
127+
minLength={8}
128+
/>
129+
</div>
130+
131+
<div className="space-y-4">
132+
<button
133+
type="submit"
134+
disabled={loading}
135+
className="w-full bg-blue-600 text-white py-3 px-4 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all duration-200 font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
136+
>
137+
{loading ? (
138+
<>
139+
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
140+
Resetting Password...
141+
</>
142+
) : (
143+
'Reset Password'
144+
)}
145+
</button>
146+
147+
<div className="text-center">
148+
<button
149+
type="button"
150+
onClick={() => navigate('/auth')}
151+
className="text-blue-600 hover:text-blue-800 font-medium transition-colors duration-200"
152+
>
153+
Back to Login
154+
</button>
155+
</div>
156+
</div>
157+
</form>
158+
159+
<div className="mt-6 p-4 bg-blue-50 rounded-lg">
160+
<h4 className="text-sm font-medium text-blue-800 mb-2">Password Requirements:</h4>
161+
<ul className="text-sm text-blue-700 space-y-1">
162+
<li>• At least 8 characters long</li>
163+
<li>• Must match the confirmation password</li>
164+
</ul>
165+
</div>
166+
</div>
167+
</div>
168+
</div>
169+
);
170+
};
171+
172+
export default ResetPasswordPage;

src/handlers/auth.rs

Lines changed: 6 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -20,37 +20,9 @@ use crate::{
2020
error::AppError,
2121
};
2222

23-
// Helper function to get frontend URL from headers or environment
24-
fn get_frontend_url(headers: &HeaderMap) -> String {
25-
// Check if we're in production by looking at production domain env var
26-
if let Ok(production_domain) = std::env::var("PRODUCTION_DOMAIN") {
27-
// Try to get host from request headers
28-
if let Some(host) = headers.get("host") {
29-
if let Ok(host_str) = host.to_str() {
30-
// If host matches production domain (decenter.run), use it
31-
if host_str.contains("decenter.run") {
32-
return production_domain;
33-
}
34-
// If it's localhost or development, use frontend URL
35-
else if host_str.contains("localhost") || host_str.contains("127.0.0.1") {
36-
return std::env::var("FRONTEND_BASE_URL")
37-
.unwrap_or_else(|_| "http://localhost:5173".to_string());
38-
}
39-
// For other domains (staging, etc), use HTTPS
40-
else {
41-
return format!("https://{}", host_str);
42-
}
43-
}
44-
}
45-
}
46-
47-
// Development fallback: try frontend base URL from env
48-
if let Ok(frontend_url) = std::env::var("FRONTEND_BASE_URL") {
49-
return frontend_url;
50-
}
51-
52-
// Final fallback
53-
"http://localhost:5173".to_string()
23+
// Helper function to get frontend URL - always return production domain
24+
fn get_frontend_url(_headers: &HeaderMap) -> String {
25+
"https://decenter.run".to_string()
5426
}
5527

5628
#[utoipa::path(
@@ -465,6 +437,9 @@ pub async fn forgot_password(
465437
let frontend_base_url = get_frontend_url(&headers);
466438
let reset_url = format!("{}/reset-password?token={}", frontend_base_url, reset_token);
467439

440+
// Log for debugging
441+
tracing::info!("Generated password reset URL: {} for user: {}", reset_url, user.email);
442+
468443
// Send password reset email
469444

470445
if let Err(e) = state.email_service.send_password_reset_email(&user.email, &user.username, &reset_token, &reset_url) {

0 commit comments

Comments
 (0)