feat: 회원가입 페이지 추가
- 모던한 디자인의 회원가입 페이지 구현 - 비밀번호 강도 표시기 추가 - 실시간 입력 검증 - /signup 라우트 추가 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@ -1,6 +1,7 @@
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import SignupPage from './pages/SignupPage'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Applications from './pages/Applications'
|
||||
import Profile from './pages/Profile'
|
||||
@ -26,6 +27,7 @@ function App() {
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/signup" element={<SignupPage />} />
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
|
||||
403
oauth/frontend/src/pages/SignupPage.tsx
Normal file
403
oauth/frontend/src/pages/SignupPage.tsx
Normal 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
|
||||
Reference in New Issue
Block a user