- POST /auth/forgot-password with welcome/reset email templates - POST /auth/reset-password with Zod validation, 4-hour tokens - Per-email rate limiting (3/hr) via Valkey, no user enumeration - Login page "Forgot password?" toggle with inline form - /reset-password page for setting new password from email link - Initial user seed sends welcome email instead of requiring password - CLI script for force-resetting passwords via kubectl exec - APP_URL env var in chart, removed INITIAL_USER_PASSWORD Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
155 lines
5.6 KiB
TypeScript
155 lines
5.6 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
|
import { resetPassword } from '@/api/auth'
|
|
|
|
interface Branding {
|
|
name: string | null
|
|
hasLogo: boolean
|
|
}
|
|
|
|
export const Route = createFileRoute('/reset-password')({
|
|
component: ResetPasswordPage,
|
|
})
|
|
|
|
function ResetPasswordPage() {
|
|
const { token } = Route.useSearch() as { token?: string }
|
|
const [password, setPassword] = useState('')
|
|
const [confirmPassword, setConfirmPassword] = useState('')
|
|
const [error, setError] = useState('')
|
|
const [loading, setLoading] = useState(false)
|
|
const [success, setSuccess] = useState(false)
|
|
const [branding, setBranding] = useState<Branding | null>(null)
|
|
|
|
useEffect(() => {
|
|
fetch('/v1/store/branding')
|
|
.then((r) => r.ok ? r.json() : null)
|
|
.then((data) => { if (data) setBranding(data) })
|
|
.catch(() => {})
|
|
}, [])
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
setError('')
|
|
|
|
if (!token) {
|
|
setError('Missing reset token. Please use the link from your email.')
|
|
return
|
|
}
|
|
|
|
if (password !== confirmPassword) {
|
|
setError('Passwords do not match')
|
|
return
|
|
}
|
|
|
|
if (password.length < 12) {
|
|
setError('Password must be at least 12 characters')
|
|
return
|
|
}
|
|
|
|
setLoading(true)
|
|
try {
|
|
await resetPassword(token, password)
|
|
setSuccess(true)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Reset failed. The link may have expired.')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="flex min-h-screen items-center justify-center"
|
|
style={{ background: 'linear-gradient(135deg, #0f1724 0%, #142038 100%)' }}
|
|
>
|
|
<div
|
|
className="w-full max-w-sm rounded-xl border p-8 shadow-2xl"
|
|
style={{ backgroundColor: '#131c2e', borderColor: '#1e2d45' }}
|
|
>
|
|
<div className="text-center mb-8">
|
|
{branding?.hasLogo ? (
|
|
<img src="/v1/store/logo" alt={branding.name ?? 'Store'} className="max-h-14 max-w-[220px] object-contain mx-auto" />
|
|
) : (
|
|
<h1 className="text-3xl font-bold" style={{ color: '#d8dfe9' }}>{branding?.name ?? 'LunarFront'}</h1>
|
|
)}
|
|
{branding?.name ? (
|
|
<p className="text-[10px] mt-2" style={{ color: '#4a5568' }}>Powered by <span style={{ color: '#6b7a8d' }}>LunarFront</span></p>
|
|
) : (
|
|
<p className="text-sm mt-1" style={{ color: '#6b7a8d' }}>Small Business Management</p>
|
|
)}
|
|
</div>
|
|
|
|
{success ? (
|
|
<div className="text-center space-y-4">
|
|
<p style={{ color: '#81c784' }}>Password reset successfully.</p>
|
|
<Link
|
|
to="/login"
|
|
className="inline-block h-9 rounded-md border px-4 py-2 text-sm font-medium transition-colors"
|
|
style={{ color: '#d0d8e0', borderColor: '#3a4a62' }}
|
|
>
|
|
Sign in
|
|
</Link>
|
|
</div>
|
|
) : !token ? (
|
|
<div className="text-center space-y-4">
|
|
<p style={{ color: '#e57373' }}>Invalid reset link. Please request a new one.</p>
|
|
<Link
|
|
to="/login"
|
|
className="inline-block h-9 rounded-md border px-4 py-2 text-sm font-medium transition-colors"
|
|
style={{ color: '#d0d8e0', borderColor: '#3a4a62' }}
|
|
>
|
|
Back to sign in
|
|
</Link>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<h2 className="text-lg font-semibold text-center mb-4" style={{ color: '#d8dfe9' }}>Set new password</h2>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" style={{ color: '#b0bec5' }}>New password</label>
|
|
<input
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
required
|
|
minLength={12}
|
|
placeholder="At least 12 characters"
|
|
className="h-9 w-full rounded-md border px-3 py-1 text-sm outline-none login-input"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" style={{ color: '#b0bec5' }}>Confirm password</label>
|
|
<input
|
|
type="password"
|
|
value={confirmPassword}
|
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
required
|
|
className="h-9 w-full rounded-md border px-3 py-1 text-sm outline-none login-input"
|
|
/>
|
|
</div>
|
|
{error && (
|
|
<p className="text-sm" style={{ color: '#e57373' }}>{error}</p>
|
|
)}
|
|
<button
|
|
type="submit"
|
|
disabled={loading}
|
|
className="h-9 w-full rounded-md border text-sm font-medium transition-colors disabled:opacity-50"
|
|
style={{ backgroundColor: 'transparent', color: '#d0d8e0', borderColor: '#3a4a62' }}
|
|
onMouseEnter={(e) => { (e.target as HTMLElement).style.backgroundColor = '#1e2d45' }}
|
|
onMouseLeave={(e) => { (e.target as HTMLElement).style.backgroundColor = 'transparent' }}
|
|
>
|
|
{loading ? 'Resetting...' : 'Reset password'}
|
|
</button>
|
|
<div className="text-center">
|
|
<Link to="/login" className="text-xs" style={{ color: '#6b7a8d' }}>
|
|
Back to sign in
|
|
</Link>
|
|
</div>
|
|
</form>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|