Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ JWT_SECRET=
DATASOURCE_URL=
DATASOURCE_USERNAME=
DATASOURCE_PASSWORD=
CORS_ALLOWED_ORIGIN=
WEB_ORIGIN=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
2 changes: 1 addition & 1 deletion .github/workflows/reusable-check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:
DATASOURCE_USERNAME: root
DATASOURCE_PASSWORD: rootpasswd
JWT_SECRET: dummy_secret
CORS_ALLOWED_ORIGIN: http://localhost
WEB_ORIGIN: http://localhost

check-frontend:
runs-on: ubuntu-latest
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ Game page hierarchy: `App (router)` → `HomePage` → `RoomDetailPage` → `Tic

### Security
- Stateless JWT; access token 10 min, refresh 2 hr, WebSocket token 1 min (short-lived for WS handshake)
- CORS origin controlled via `CORS_ALLOWED_ORIGIN` env var
- CORS origin controlled via `WEB_ORIGIN` env var
- `SecurityConfig.java` is the central Spring Security configuration

### Database
Expand All @@ -103,7 +103,7 @@ JWT_SECRET=
DATASOURCE_URL=jdbc:mysql://localhost:3306/tichu?createDatabaseIfNotExist=true
DATASOURCE_USERNAME=
DATASOURCE_PASSWORD=
CORS_ALLOWED_ORIGIN=http://localhost:5173
WEB_ORIGIN=http://localhost:5173
```

## CI/CD
Expand Down
54 changes: 32 additions & 22 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { BrowserRouter, Routes, Route, Navigate, Outlet, useLocation } from 'react-router-dom';
import { AuthProvider, useAuth } from '@/useAuth.tsx';
import LoginPage from '@/LoginPage.tsx';
import SignupPage from '@/SignupPage.tsx';
import GoogleCallbackPage from '@/GoogleCallbackPage.tsx';
import InitNamePage from '@/InitNamePage.tsx';
import NavBar from "@/NavBar.tsx";
import HomePage from '@/HomePage/HomePage.tsx';
import RoomDetailPage from '@/RoomDetailPage.tsx';
Expand All @@ -11,23 +13,8 @@ import './App.css';
const AdminPage = lazy(() => import('@/AdminPage.tsx'));
const ImpersonationOverlay = lazy(() => import('@/ImpersonationOverlay.tsx'));

const AppContent = () => {
const { ready: authReady, accessToken, user, impersonating } = useAuth();
const location = useLocation();

if (!authReady) {
return <div>Authenticating...</div>;
}

if (!accessToken) {
return (
<Routes>
<Route path="/" element={<LoginPage/>}/>
<Route path="/signup" element={<SignupPage/>}/>
<Route path="*" element={<Navigate to="/" replace state={{ from: location.pathname + location.search }}/>}/>
</Routes>
);
}
const AuthenticatedLayout = () => {
const { impersonating } = useAuth();

return (
<>
Expand All @@ -38,7 +25,26 @@ const AppContent = () => {
)}
<NavBar/>
<div className='container'>
<Routes>
<Outlet/>
</div>
</>
);
};

const AppContent = () => {
const { ready: authReady, accessToken, user } = useAuth();
const location = useLocation();

if (!authReady) {
return <div>Authenticating...</div>;
}

return (
<Routes>
<Route path="/auth/callback/google" element={<GoogleCallbackPage/>}/>
{accessToken ? (<>
<Route path="/init-name" element={<InitNamePage/>}/>
<Route element={<AuthenticatedLayout/>}>
<Route path="/" element={<HomePage/>}/>
<Route path="/rooms/:roomId" element={<RoomDetailPage/>}/>
{user?.role === 'ADMIN' && (
Expand All @@ -47,9 +53,13 @@ const AppContent = () => {
</Suspense>}/>
)}
<Route path="*" element={<Navigate to="/" replace/>}/>
</Routes>
</div>
</>
</Route>
</>) : (<>
<Route path="/" element={<LoginPage/>}/>
<Route path="/signup" element={<SignupPage/>}/>
<Route path="*" element={<Navigate to="/" replace state={{ from: location.pathname + location.search }}/>}/>
</>)}
</Routes>
);
};

Expand Down
47 changes: 47 additions & 0 deletions frontend/src/GoogleCallbackPage.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
.container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 1rem;
width: 100%;
max-width: 400px;
}

.card {
background: white;
padding: 2.5rem;
border-radius: 1rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.05);
text-align: center;
width: 100%;
}

.card h2 {
margin-top: 0;
margin-bottom: 0.5rem;
color: #333;
}

.loading-text {
color: #666;
}

.error-message {
color: #e53935;
background-color: #ffebee;
padding: 0.75rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
font-size: 0.9rem;
}

.back-link {
color: #4facfe;
text-decoration: none;
font-size: 0.9rem;
}

.back-link:hover {
text-decoration: underline;
}
75 changes: 75 additions & 0 deletions frontend/src/GoogleCallbackPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { useState, useEffect, useRef } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '@/useAuth.tsx';
import styles from './GoogleCallbackPage.module.css';
import { JwtResponse } from "@/types.ts";
import { ALLOW_INIT_NAME_PAGE_KEY } from '@/InitNamePage.tsx';

const GoogleCallbackPage = () => {
const { login } = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const hasFetchedRef = useRef(false);

useEffect(() => {
const code = searchParams.get('code');
const state = searchParams.get('state');

if (!code || !state) {
setErrorMessage('잘못된 접근입니다.');
return;
}

if (hasFetchedRef.current) {
return;
}
hasFetchedRef.current = true;

(async () => {
let token: string;
let isNewUser: boolean;
try {
const response = await fetch('/api/auth/social/google/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, state }),
});

if (!response.ok) {
setErrorMessage('Google 로그인에 실패했습니다.');
return;
}

({ token } = await response.json() as JwtResponse);
isNewUser = response.status === 201;
} catch {
setErrorMessage('서버와 통신 중 오류가 발생했습니다.');
return;
}

await login(token);
if (isNewUser) {
sessionStorage.setItem(ALLOW_INIT_NAME_PAGE_KEY, '1');
}
navigate(isNewUser ? '/init-name' : '/', { replace: true });
})();
}, []);

return (
<div className={styles.container}>
<div className={styles.card}>
{errorMessage ? (
<>
<p className={styles.errorMessage}>{errorMessage}</p>
<Link to="/" className={styles.backLink}>로그인 페이지로 돌아가기</Link>
</>
) : (
<p className={styles.loadingText}>로그인 중...</p>
)}
</div>
</div>
);
};

export default GoogleCallbackPage;
99 changes: 99 additions & 0 deletions frontend/src/InitNamePage.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
.container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 1rem;
width: 100%;
max-width: 400px;
}

.card {
background: white;
padding: 2.5rem;
border-radius: 1rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.05);
text-align: center;
width: 100%;
}

.card h2 {
margin-top: 0;
margin-bottom: 0.5rem;
color: #333;
}

.description {
color: #666;
font-size: 0.95rem;
margin-bottom: 1.5rem;
}

.form {
display: flex;
flex-direction: column;
gap: 1rem;
}

.form-group {
text-align: left;
}

.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #555;
}

.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 0.5rem;
font-size: 1rem;
box-sizing: border-box;
}

.submit-button {
width: 100%;
padding: 0.75rem;
background-color: #4facfe;
color: white;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
}

.submit-button:hover:not(:disabled) {
background-color: #0089f2;
}

.submit-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}

.skip-button {
width: 100%;
padding: 0.75rem;
background-color: transparent;
color: #888;
border: 1px solid #ddd;
border-radius: 0.5rem;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
}

.skip-button:hover:not(:disabled) {
background-color: #f5f5f5;
}

.skip-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
Loading