feat: 전문적이고 모던한 OAuth 로그인 UI 구현

- AiMond Authorization 브랜딩 적용
- 다크모드 기반 글래스모피즘 디자인
- 애니메이션 효과 (플로팅, 그라디언트, 포커스)
- React Router 기반 라우팅 구조
- AuthContext를 통한 인증 상태 관리
- 대시보드 및 관리 페이지 기본 구조
- Backend API 엔드포인트 구조 개선
- pymongo 호환성 문제 수정

🤖 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:07:06 +09:00
parent c03619bdb6
commit b773ef1b3c
20 changed files with 1130 additions and 83 deletions

View File

@ -0,0 +1,25 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import List
from app.core.security import get_current_user, require_admin
from app.models.user import User
router = APIRouter()
@router.get("/users", dependencies=[Depends(require_admin)])
async def get_all_users():
# TODO: Implement get all users logic
return {"users": []}
@router.get("/stats", dependencies=[Depends(require_admin)])
async def get_system_stats():
# TODO: Implement system statistics logic
return {
"total_users": 0,
"total_applications": 0,
"active_sessions": 0
}
@router.post("/users/{user_id}/role", dependencies=[Depends(require_admin)])
async def update_user_role(user_id: str, role: str):
# TODO: Implement role update logic
return {"message": f"User {user_id} role updated to {role}"}

View File

@ -0,0 +1,37 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import List
from app.core.security import get_current_user
from app.models.user import User
from app.models.application import Application
router = APIRouter()
@router.get("/", response_model=List[Application])
async def get_applications(current_user: User = Depends(get_current_user)):
# TODO: Implement application list logic
return []
@router.post("/", response_model=Application)
async def create_application(
app_data: dict,
current_user: User = Depends(get_current_user)
):
# TODO: Implement application creation logic
return {"message": "Application created"}
@router.put("/{app_id}")
async def update_application(
app_id: str,
app_data: dict,
current_user: User = Depends(get_current_user)
):
# TODO: Implement application update logic
return {"message": f"Application {app_id} updated"}
@router.delete("/{app_id}")
async def delete_application(
app_id: str,
current_user: User = Depends(get_current_user)
):
# TODO: Implement application deletion logic
return {"message": f"Application {app_id} deleted"}

View File

@ -0,0 +1,38 @@
from fastapi import APIRouter, HTTPException, Depends, status
from fastapi.security import OAuth2PasswordRequestForm
from app.core.security import create_access_token, get_current_user
from app.models.user import User
from app.core.config import settings
router = APIRouter()
@router.post("/login")
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
# TODO: Implement actual authentication
return {
"access_token": create_access_token({"sub": form_data.username}),
"token_type": "bearer"
}
@router.post("/logout")
async def logout(current_user: User = Depends(get_current_user)):
# TODO: Implement logout logic
return {"message": "Logged out successfully"}
@router.post("/refresh")
async def refresh_token(current_user: User = Depends(get_current_user)):
# TODO: Implement token refresh logic
return {
"access_token": create_access_token({"sub": current_user.email}),
"token_type": "bearer"
}
@router.get("/authorize")
async def authorize():
# TODO: Implement OAuth authorization
return {"message": "Authorization endpoint"}
@router.post("/token")
async def token():
# TODO: Implement OAuth token endpoint
return {"message": "Token endpoint"}

View File

@ -0,0 +1,25 @@
from fastapi import APIRouter, Depends, HTTPException
from app.core.security import get_current_user
from app.models.user import User
router = APIRouter()
@router.get("/me")
async def get_me(current_user: User = Depends(get_current_user)):
return current_user
@router.put("/me")
async def update_me(
user_update: dict,
current_user: User = Depends(get_current_user)
):
# TODO: Implement user update logic
return {"message": "User updated", "user": current_user}
@router.post("/me/password")
async def change_password(
password_data: dict,
current_user: User = Depends(get_current_user)
):
# TODO: Implement password change logic
return {"message": "Password changed successfully"}

View File

@ -0,0 +1,51 @@
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
async def get_current_user(token: str = Depends(oauth2_scheme)):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
# TODO: Get user from database
return {"email": username, "role": "user"}
async def require_admin(current_user = Depends(get_current_user)):
if current_user.get("role") != "system_admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
return current_user

View File

@ -4,6 +4,7 @@ python-multipart==0.0.9
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
motor==3.5.1
pymongo==4.8.0
redis==5.0.7
pydantic==2.9.1
pydantic-settings==2.4.0

View File

@ -14,6 +14,7 @@
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-hook-form": "^7.62.0",
"react-hot-toast": "^2.6.0",
"react-router-dom": "^7.8.2",
"zod": "^4.1.5",
"zustand": "^5.0.8"
@ -2152,7 +2153,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/debug": {
@ -2770,6 +2770,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/goober": {
"version": "2.1.16",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz",
"integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==",
"license": "MIT",
"peerDependencies": {
"csstype": "^3.0.10"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@ -3389,6 +3398,23 @@
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-hot-toast": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
"integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
"license": "MIT",
"dependencies": {
"csstype": "^3.1.3",
"goober": "^2.1.16"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",

View File

@ -16,6 +16,7 @@
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-hook-form": "^7.62.0",
"react-hot-toast": "^2.6.0",
"react-router-dom": "^7.8.2",
"zod": "^4.1.5",
"zustand": "^5.0.8"

View File

@ -1,35 +1,70 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import LoginPage from './pages/LoginPage'
import Dashboard from './pages/Dashboard'
import Applications from './pages/Applications'
import Profile from './pages/Profile'
import AdminPanel from './pages/AdminPanel'
import AuthCallback from './pages/AuthCallback'
import { AuthProvider } from './contexts/AuthContext'
import ProtectedRoute from './components/ProtectedRoute'
import './App.css'
function App() {
const [count, setCount] = useState(0)
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
},
},
})
function App() {
return (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<Router>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/applications"
element={
<ProtectedRoute>
<Applications />
</ProtectedRoute>
}
/>
<Route
path="/profile"
element={
<ProtectedRoute>
<Profile />
</ProtectedRoute>
}
/>
<Route
path="/admin"
element={
<ProtectedRoute requireAdmin>
<AdminPanel />
</ProtectedRoute>
}
/>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
</Router>
</AuthProvider>
</QueryClientProvider>
)
}
export default App
export default App

View File

@ -0,0 +1,31 @@
import { Navigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
interface ProtectedRouteProps {
children: React.ReactNode
requireAdmin?: boolean
}
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children, requireAdmin = false }) => {
const { user, isLoading } = useAuth()
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
)
}
if (!user) {
return <Navigate to="/login" replace />
}
if (requireAdmin && user.role !== 'system_admin') {
return <Navigate to="/dashboard" replace />
}
return <>{children}</>
}
export default ProtectedRoute

View File

@ -0,0 +1,108 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'
interface User {
id: string
email: string
name: string
role: 'system_admin' | 'group_admin' | 'user'
avatar?: string
}
interface AuthContextType {
user: User | null
isLoading: boolean
login: (email: string, password: string) => Promise<void>
logout: () => void
checkAuth: () => Promise<void>
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export const useAuth = () => {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}
interface AuthProviderProps {
children: ReactNode
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
const checkAuth = async () => {
try {
const token = localStorage.getItem('access_token')
if (!token) {
setIsLoading(false)
return
}
// TODO: Verify token with backend
const response = await fetch('/api/v1/users/me', {
headers: {
Authorization: `Bearer ${token}`,
},
})
if (response.ok) {
const userData = await response.json()
setUser(userData)
} else {
localStorage.removeItem('access_token')
}
} catch (error) {
console.error('Auth check failed:', error)
} finally {
setIsLoading(false)
}
}
const login = async (email: string, password: string) => {
try {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
username: email,
password,
}),
})
if (!response.ok) {
throw new Error('Login failed')
}
const data = await response.json()
localStorage.setItem('access_token', data.access_token)
await checkAuth()
window.location.href = '/dashboard'
} catch (error) {
console.error('Login failed:', error)
throw error
}
}
const logout = () => {
localStorage.removeItem('access_token')
setUser(null)
window.location.href = '/login'
}
useEffect(() => {
checkAuth()
}, [])
return (
<AuthContext.Provider value={{ user, isLoading, login, logout, checkAuth }}>
{children}
</AuthContext.Provider>
)
}

View File

@ -1,68 +1,148 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
--gradient-1: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--gradient-2: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
--gradient-3: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
--gradient-4: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
--gradient-dark: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
}
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
body {
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
/* Glass effect */
.glass {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.18);
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
.glass-dark {
background: rgba(0, 0, 0, 0.2);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
h1 {
font-size: 3.2em;
line-height: 1.1;
/* Animations */
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-20px); }
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
@keyframes pulse-slow {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.8; }
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
@keyframes gradient-shift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.animate-float {
animation: float 6s ease-in-out infinite;
}
.animate-pulse-slow {
animation: pulse-slow 4s ease-in-out infinite;
}
.animate-gradient {
background-size: 200% 200%;
animation: gradient-shift 8s ease infinite;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Input focus effects */
.input-modern {
@apply relative;
}
.input-modern::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 0;
height: 2px;
background: linear-gradient(90deg, #667eea, #764ba2);
transition: width 0.3s ease;
}
.input-modern:focus-within::after {
width: 100%;
}
/* Button hover effects */
.btn-glow {
position: relative;
overflow: hidden;
transition: all 0.3s ease;
}
.btn-glow::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.5);
transform: translate(-50%, -50%);
transition: width 0.6s, height 0.6s;
}
.btn-glow:hover::before {
width: 300px;
height: 300px;
}
/* Neumorphism effects */
.neu-shadow {
box-shadow:
12px 12px 24px rgba(0, 0, 0, 0.1),
-12px -12px 24px rgba(255, 255, 255, 0.1);
}
.neu-shadow-inset {
box-shadow:
inset 6px 6px 12px rgba(0, 0, 0, 0.1),
inset -6px -6px 12px rgba(255, 255, 255, 0.1);
}

View File

@ -0,0 +1,10 @@
const AdminPanel = () => {
return (
<div className="min-h-screen bg-gray-50 p-6">
<h1 className="text-2xl font-bold"> </h1>
<p className="text-gray-600"> .</p>
</div>
)
}
export default AdminPanel

View File

@ -0,0 +1,10 @@
const Applications = () => {
return (
<div className="min-h-screen bg-gray-50 p-6">
<h1 className="text-2xl font-bold"> </h1>
<p className="text-gray-600">OAuth .</p>
</div>
)
}
export default Applications

View File

@ -0,0 +1,54 @@
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
const AuthCallback = () => {
const navigate = useNavigate()
useEffect(() => {
// Handle OAuth callback
const params = new URLSearchParams(window.location.search)
const code = params.get('code')
const state = params.get('state')
if (code) {
// Exchange code for token
handleOAuthCallback(code, state)
} else {
navigate('/login')
}
}, [navigate])
const handleOAuthCallback = async (code: string, state: string | null) => {
try {
const response = await fetch('/api/v1/auth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ code, state }),
})
if (response.ok) {
const data = await response.json()
localStorage.setItem('access_token', data.access_token)
navigate('/dashboard')
} else {
navigate('/login')
}
} catch (error) {
console.error('OAuth callback error:', error)
navigate('/login')
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600"> ...</p>
</div>
</div>
)
}
export default AuthCallback

View File

@ -0,0 +1,190 @@
import { useState } from 'react'
import { useAuth } from '../contexts/AuthContext'
import {
LayoutDashboard,
Users,
Settings,
LogOut,
ChevronDown,
Bell,
Search,
Menu,
X,
Shield,
Key,
Activity,
Clock
} from 'lucide-react'
const Dashboard = () => {
const { user, logout } = useAuth()
const [isSidebarOpen, setIsSidebarOpen] = useState(true)
const [isProfileOpen, setIsProfileOpen] = useState(false)
const stats = [
{ title: '활성 세션', value: '24', icon: Users, change: '+12%' },
{ title: '등록된 앱', value: '8', icon: Key, change: '+2' },
{ title: '이번 달 로그인', value: '1,429', icon: Activity, change: '+48%' },
{ title: '평균 응답시간', value: '132ms', icon: Clock, change: '-12%' },
]
const recentActivities = [
{ id: 1, action: '새로운 애플리케이션 등록', app: 'E-Commerce Platform', time: '5분 전' },
{ id: 2, action: '권한 업데이트', app: 'Mobile App', time: '2시간 전' },
{ id: 3, action: '토큰 갱신', app: 'Analytics Dashboard', time: '4시간 전' },
{ id: 4, action: '새로운 사용자 추가', app: 'CRM System', time: '1일 전' },
]
return (
<div className="min-h-screen bg-gray-50">
{/* Sidebar */}
<aside className={`fixed top-0 left-0 z-40 h-screen transition-transform ${
isSidebarOpen ? 'translate-x-0' : '-translate-x-full'
} bg-white border-r border-gray-200 w-64`}>
<div className="flex items-center justify-between p-4 border-b">
<h1 className="text-xl font-bold text-gray-900">OAuth System</h1>
<button onClick={() => setIsSidebarOpen(false)} className="lg:hidden">
<X size={20} />
</button>
</div>
<nav className="p-4 space-y-2">
<a href="/dashboard" className="flex items-center space-x-3 p-3 bg-blue-50 text-blue-600 rounded-lg">
<LayoutDashboard size={20} />
<span></span>
</a>
<a href="/applications" className="flex items-center space-x-3 p-3 text-gray-700 hover:bg-gray-50 rounded-lg">
<Key size={20} />
<span></span>
</a>
<a href="/profile" className="flex items-center space-x-3 p-3 text-gray-700 hover:bg-gray-50 rounded-lg">
<Settings size={20} />
<span> </span>
</a>
{user?.role === 'system_admin' && (
<a href="/admin" className="flex items-center space-x-3 p-3 text-gray-700 hover:bg-gray-50 rounded-lg">
<Shield size={20} />
<span> </span>
</a>
)}
</nav>
<div className="absolute bottom-4 left-4 right-4">
<button
onClick={logout}
className="flex items-center space-x-3 p-3 text-red-600 hover:bg-red-50 rounded-lg w-full"
>
<LogOut size={20} />
<span></span>
</button>
</div>
</aside>
{/* Main Content */}
<div className={`${isSidebarOpen ? 'lg:ml-64' : ''}`}>
{/* Header */}
<header className="bg-white border-b border-gray-200">
<div className="flex items-center justify-between p-4">
<div className="flex items-center space-x-4">
<button onClick={() => setIsSidebarOpen(!isSidebarOpen)}>
<Menu size={24} />
</button>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={20} />
<input
type="text"
placeholder="검색..."
className="pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div className="flex items-center space-x-4">
<button className="relative p-2 text-gray-600 hover:text-gray-900">
<Bell size={20} />
<span className="absolute top-0 right-0 w-2 h-2 bg-red-500 rounded-full"></span>
</button>
<div className="relative">
<button
onClick={() => setIsProfileOpen(!isProfileOpen)}
className="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-50"
>
<div className="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-white">
{user?.name?.[0] || user?.email?.[0]?.toUpperCase()}
</div>
<span className="hidden md:block text-sm font-medium text-gray-700">
{user?.name || user?.email}
</span>
<ChevronDown size={16} />
</button>
{isProfileOpen && (
<div className="absolute right-0 mt-2 w-48 bg-white border border-gray-200 rounded-lg shadow-lg">
<a href="/profile" className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
</a>
<hr className="border-gray-200" />
<button onClick={logout} className="block w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50">
</button>
</div>
)}
</div>
</div>
</div>
</header>
{/* Dashboard Content */}
<main className="p-6">
<div className="mb-6">
<h2 className="text-2xl font-bold text-gray-900"></h2>
<p className="text-gray-600">OAuth </p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{stats.map((stat) => (
<div key={stat.title} className="bg-white p-6 rounded-lg shadow">
<div className="flex items-center justify-between mb-4">
<div className="p-2 bg-blue-50 rounded-lg">
<stat.icon className="text-blue-600" size={24} />
</div>
<span className={`text-sm font-medium ${
stat.change.startsWith('+') ? 'text-green-600' : 'text-red-600'
}`}>
{stat.change}
</span>
</div>
<h3 className="text-2xl font-bold text-gray-900">{stat.value}</h3>
<p className="text-sm text-gray-600">{stat.title}</p>
</div>
))}
</div>
{/* Recent Activities */}
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900"> </h3>
</div>
<div className="p-6">
<div className="space-y-4">
{recentActivities.map((activity) => (
<div key={activity.id} className="flex items-center justify-between py-3 border-b border-gray-100 last:border-0">
<div>
<p className="text-sm font-medium text-gray-900">{activity.action}</p>
<p className="text-sm text-gray-600">{activity.app}</p>
</div>
<span className="text-sm text-gray-500">{activity.time}</span>
</div>
))}
</div>
</div>
</div>
</main>
</div>
</div>
)
}
export default Dashboard

View File

@ -0,0 +1,316 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import {
Eye,
EyeOff,
Loader2,
Fingerprint,
Shield,
Sparkles,
Lock,
Mail,
ArrowRight,
CheckCircle2
} from 'lucide-react'
const LoginPage = () => {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [rememberMe, setRememberMe] = useState(false)
const [focusedInput, setFocusedInput] = useState<string | null>(null)
const [theme, setTheme] = useState<any>(null)
const { login, user } = useAuth()
const navigate = useNavigate()
useEffect(() => {
if (user) {
navigate('/dashboard')
}
// Get theme from query params (for OAuth applications)
const params = new URLSearchParams(window.location.search)
const appId = params.get('app_id')
if (appId) {
fetchApplicationTheme(appId)
}
}, [user, navigate])
const fetchApplicationTheme = async (appId: string) => {
try {
const response = await fetch(`/api/v1/applications/${appId}/theme`)
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)
} catch (error) {
console.error('Login error:', error)
} finally {
setIsLoading(false)
}
}
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">
{theme?.title || '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">
<CheckCircle2 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">OAuth 2.0 </p>
</div>
</div>
<div className="flex items-start space-x-4">
<div className="p-2 bg-blue-500/20 rounded-lg flex-shrink-0">
<Fingerprint 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">Touch ID, Face ID </p>
</div>
</div>
<div className="flex items-start space-x-4">
<div className="p-2 bg-purple-500/20 rounded-lg flex-shrink-0">
<Sparkles 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>
{/* Stats */}
<div className="grid grid-cols-3 gap-4 pt-8 border-t border-white/10">
<div>
<div className="text-3xl font-bold text-white">99.9%</div>
<div className="text-sm text-gray-400"></div>
</div>
<div>
<div className="text-3xl font-bold text-white">2FA</div>
<div className="text-sm text-gray-400"> </div>
</div>
<div>
<div className="text-3xl font-bold text-white">256bit</div>
<div className="text-sm text-gray-400">AES </div>
</div>
</div>
</div>
{/* Right side - Login 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">Welcome Back</h2>
<p className="text-gray-300"> </p>
</div>
{/* Login Form */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* 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"
type="email"
required
value={email}
onChange={(e) => 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}
/>
<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>
{/* 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"
type={showPassword ? 'text' : 'password'}
required
value={password}
onChange={(e) => 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}
/>
<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>
</div>
{/* Remember Me & Forgot Password */}
<div className="flex items-center justify-between">
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="w-4 h-4 bg-white/10 border-white/20 rounded text-purple-500 focus:ring-purple-500 focus:ring-offset-0"
/>
<span className="text-sm text-gray-300"> </span>
</label>
<a href="#" className="text-sm text-purple-400 hover:text-purple-300 transition-colors">
?
</a>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={isLoading}
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>
{/* Divider */}
<div className="relative my-8">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-4 bg-transparent text-gray-400"></span>
</div>
</div>
{/* Social Login */}
<div className="space-y-3">
<button className="w-full flex items-center justify-center space-x-3 px-4 py-3 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl transition-all duration-300 group">
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
<span className="text-gray-300 group-hover:text-white transition-colors">Google로 </span>
</button>
<button className="w-full flex items-center justify-center space-x-3 px-4 py-3 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl transition-all duration-300 group">
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
<span className="text-gray-300 group-hover:text-white transition-colors">GitHub로 </span>
</button>
</div>
{/* Sign up link */}
<p className="mt-8 text-center text-sm text-gray-400">
?{' '}
<a href="/signup" 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 LoginPage

View File

@ -0,0 +1,10 @@
const Profile = () => {
return (
<div className="min-h-screen bg-gray-50 p-6">
<h1 className="text-2xl font-bold"> </h1>
<p className="text-gray-600"> .</p>
</div>
)
}
export default Profile