feat: 회원가입 페이지 추가

- 모던한 디자인의 회원가입 페이지 구현
- 비밀번호 강도 표시기 추가
- 실시간 입력 검증
- /signup 라우트 추가

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Claude
2025-08-31 11:10:10 +09:00
parent b773ef1b3c
commit 18fe4df9ef
4 changed files with 405 additions and 0 deletions

View File

@ -1,6 +1,7 @@
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom' import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import LoginPage from './pages/LoginPage' import LoginPage from './pages/LoginPage'
import SignupPage from './pages/SignupPage'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
import Applications from './pages/Applications' import Applications from './pages/Applications'
import Profile from './pages/Profile' import Profile from './pages/Profile'
@ -26,6 +27,7 @@ function App() {
<Router> <Router>
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignupPage />} />
<Route path="/auth/callback" element={<AuthCallback />} /> <Route path="/auth/callback" element={<AuthCallback />} />
<Route <Route
path="/dashboard" path="/dashboard"

View File

@ -0,0 +1,403 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Eye,
EyeOff,
Loader2,
Shield,
Lock,
Mail,
ArrowRight,
User,
Building,
Check
} from 'lucide-react'
const SignupPage = () => {
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
confirmPassword: '',
organization: '',
agreeTerms: false
})
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [focusedInput, setFocusedInput] = useState<string | null>(null)
const [passwordStrength, setPasswordStrength] = useState(0)
const navigate = useNavigate()
const checkPasswordStrength = (password: string) => {
let strength = 0
if (password.length >= 8) strength++
if (password.match(/[a-z]/) && password.match(/[A-Z]/)) strength++
if (password.match(/[0-9]/)) strength++
if (password.match(/[^a-zA-Z0-9]/)) strength++
setPasswordStrength(strength)
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target
const newValue = type === 'checkbox' ? checked : value
setFormData(prev => ({
...prev,
[name]: newValue
}))
if (name === 'password') {
checkPasswordStrength(value)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (formData.password !== formData.confirmPassword) {
alert('비밀번호가 일치하지 않습니다.')
return
}
setIsLoading(true)
try {
const response = await fetch('/api/v1/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: formData.name,
email: formData.email,
password: formData.password,
organization: formData.organization
}),
})
if (response.ok) {
navigate('/login')
}
} catch (error) {
console.error('Signup error:', error)
} finally {
setIsLoading(false)
}
}
const getPasswordStrengthColor = () => {
switch(passwordStrength) {
case 0: return 'bg-gray-400'
case 1: return 'bg-red-500'
case 2: return 'bg-yellow-500'
case 3: return 'bg-blue-500'
case 4: return 'bg-green-500'
default: return 'bg-gray-400'
}
}
const getPasswordStrengthText = () => {
switch(passwordStrength) {
case 0: return ''
case 1: return '약함'
case 2: return '보통'
case 3: return '강함'
case 4: return '매우 강함'
default: return ''
}
}
return (
<div className="min-h-screen flex relative overflow-hidden bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
{/* Animated background elements */}
<div className="absolute inset-0">
<div className="absolute top-20 left-20 w-72 h-72 bg-purple-600 rounded-full mix-blend-multiply filter blur-xl opacity-30 animate-float"></div>
<div className="absolute top-40 right-20 w-72 h-72 bg-pink-600 rounded-full mix-blend-multiply filter blur-xl opacity-30 animate-float" style={{ animationDelay: '2s' }}></div>
<div className="absolute bottom-20 left-1/2 w-72 h-72 bg-blue-600 rounded-full mix-blend-multiply filter blur-xl opacity-30 animate-float" style={{ animationDelay: '4s' }}></div>
</div>
{/* Grid pattern overlay */}
<div className="absolute inset-0 opacity-20"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%239C92AC' fill-opacity='0.05'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`
}}></div>
{/* Main content */}
<div className="relative z-10 w-full flex items-center justify-center p-4">
<div className="w-full max-w-5xl grid lg:grid-cols-2 gap-12 items-center">
{/* Left side - Branding */}
<div className="hidden lg:block text-white space-y-8">
<div className="space-y-4">
<div className="flex items-center space-x-3">
<div className="p-3 bg-white/10 backdrop-blur-xl rounded-2xl">
<Shield className="w-8 h-8 text-white" />
</div>
<h1 className="text-4xl font-bold">
AiMond Authorization
</h1>
</div>
<p className="text-xl text-gray-300">
</p>
</div>
<div className="space-y-6">
<div className="flex items-start space-x-4">
<div className="p-2 bg-green-500/20 rounded-lg flex-shrink-0">
<Check className="w-5 h-5 text-green-400" />
</div>
<div>
<h3 className="font-semibold text-lg mb-1"> </h3>
<p className="text-gray-400 text-sm"> </p>
</div>
</div>
<div className="flex items-start space-x-4">
<div className="p-2 bg-blue-500/20 rounded-lg flex-shrink-0">
<Shield className="w-5 h-5 text-blue-400" />
</div>
<div>
<h3 className="font-semibold text-lg mb-1"> </h3>
<p className="text-gray-400 text-sm"> </p>
</div>
</div>
<div className="flex items-start space-x-4">
<div className="p-2 bg-purple-500/20 rounded-lg flex-shrink-0">
<Building className="w-5 h-5 text-purple-400" />
</div>
<div>
<h3 className="font-semibold text-lg mb-1"> </h3>
<p className="text-gray-400 text-sm"> </p>
</div>
</div>
</div>
</div>
{/* Right side - Signup Form */}
<div className="w-full max-w-md mx-auto">
<div className="bg-white/10 backdrop-blur-2xl rounded-3xl p-8 shadow-2xl border border-white/20">
{/* Form Header */}
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-white mb-2">Create Account</h2>
<p className="text-gray-300"> </p>
</div>
{/* Signup Form */}
<form onSubmit={handleSubmit} className="space-y-5">
{/* Name Input */}
<div className="space-y-2">
<label htmlFor="name" className="text-sm font-medium text-gray-300 flex items-center space-x-2">
<User size={16} />
<span></span>
</label>
<div className="relative">
<input
id="name"
name="name"
type="text"
required
value={formData.name}
onChange={handleInputChange}
onFocus={() => setFocusedInput('name')}
onBlur={() => setFocusedInput(null)}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:border-purple-400 focus:bg-white/10 transition-all duration-300"
placeholder="홍길동"
disabled={isLoading}
/>
<div className={`absolute bottom-0 left-0 h-0.5 bg-gradient-to-r from-purple-400 to-pink-400 transition-all duration-300 ${
focusedInput === 'name' ? 'w-full' : 'w-0'
}`}></div>
</div>
</div>
{/* Email Input */}
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium text-gray-300 flex items-center space-x-2">
<Mail size={16} />
<span> </span>
</label>
<div className="relative">
<input
id="email"
name="email"
type="email"
required
value={formData.email}
onChange={handleInputChange}
onFocus={() => setFocusedInput('email')}
onBlur={() => setFocusedInput(null)}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:border-purple-400 focus:bg-white/10 transition-all duration-300"
placeholder="your@email.com"
disabled={isLoading}
/>
<div className={`absolute bottom-0 left-0 h-0.5 bg-gradient-to-r from-purple-400 to-pink-400 transition-all duration-300 ${
focusedInput === 'email' ? 'w-full' : 'w-0'
}`}></div>
</div>
</div>
{/* Organization Input */}
<div className="space-y-2">
<label htmlFor="organization" className="text-sm font-medium text-gray-300 flex items-center space-x-2">
<Building size={16} />
<span>/ ()</span>
</label>
<div className="relative">
<input
id="organization"
name="organization"
type="text"
value={formData.organization}
onChange={handleInputChange}
onFocus={() => setFocusedInput('organization')}
onBlur={() => setFocusedInput(null)}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:border-purple-400 focus:bg-white/10 transition-all duration-300"
placeholder="AiMond Inc."
disabled={isLoading}
/>
<div className={`absolute bottom-0 left-0 h-0.5 bg-gradient-to-r from-purple-400 to-pink-400 transition-all duration-300 ${
focusedInput === 'organization' ? 'w-full' : 'w-0'
}`}></div>
</div>
</div>
{/* Password Input */}
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium text-gray-300 flex items-center space-x-2">
<Lock size={16} />
<span></span>
</label>
<div className="relative">
<input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
required
value={formData.password}
onChange={handleInputChange}
onFocus={() => setFocusedInput('password')}
onBlur={() => setFocusedInput(null)}
className="w-full px-4 py-3 pr-12 bg-white/5 border border-white/10 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:border-purple-400 focus:bg-white/10 transition-all duration-300"
placeholder="••••••••"
disabled={isLoading}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white transition-colors"
>
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
<div className={`absolute bottom-0 left-0 h-0.5 bg-gradient-to-r from-purple-400 to-pink-400 transition-all duration-300 ${
focusedInput === 'password' ? 'w-full' : 'w-0'
}`}></div>
</div>
{formData.password && (
<div className="flex items-center space-x-2 mt-2">
<div className="flex-1 bg-gray-700 rounded-full h-2">
<div className={`h-full rounded-full transition-all duration-300 ${getPasswordStrengthColor()}`}
style={{ width: `${passwordStrength * 25}%` }}></div>
</div>
<span className="text-xs text-gray-400">{getPasswordStrengthText()}</span>
</div>
)}
</div>
{/* Confirm Password Input */}
<div className="space-y-2">
<label htmlFor="confirmPassword" className="text-sm font-medium text-gray-300 flex items-center space-x-2">
<Lock size={16} />
<span> </span>
</label>
<div className="relative">
<input
id="confirmPassword"
name="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
required
value={formData.confirmPassword}
onChange={handleInputChange}
onFocus={() => setFocusedInput('confirmPassword')}
onBlur={() => setFocusedInput(null)}
className="w-full px-4 py-3 pr-12 bg-white/5 border border-white/10 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:border-purple-400 focus:bg-white/10 transition-all duration-300"
placeholder="••••••••"
disabled={isLoading}
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white transition-colors"
>
{showConfirmPassword ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
<div className={`absolute bottom-0 left-0 h-0.5 bg-gradient-to-r from-purple-400 to-pink-400 transition-all duration-300 ${
focusedInput === 'confirmPassword' ? 'w-full' : 'w-0'
}`}></div>
</div>
</div>
{/* Terms Agreement */}
<div className="flex items-start space-x-2">
<input
type="checkbox"
id="agreeTerms"
name="agreeTerms"
checked={formData.agreeTerms}
onChange={handleInputChange}
className="mt-1 w-4 h-4 bg-white/10 border-white/20 rounded text-purple-500 focus:ring-purple-500 focus:ring-offset-0"
required
/>
<label htmlFor="agreeTerms" className="text-sm text-gray-300">
<a href="#" className="text-purple-400 hover:text-purple-300"> </a> {' '}
<a href="#" className="text-purple-400 hover:text-purple-300"> </a>
</label>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={isLoading || !formData.agreeTerms}
className="w-full relative group overflow-hidden rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 p-[2px] transition-all duration-300 hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed"
>
<div className="relative flex items-center justify-center w-full bg-slate-900 back rounded-[10px] py-3 transition-all duration-300 group-hover:bg-opacity-0">
{isLoading ? (
<>
<Loader2 className="animate-spin mr-2" size={20} />
<span className="font-semibold text-white"> ...</span>
</>
) : (
<>
<span className="font-semibold text-white"> </span>
<ArrowRight className="ml-2 group-hover:translate-x-1 transition-transform" size={20} />
</>
)}
</div>
</button>
</form>
{/* Sign in link */}
<p className="mt-6 text-center text-sm text-gray-400">
?{' '}
<a href="/login" className="font-medium text-purple-400 hover:text-purple-300 transition-colors">
</a>
</p>
</div>
{/* Security Badge */}
<div className="mt-6 flex items-center justify-center space-x-2 text-xs text-gray-400">
<Lock size={12} />
<span>256-bit SSL </span>
</div>
</div>
</div>
</div>
</div>
)
}
export default SignupPage

BIN
ref/image19.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
ref/image20.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB