feat: Google OAuth 스타일 로그인 및 권한 요청 페이지 구현

- 심플하고 깔끔한 Google 스타일 로그인 페이지
- 사용자 계정 기억 기능 (프로필 아바타 표시)
- OAuth 권한 요청/승인 페이지 구현
- 필수/선택 권한 구분 및 상세 정보 표시
- /oauth/authorize 라우트 추가

🤖 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:13:51 +09:00
parent 18fe4df9ef
commit 6d853562a8
3 changed files with 559 additions and 230 deletions

View File

@ -0,0 +1,391 @@
import { useState, useEffect } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import {
Shield,
ChevronDown,
AlertCircle,
Check,
X,
Info,
Database,
User,
Mail,
Calendar,
FileText,
Settings,
Lock
} from 'lucide-react'
interface Permission {
id: string
name: string
description: string
icon: React.ElementType
required: boolean
}
const AuthorizePage = () => {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const { user } = useAuth()
const [isLoading, setIsLoading] = useState(false)
const [appInfo, setAppInfo] = useState<any>(null)
const [selectedPermissions, setSelectedPermissions] = useState<Set<string>>(new Set())
const [showDetails, setShowDetails] = useState(false)
// OAuth parameters
const clientId = searchParams.get('client_id')
const redirectUri = searchParams.get('redirect_uri')
const responseType = searchParams.get('response_type')
const scope = searchParams.get('scope')
const state = searchParams.get('state')
const permissions: Permission[] = [
{
id: 'profile',
name: '프로필 정보',
description: '이름, 이메일, 프로필 사진 등 기본 정보',
icon: User,
required: true
},
{
id: 'email',
name: '이메일 주소',
description: '이메일 주소 확인 및 알림 전송',
icon: Mail,
required: true
},
{
id: 'offline_access',
name: '오프라인 액세스',
description: '사용자가 오프라인일 때도 액세스 유지',
icon: Database,
required: false
},
{
id: 'calendar',
name: '캘린더 접근',
description: '캘린더 일정 읽기 및 수정',
icon: Calendar,
required: false
},
{
id: 'files',
name: '파일 접근',
description: '파일 읽기, 쓰기 및 공유',
icon: FileText,
required: false
},
{
id: 'settings',
name: '설정 관리',
description: '애플리케이션 설정 읽기 및 수정',
icon: Settings,
required: false
}
]
useEffect(() => {
if (!user) {
// Redirect to login with return URL
const returnUrl = encodeURIComponent(window.location.href)
navigate(`/login?return_url=${returnUrl}`)
return
}
if (!clientId) {
// Invalid request
navigate('/error?type=invalid_request')
return
}
// Fetch application information
fetchAppInfo(clientId)
// Parse requested scopes and set initial permissions
if (scope) {
const requestedScopes = scope.split(' ')
const initialPermissions = new Set<string>()
permissions.forEach(perm => {
if (perm.required || requestedScopes.includes(perm.id)) {
initialPermissions.add(perm.id)
}
})
setSelectedPermissions(initialPermissions)
}
}, [user, clientId, scope, navigate])
const fetchAppInfo = async (clientId: string) => {
try {
const response = await fetch(`/api/v1/applications/${clientId}`)
if (response.ok) {
const data = await response.json()
setAppInfo(data)
}
} catch (error) {
console.error('Failed to fetch app info:', error)
// Use default app info for demo
setAppInfo({
name: 'Project Default Service Account',
developer: 'AiMond Developer',
website: 'https://aimond.io',
verified: true
})
}
}
const handleAccept = async () => {
setIsLoading(true)
try {
// Send authorization request to backend
const response = await fetch('/api/v1/auth/authorize', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
},
body: JSON.stringify({
client_id: clientId,
redirect_uri: redirectUri,
response_type: responseType,
scope: Array.from(selectedPermissions).join(' '),
state: state
})
})
if (response.ok) {
const data = await response.json()
// Redirect back to the application with authorization code
const redirectUrl = new URL(redirectUri || '/')
redirectUrl.searchParams.append('code', data.code)
if (state) {
redirectUrl.searchParams.append('state', state)
}
window.location.href = redirectUrl.toString()
}
} catch (error) {
console.error('Authorization error:', error)
} finally {
setIsLoading(false)
}
}
const handleCancel = () => {
// Redirect back to the application with error
const redirectUrl = new URL(redirectUri || '/')
redirectUrl.searchParams.append('error', 'access_denied')
if (state) {
redirectUrl.searchParams.append('state', state)
}
window.location.href = redirectUrl.toString()
}
const togglePermission = (permId: string) => {
const permission = permissions.find(p => p.id === permId)
if (permission?.required) return // Can't uncheck required permissions
const newPermissions = new Set(selectedPermissions)
if (newPermissions.has(permId)) {
newPermissions.delete(permId)
} else {
newPermissions.add(permId)
}
setSelectedPermissions(newPermissions)
}
if (!user || !appInfo) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
)
}
return (
<div className="min-h-screen flex flex-col bg-gray-50">
{/* Header */}
<header className="bg-white border-b border-gray-200 px-4 py-3">
<div className="max-w-3xl mx-auto flex items-center justify-between">
<div className="flex items-center space-x-3">
<Shield className="w-8 h-8 text-blue-600" />
<span className="text-xl font-semibold text-gray-900">AiMond</span>
</div>
<div className="flex items-center space-x-4 text-sm">
<span className="text-gray-600">{user.email}</span>
<ChevronDown size={16} className="text-gray-400" />
</div>
</div>
</header>
{/* Main Content */}
<div className="flex-1 flex items-center justify-center py-12 px-4">
<div className="max-w-md w-full">
{/* App Info Card */}
<div className="bg-white rounded-lg shadow-md border border-gray-200 p-6">
{/* App Header */}
<div className="text-center mb-6">
<h1 className="text-2xl font-semibold text-gray-900 mb-2">
{appInfo.name}
</h1>
<p className="text-gray-600"> :</p>
</div>
{/* Permissions List */}
<div className="space-y-3 mb-6">
{permissions.map(permission => {
const Icon = permission.icon
const isSelected = selectedPermissions.has(permission.id)
const isRequired = permission.required
return (
<div
key={permission.id}
className={`flex items-start space-x-3 p-3 rounded-lg border ${
isRequired ? 'bg-gray-50 border-gray-200' : 'border-gray-200 cursor-pointer hover:bg-gray-50'
}`}
onClick={() => !isRequired && togglePermission(permission.id)}
>
<div className="flex-shrink-0 mt-0.5">
{isRequired ? (
<div className="w-5 h-5 bg-blue-600 rounded flex items-center justify-center">
<Check size={14} className="text-white" />
</div>
) : (
<input
type="checkbox"
checked={isSelected}
onChange={() => togglePermission(permission.id)}
className="w-5 h-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
onClick={(e) => e.stopPropagation()}
/>
)}
</div>
<div className="flex-shrink-0">
<Icon size={20} className="text-gray-600" />
</div>
<div className="flex-1">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium text-gray-900">
{permission.name}
</p>
{isRequired && (
<span className="text-xs text-gray-500 bg-gray-200 px-2 py-0.5 rounded">
</span>
)}
</div>
<p className="text-xs text-gray-600 mt-0.5">
{permission.description}
</p>
</div>
</div>
)
})}
</div>
{/* Info Box */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-6">
<div className="flex items-start space-x-2">
<Info size={16} className="text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-xs text-blue-800">
<p className="font-medium mb-1"> </p>
<p> , .</p>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex space-x-3">
<button
onClick={handleCancel}
className="flex-1 py-2.5 px-4 border border-gray-300 text-gray-700 font-medium rounded-md hover:bg-gray-50 transition duration-200"
>
</button>
<button
onClick={handleAccept}
disabled={isLoading || selectedPermissions.size === 0}
className="flex-1 py-2.5 px-4 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-md transition duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? '처리 중...' : '허용'}
</button>
</div>
{/* Additional Info */}
<button
onClick={() => setShowDetails(!showDetails)}
className="w-full mt-4 text-center text-xs text-gray-500 hover:text-gray-700"
>
{showDetails ? '상세 정보 숨기기' : '상세 정보 보기'}
</button>
{showDetails && (
<div className="mt-4 pt-4 border-t border-gray-200 space-y-2 text-xs text-gray-600">
<div className="flex justify-between">
<span>:</span>
<span className="font-medium">{appInfo.developer}</span>
</div>
<div className="flex justify-between">
<span>:</span>
<a href={appInfo.website} className="text-blue-600 hover:underline">
{appInfo.website}
</a>
</div>
<div className="flex justify-between">
<span> :</span>
<span className="flex items-center space-x-1">
{appInfo.verified ? (
<>
<Check size={12} className="text-green-600" />
<span className="text-green-600"></span>
</>
) : (
<>
<AlertCircle size={12} className="text-yellow-600" />
<span className="text-yellow-600"></span>
</>
)}
</span>
</div>
</div>
)}
</div>
{/* Privacy Notice */}
<div className="mt-6 text-center">
<p className="text-xs text-gray-500">
AiMond의{' '}
<a href="#" className="text-blue-600 hover:underline"> </a> {' '}
<a href="#" className="text-blue-600 hover:underline"></a> .
</p>
</div>
</div>
</div>
{/* Footer */}
<footer className="py-4 px-4 border-t border-gray-200 bg-white">
<div className="max-w-7xl mx-auto flex items-center justify-between text-xs text-gray-600">
<div className="flex items-center space-x-4">
<span></span>
<ChevronDown size={16} />
</div>
<div className="flex items-center space-x-6">
<a href="#" className="hover:text-gray-900"></a>
<a href="#" className="hover:text-gray-900"></a>
<a href="#" className="hover:text-gray-900"></a>
</div>
</div>
</footer>
</div>
)
}
export default AuthorizePage