- 게임 시작 전 토큰 리프레시 (만료 토큰 전달 방지) - 토큰 null 가드 (다른 탭 로그아웃 시 로그인 유도) - 토큰 URL 인코딩 (encodeURIComponent) - 런처 미설치 시 힌트 강조 표시 - 게임 시작 버튼 로딩 상태 + 더블 클릭 방지 - 다운로드 정보 실패 시 재시도 버튼 추가 - 비밀번호 강도 실시간 피드백 (약함/보통/강함) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,3 +18,25 @@ export async function logout() {
|
||||
return apiFetch('/api/auth/logout', { method: 'POST' });
|
||||
}
|
||||
|
||||
// 토큰을 리프레시하고 새 access token을 반환
|
||||
export async function refreshToken() {
|
||||
const rt = localStorage.getItem('refreshToken');
|
||||
if (!rt) throw new Error('no_refresh_token');
|
||||
|
||||
const res = await fetch(
|
||||
(import.meta.env.VITE_API_BASE_URL || '') + '/api/auth/refresh',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken: rt }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) throw new Error('refresh_failed');
|
||||
|
||||
const data = await res.json();
|
||||
localStorage.setItem('token', data.token);
|
||||
localStorage.setItem('refreshToken', data.refreshToken);
|
||||
return data.token;
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,10 @@
|
||||
margin: 16px 0 0;
|
||||
}
|
||||
|
||||
.launch-hint-active {
|
||||
color: #BACDB0;
|
||||
}
|
||||
|
||||
.download-preparing {
|
||||
font-size: 0.9rem;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
|
||||
@@ -1,31 +1,56 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/useAuth';
|
||||
import { getDownloadInfo } from '../api/download';
|
||||
import { refreshToken } from '../api/auth';
|
||||
import './DownloadSection.css';
|
||||
|
||||
export default function DownloadSection() {
|
||||
const [info, setInfo] = useState(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [launched, setLaunched] = useState(false);
|
||||
const [launching, setLaunching] = useState(false);
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const loadInfo = useCallback(() => {
|
||||
setReady(false);
|
||||
setLoadError(false);
|
||||
getDownloadInfo()
|
||||
.then((data) => { setInfo(data); setReady(true); })
|
||||
.catch(() => setReady(true));
|
||||
.catch(() => { setLoadError(true); setReady(true); });
|
||||
}, []);
|
||||
|
||||
const handlePlay = () => {
|
||||
useEffect(() => { loadInfo(); }, [loadInfo]);
|
||||
|
||||
const handlePlay = async () => {
|
||||
if (!user) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// user.token은 로그인 시점 값으로 만료됐을 수 있으므로
|
||||
// tryRefresh가 갱신한 최신 토큰을 localStorage에서 직접 읽음
|
||||
const token = localStorage.getItem('token');
|
||||
window.location.href = 'a301://launch?token=' + token;
|
||||
// 토큰이 없으면 (다른 탭에서 로그아웃 등) 로그인 유도
|
||||
let token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
setLaunching(true);
|
||||
|
||||
// 토큰 만료 대비: 런처에 전달하기 전에 리프레시 시도
|
||||
try {
|
||||
token = await refreshToken();
|
||||
} catch {
|
||||
// 리프레시 실패해도 기존 토큰으로 시도 (아직 유효할 수 있음)
|
||||
}
|
||||
|
||||
window.location.href = 'a301://launch?token=' + encodeURIComponent(token);
|
||||
|
||||
// 런처가 실행되지 않았을 수 있으므로 안내 표시
|
||||
setLaunched(true);
|
||||
setLaunching(false);
|
||||
};
|
||||
|
||||
const handleDownloadLauncher = () => {
|
||||
@@ -48,20 +73,37 @@ export default function DownloadSection() {
|
||||
<p className="download-meta">
|
||||
{info.version} · {info.fileSize}
|
||||
</p>
|
||||
<button onClick={handlePlay} className="btn-play">
|
||||
게임 시작
|
||||
<button onClick={handlePlay} className="btn-play" disabled={launching}>
|
||||
{launching ? '준비 중...' : '게임 시작'}
|
||||
</button>
|
||||
{info?.launcherUrl && (
|
||||
<button onClick={handleDownloadLauncher} className="btn-launcher-download">
|
||||
런처 다운로드
|
||||
</button>
|
||||
)}
|
||||
<p className="launch-hint">
|
||||
처음이거나 게임이 실행되지 않으면 런처를 다운로드해주세요.
|
||||
</p>
|
||||
{launched ? (
|
||||
<p className="launch-hint launch-hint-active">
|
||||
게임이 실행되지 않나요? 런처를 다운로드한 뒤 한 번 실행해주세요.
|
||||
</p>
|
||||
) : (
|
||||
<p className="launch-hint">
|
||||
처음이거나 게임이 실행되지 않으면 런처를 다운로드해주세요.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="download-preparing">런처 준비 중입니다. 잠시 후 다시 확인해주세요.</p>
|
||||
<>
|
||||
<p className="download-preparing">
|
||||
{loadError
|
||||
? '서버에 연결할 수 없습니다.'
|
||||
: '런처 준비 중입니다. 잠시 후 다시 확인해주세요.'}
|
||||
</p>
|
||||
{loadError && (
|
||||
<button onClick={loadInfo} className="btn-launcher-download">
|
||||
다시 시도
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -164,3 +164,13 @@
|
||||
.login-back:hover {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.password-strength {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(186, 205, 176, 0.7);
|
||||
margin: -12px 0 0;
|
||||
}
|
||||
|
||||
.password-strength.strength-weak {
|
||||
color: #e57373;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,22 @@ export default function RegisterPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const getPasswordStrength = (pw) => {
|
||||
if (pw.length === 0) return '';
|
||||
if (pw.length < 6) return '비밀번호는 6자 이상이어야 합니다.';
|
||||
let strength = 0;
|
||||
if (/[a-z]/.test(pw)) strength++;
|
||||
if (/[A-Z]/.test(pw)) strength++;
|
||||
if (/[0-9]/.test(pw)) strength++;
|
||||
if (/[^a-zA-Z0-9]/.test(pw)) strength++;
|
||||
if (strength <= 1) return '약함';
|
||||
if (strength <= 2) return '보통';
|
||||
return '강함';
|
||||
};
|
||||
|
||||
const passwordStrength = getPasswordStrength(password);
|
||||
const isPasswordWeak = password.length > 0 && password.length < 6;
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
@@ -68,6 +84,12 @@ export default function RegisterPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{passwordStrength && (
|
||||
<p className={`password-strength ${isPasswordWeak ? 'strength-weak' : ''}`}>
|
||||
강도: {passwordStrength}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="input-group">
|
||||
<label htmlFor="confirm">비밀번호 확인</label>
|
||||
<input
|
||||
|
||||
Reference in New Issue
Block a user