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:
391
oauth/frontend/src/pages/AuthorizePage.tsx
Normal file
391
oauth/frontend/src/pages/AuthorizePage.tsx
Normal 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
|
||||
Reference in New Issue
Block a user