Files
lunarfront-app/packages/admin/src/routes/login.tsx
ryan bc8613bbbc
All checks were successful
CI / ci (pull_request) Successful in 27s
CI / e2e (pull_request) Successful in 1m0s
feat: password reset flow with welcome emails
- 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>
2026-04-05 17:09:23 +00:00

192 lines
7.4 KiB
TypeScript

import { useState, useEffect } from 'react'
import { createFileRoute, useRouter, redirect } from '@tanstack/react-router'
import { useAuthStore } from '@/stores/auth.store'
import { login, forgotPassword } from '@/api/auth'
interface Branding {
name: string | null
hasLogo: boolean
}
export const Route = createFileRoute('/login')({
beforeLoad: () => {
const { token } = useAuthStore.getState()
if (token) {
throw redirect({ to: '/accounts', search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })
}
},
component: LoginPage,
})
function LoginPage() {
const router = useRouter()
const setAuth = useAuthStore((s) => s.setAuth)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [branding, setBranding] = useState<Branding | null>(null)
const [forgotMode, setForgotMode] = useState(false)
const [forgotSent, setForgotSent] = useState(false)
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('')
setLoading(true)
try {
const res = await login(email, password)
setAuth(res.token, res.user)
await router.invalidate()
await router.navigate({ to: '/accounts', search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const }, replace: true })
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed')
} 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>
{forgotMode ? (
forgotSent ? (
<div className="text-center space-y-4">
<p className="text-sm" style={{ color: '#b0bec5' }}>If an account exists with that email, you will receive a password reset link.</p>
<button
onClick={() => { setForgotMode(false); setForgotSent(false); setError('') }}
className="text-xs"
style={{ color: '#6b7a8d' }}
>
Back to sign in
</button>
</div>
) : (
<form onSubmit={async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await forgotPassword(email)
setForgotSent(true)
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong')
} finally {
setLoading(false)
}
}} className="space-y-4">
<p className="text-sm" style={{ color: '#b0bec5' }}>Enter your email and we'll send you a reset link.</p>
<div className="space-y-2">
<label className="text-sm font-medium" style={{ color: '#b0bec5' }}>Email</label>
<input
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(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 ? 'Sending...' : 'Send reset link'}
</button>
<div className="text-center">
<button
type="button"
onClick={() => { setForgotMode(false); setError('') }}
className="text-xs"
style={{ color: '#6b7a8d' }}
>
Back to sign in
</button>
</div>
</form>
)
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium" style={{ color: '#b0bec5' }}>Email</label>
<input
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
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' }}>Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(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 ? 'Signing in...' : 'Sign in'}
</button>
<div className="text-center">
<button
type="button"
onClick={() => { setForgotMode(true); setError('') }}
className="text-xs"
style={{ color: '#6b7a8d' }}
>
Forgot password?
</button>
</div>
</form>
)}
</div>
</div>
)
}