From 6d853562a87c9de7cb6cc7a1fd1ee0d3e5c76324 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 Aug 2025 11:13:51 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Google=20OAuth=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=B0=8F=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EC=9A=94=EC=B2=AD=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 심플하고 깔끔한 Google 스타일 로그인 페이지 - 사용자 계정 기억 기능 (프로필 아바타 표시) - OAuth 권한 요청/승인 페이지 구현 - 필수/선택 권한 구분 및 상세 정보 표시 - /oauth/authorize 라우트 추가 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- oauth/frontend/src/App.tsx | 2 + oauth/frontend/src/pages/AuthorizePage.tsx | 391 ++++++++++++++++++++ oauth/frontend/src/pages/LoginPage.tsx | 396 +++++++++------------ 3 files changed, 559 insertions(+), 230 deletions(-) create mode 100644 oauth/frontend/src/pages/AuthorizePage.tsx diff --git a/oauth/frontend/src/App.tsx b/oauth/frontend/src/App.tsx index 60e3e57..9aa1ed4 100644 --- a/oauth/frontend/src/App.tsx +++ b/oauth/frontend/src/App.tsx @@ -7,6 +7,7 @@ import Applications from './pages/Applications' import Profile from './pages/Profile' import AdminPanel from './pages/AdminPanel' import AuthCallback from './pages/AuthCallback' +import AuthorizePage from './pages/AuthorizePage' import { AuthProvider } from './contexts/AuthContext' import ProtectedRoute from './components/ProtectedRoute' import './App.css' @@ -29,6 +30,7 @@ function App() { } /> } /> } /> + } /> { + const [searchParams] = useSearchParams() + const navigate = useNavigate() + const { user } = useAuth() + + const [isLoading, setIsLoading] = useState(false) + const [appInfo, setAppInfo] = useState(null) + const [selectedPermissions, setSelectedPermissions] = useState>(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() + + 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 ( +
+
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+
+ + AiMond +
+
+ {user.email} + +
+
+
+ + {/* Main Content */} +
+
+ {/* App Info Card */} +
+ {/* App Header */} +
+

+ {appInfo.name} +

+

이 앱에서 다음 권한을 요청합니다:

+
+ + {/* Permissions List */} +
+ {permissions.map(permission => { + const Icon = permission.icon + const isSelected = selectedPermissions.has(permission.id) + const isRequired = permission.required + + return ( +
!isRequired && togglePermission(permission.id)} + > +
+ {isRequired ? ( +
+ +
+ ) : ( + togglePermission(permission.id)} + className="w-5 h-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500" + onClick={(e) => e.stopPropagation()} + /> + )} +
+
+ +
+
+
+

+ {permission.name} +

+ {isRequired && ( + + 필수 + + )} +
+

+ {permission.description} +

+
+
+ ) + })} +
+ + {/* Info Box */} +
+
+ +
+

앱 권한 정보

+

이 앱은 선택한 권한에만 접근할 수 있으며, 언제든지 계정 설정에서 권한을 취소할 수 있습니다.

+
+
+
+ + {/* Action Buttons */} +
+ + +
+ + {/* Additional Info */} + + + {showDetails && ( +
+
+ 개발자: + {appInfo.developer} +
+
+ 웹사이트: + + {appInfo.website} + +
+
+ 인증 상태: + + {appInfo.verified ? ( + <> + + 인증됨 + + ) : ( + <> + + 미인증 + + )} + +
+
+ )} +
+ + {/* Privacy Notice */} +
+

+ 계속 진행하면 AiMond의{' '} + 서비스 약관 및{' '} + 개인정보처리방침에 동의하는 것입니다. +

+
+
+
+ + {/* Footer */} + +
+ ) +} + +export default AuthorizePage \ No newline at end of file diff --git a/oauth/frontend/src/pages/LoginPage.tsx b/oauth/frontend/src/pages/LoginPage.tsx index 52e4e08..dc44967 100644 --- a/oauth/frontend/src/pages/LoginPage.tsx +++ b/oauth/frontend/src/pages/LoginPage.tsx @@ -4,14 +4,10 @@ import { useAuth } from '../contexts/AuthContext' import { Eye, EyeOff, - Loader2, - Fingerprint, + Loader2, Shield, - Sparkles, - Lock, - Mail, - ArrowRight, - CheckCircle2 + ChevronDown, + HelpCircle } from 'lucide-react' const LoginPage = () => { @@ -20,7 +16,7 @@ const LoginPage = () => { const [showPassword, setShowPassword] = useState(false) const [isLoading, setIsLoading] = useState(false) const [rememberMe, setRememberMe] = useState(false) - const [focusedInput, setFocusedInput] = useState(null) + const [currentUser, setCurrentUser] = useState(null) const [theme, setTheme] = useState(null) const { login, user } = useAuth() @@ -34,6 +30,12 @@ const LoginPage = () => { // Get theme from query params (for OAuth applications) const params = new URLSearchParams(window.location.search) const appId = params.get('app_id') + const savedEmail = localStorage.getItem('last_user_email') + + if (savedEmail) { + setCurrentUser(savedEmail) + setEmail(savedEmail) + } if (appId) { fetchApplicationTheme(appId) @@ -46,25 +48,21 @@ const LoginPage = () => { if (response.ok) { const themeData = await response.json() setTheme(themeData) - applyTheme(themeData) } } catch (error) { console.error('Failed to fetch theme:', error) } } - const applyTheme = (themeData: any) => { - if (themeData?.primaryColor) { - document.documentElement.style.setProperty('--primary-color', themeData.primaryColor) - } - } - const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setIsLoading(true) try { await login(email, password) + if (rememberMe) { + localStorage.setItem('last_user_email', email) + } } catch (error) { console.error('Login error:', error) } finally { @@ -72,243 +70,181 @@ const LoginPage = () => { } } + const switchAccount = () => { + setCurrentUser(null) + setEmail('') + setPassword('') + localStorage.removeItem('last_user_email') + } + return ( -
- {/* Animated background elements */} -
-
-
-
-
- - {/* Grid pattern overlay */} -
- - {/* Main content */} -
-
- - {/* Left side - Branding */} -
-
-
-
- +
+ {/* Header */} +
+
+ {/* Logo and Title */} +
+
+ {theme?.logo ? ( + Logo + ) : ( +
+ + AiMond
-

- {theme?.title || 'AiMond Authorization'} -

-
-

- 엔터프라이즈급 통합 인증 시스템 -

+ )}
+

+ {currentUser ? `${currentUser}로 로그인` : 'AiMond Account로 로그인'} +

+
-
-
-
- + {/* Login Card */} +
+ {currentUser && ( +
+
+
+ + {currentUser[0].toUpperCase()} + +
+
+

{currentUser}

+ +
+
+ )} + +
+ {!currentUser && (
-

완벽한 보안

-

OAuth 2.0 표준 준수 및 엔드투엔드 암호화

+ setEmail(e.target.value)} + className="w-full px-4 py-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition text-gray-900" + placeholder="이메일" + disabled={isLoading} + /> +
+ )} + +
+
+ setPassword(e.target.value)} + className="w-full px-4 py-3 pr-12 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition text-gray-900" + placeholder="비밀번호를 입력하세요" + disabled={isLoading} + /> +
-
-
- -
-
-

생체 인증 지원

-

Touch ID, Face ID 등 최신 인증 기술 통합

-
+
+ setRememberMe(e.target.checked)} + className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" + /> +
-
-
- -
-
-

실시간 모니터링

-

모든 인증 활동을 실시간으로 추적 및 분석

-
-
-
+ +
- {/* Stats */} -
-
-
99.9%
-
가동률
-
-
-
2FA
-
이중 인증
-
-
-
256bit
-
AES 암호화
-
+
- {/* Right side - Login Form */} -
-
- {/* Form Header */} -
-

Welcome Back

-

보안 인증을 통해 시스템에 접속하세요

-
+ {/* Create Account Link */} + - {/* Login Form */} -
- {/* Email Input */} -
- -
- setEmail(e.target.value)} - 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} - /> -
-
-
+ {/* Footer Text */} +
+

+ 하나의 AiMond 계정으로 모든 AiMond 서비스를 이용하실 수 있습니다 +

+
- {/* Password Input */} -
- -
- setPassword(e.target.value)} - 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} - /> - -
-
-
- - {/* Remember Me & Forgot Password */} -
- - - 비밀번호를 잊으셨나요? - -
- - {/* Submit Button */} - -
- - {/* Divider */} -
-
-
-
-
- 또는 -
-
- - {/* Social Login */} -
- - - -
- - {/* Sign up link */} -

- 아직 계정이 없으신가요?{' '} - - 지금 가입하기 - -

+ {/* Service Icons */} +
+
+ A
- - {/* Security Badge */} -
- - 256-bit SSL 암호화로 보호됨 +
+ M +
+
+ D +
+
+ S +
+
+ C
+ + {/* Footer */} +
) }