diff --git a/.env.example b/.env.example
index 639289e..1bacd26 100644
--- a/.env.example
+++ b/.env.example
@@ -2,4 +2,6 @@ JWT_SECRET=
DATASOURCE_URL=
DATASOURCE_USERNAME=
DATASOURCE_PASSWORD=
-CORS_ALLOWED_ORIGIN=
+WEB_ORIGIN=
+GOOGLE_CLIENT_ID=
+GOOGLE_CLIENT_SECRET=
diff --git a/.github/workflows/reusable-check.yaml b/.github/workflows/reusable-check.yaml
index 1d8edc1..891c993 100644
--- a/.github/workflows/reusable-check.yaml
+++ b/.github/workflows/reusable-check.yaml
@@ -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
diff --git a/CLAUDE.md b/CLAUDE.md
index fb477d5..60ac625 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
@@ -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
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 366c406..d9b613b 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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';
@@ -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
Authenticating...
;
- }
-
- if (!accessToken) {
- return (
-
- }/>
- }/>
- }/>
-
- );
- }
+const AuthenticatedLayout = () => {
+ const { impersonating } = useAuth();
return (
<>
@@ -38,7 +25,26 @@ const AppContent = () => {
)}
-
+
+
+ >
+ );
+};
+
+const AppContent = () => {
+ const { ready: authReady, accessToken, user } = useAuth();
+ const location = useLocation();
+
+ if (!authReady) {
+ return Authenticating...
;
+ }
+
+ return (
+
+ }/>
+ {accessToken ? (<>
+ }/>
+ }>
}/>
}/>
{user?.role === 'ADMIN' && (
@@ -47,9 +53,13 @@ const AppContent = () => {
}/>
)}
}/>
-
-
- >
+
+ >) : (<>
+ }/>
+ }/>
+ }/>
+ >)}
+
);
};
diff --git a/frontend/src/GoogleCallbackPage.module.css b/frontend/src/GoogleCallbackPage.module.css
new file mode 100644
index 0000000..5e05fff
--- /dev/null
+++ b/frontend/src/GoogleCallbackPage.module.css
@@ -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;
+}
diff --git a/frontend/src/GoogleCallbackPage.tsx b/frontend/src/GoogleCallbackPage.tsx
new file mode 100644
index 0000000..aa3bcb3
--- /dev/null
+++ b/frontend/src/GoogleCallbackPage.tsx
@@ -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(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 (
+
+
+ {errorMessage ? (
+ <>
+
{errorMessage}
+
로그인 페이지로 돌아가기
+ >
+ ) : (
+
로그인 중...
+ )}
+
+
+ );
+};
+
+export default GoogleCallbackPage;
diff --git a/frontend/src/InitNamePage.module.css b/frontend/src/InitNamePage.module.css
new file mode 100644
index 0000000..d015137
--- /dev/null
+++ b/frontend/src/InitNamePage.module.css
@@ -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;
+}
diff --git a/frontend/src/InitNamePage.tsx b/frontend/src/InitNamePage.tsx
new file mode 100644
index 0000000..af6c73d
--- /dev/null
+++ b/frontend/src/InitNamePage.tsx
@@ -0,0 +1,78 @@
+import { SubmitEvent, useEffect, useRef, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useAuth } from '@/useAuth.tsx';
+import { useAxios } from '@/useAxios.tsx';
+import styles from './InitNamePage.module.css';
+
+export const ALLOW_INIT_NAME_PAGE_KEY = 'allowInitNamePage';
+
+const InitNamePage = () => {
+ const { user, reloadUser } = useAuth();
+ const navigate = useNavigate();
+ const api = useAxios();
+ const [name, setName] = useState(user?.name ?? '');
+ const [submitting, setSubmitting] = useState(false);
+ const [allowed, setAllowed] = useState(null);
+ const hasCheckedRef = useRef(false);
+
+ useEffect(() => {
+ if (hasCheckedRef.current) {
+ return;
+ }
+ hasCheckedRef.current = true;
+
+ const keyExists = sessionStorage.getItem(ALLOW_INIT_NAME_PAGE_KEY) !== null;
+ sessionStorage.removeItem(ALLOW_INIT_NAME_PAGE_KEY);
+ setAllowed(keyExists);
+ if (!keyExists) {
+ navigate('/', { replace: true });
+ }
+ }, []);
+
+ if (!user || allowed !== true) {
+ return null;
+ }
+
+ const handleComplete = () => navigate('/', { replace: true });
+
+ const handleSubmit = async (e: SubmitEvent) => {
+ e.preventDefault();
+ setSubmitting(true);
+ try {
+ await api.patch(`/users/${user.id}`, { name });
+ await reloadUser();
+ } finally {
+ setSubmitting(false);
+ }
+ handleComplete();
+ };
+
+ return (
+
+
+
이름 설정
+
사용할 이름을 설정해 주세요.
+
+
+
+ );
+};
+
+export default InitNamePage;
diff --git a/frontend/src/LoginPage.module.css b/frontend/src/LoginPage.module.css
index f511b0d..fd49c20 100644
--- a/frontend/src/LoginPage.module.css
+++ b/frontend/src/LoginPage.module.css
@@ -94,3 +94,51 @@
margin-bottom: 1rem;
font-size: 0.9rem;
}
+
+.divider {
+ display: flex;
+ align-items: center;
+ margin: 1.25rem 0;
+ color: #aaa;
+ font-size: 0.85rem;
+}
+
+.divider::before,
+.divider::after {
+ content: '';
+ flex: 1;
+ height: 1px;
+ background: #e0e0e0;
+}
+
+.divider span {
+ padding: 0 0.75rem;
+}
+
+.google-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.6rem;
+ width: 100%;
+ padding: 0.75rem;
+ background-color: #fff;
+ color: #333;
+ border: 1px solid #ddd;
+ border-radius: 0.5rem;
+ font-size: 1rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background-color 0.2s, border-color 0.2s;
+}
+
+.google-button:hover, .google-button:active {
+ background-color: #f5f5f5;
+ border-color: #bbb;
+}
+
+.google-icon {
+ width: 1.25rem;
+ height: 1.25rem;
+ flex-shrink: 0;
+}
diff --git a/frontend/src/LoginPage.tsx b/frontend/src/LoginPage.tsx
index 323715c..0911e96 100644
--- a/frontend/src/LoginPage.tsx
+++ b/frontend/src/LoginPage.tsx
@@ -2,8 +2,14 @@ import { SubmitEvent, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { JwtResponse } from "@/types.ts";
import { useAuth } from '@/useAuth.tsx';
+import googleIcon from '@/assets/GoogleIcon.svg';
import styles from './LoginPage.module.css';
+interface SocialAuthUrlResponse {
+ url: string;
+ state: string;
+}
+
const LoginPage = () => {
const { login } = useAuth();
const navigate = useNavigate();
@@ -13,6 +19,20 @@ const LoginPage = () => {
const [password, setPassword] = useState('');
const [errorMessage, setErrorMessage] = useState('');
+ const handleGoogleLogin = async () => {
+ try {
+ const response = await fetch('/api/auth/social/google/url');
+ if (!response.ok) {
+ setErrorMessage('Google 로그인을 시작할 수 없습니다.');
+ return;
+ }
+ const data = await response.json() as SocialAuthUrlResponse;
+ window.location.href = data.url;
+ } catch {
+ setErrorMessage('서버와 통신 중 오류가 발생했습니다.');
+ }
+ };
+
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
setErrorMessage('');
@@ -41,6 +61,11 @@ const LoginPage = () => {
로그인
{errorMessage &&
{errorMessage}
}
+
+
이메일로 로그인