Compare commits
2 Commits
f85e261366
...
90e9922bde
| Author | SHA1 | Date | |
|---|---|---|---|
| 90e9922bde | |||
| 6fb7e2cbc5 |
@@ -13,3 +13,30 @@ export async function register(username, password) {
|
|||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,84 @@
|
|||||||
const BASE = import.meta.env.VITE_API_BASE_URL || '';
|
const BASE = import.meta.env.VITE_API_BASE_URL || '';
|
||||||
|
|
||||||
|
// 동시 401 발생 시 refresh를 한 번만 실행하기 위한 Promise 공유
|
||||||
|
let refreshingPromise = null;
|
||||||
|
|
||||||
|
async function tryRefresh() {
|
||||||
|
if (refreshingPromise) return refreshingPromise;
|
||||||
|
|
||||||
|
refreshingPromise = (async () => {
|
||||||
|
const rt = localStorage.getItem('refreshToken');
|
||||||
|
if (!rt) throw new Error('no_refresh_token');
|
||||||
|
|
||||||
|
const res = await fetch(BASE + '/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;
|
||||||
|
})().finally(() => {
|
||||||
|
refreshingPromise = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return refreshingPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doFetch(path, options, token) {
|
||||||
|
const headers = { 'Content-Type': 'application/json', ...options.headers };
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
return fetch(BASE + path, { ...options, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseError(res) {
|
||||||
|
let message = res.statusText;
|
||||||
|
try {
|
||||||
|
const body = await res.json();
|
||||||
|
if (body.error) message = body.error;
|
||||||
|
} catch {}
|
||||||
|
const err = new Error(message);
|
||||||
|
err.status = res.status;
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 204 No Content는 null 반환, 나머지는 JSON 파싱
|
||||||
|
async function parseResponse(res) {
|
||||||
|
if (res.status === 204) return null;
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
export async function apiFetch(path, options = {}) {
|
export async function apiFetch(path, options = {}) {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const headers = { 'Content-Type': 'application/json', ...options.headers };
|
const res = await doFetch(path, options, token);
|
||||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
|
|
||||||
const res = await fetch(BASE + path, { ...options, headers });
|
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
window.dispatchEvent(new Event('auth:unauthorized'));
|
try {
|
||||||
|
const newToken = await tryRefresh();
|
||||||
|
// 새 토큰으로 원래 요청 재시도
|
||||||
|
const retryRes = await doFetch(path, options, newToken);
|
||||||
|
if (retryRes.status === 401) {
|
||||||
|
window.dispatchEvent(new Event('auth:unauthorized'));
|
||||||
|
throw await parseError(retryRes);
|
||||||
|
}
|
||||||
|
if (!retryRes.ok) throw await parseError(retryRes);
|
||||||
|
return parseResponse(retryRes);
|
||||||
|
} catch (e) {
|
||||||
|
// refresh 자체 실패 → 로그아웃
|
||||||
|
if (e.message === 'no_refresh_token' || e.message === 'refresh_failed') {
|
||||||
|
window.dispatchEvent(new Event('auth:unauthorized'));
|
||||||
|
const err = new Error('인증이 필요합니다');
|
||||||
|
err.status = 401;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
|
||||||
const err = new Error(res.statusText);
|
if (!res.ok) throw await parseError(res);
|
||||||
err.status = res.status;
|
return parseResponse(res);
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,18 @@ import './AnnouncementBoard.css';
|
|||||||
export default function AnnouncementBoard() {
|
export default function AnnouncementBoard() {
|
||||||
const [list, setList] = useState([]);
|
const [list, setList] = useState([]);
|
||||||
const [expanded, setExpanded] = useState(null);
|
const [expanded, setExpanded] = useState(null);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getAnnouncements().then(setList);
|
getAnnouncements()
|
||||||
|
.then(setList)
|
||||||
|
.catch(() => setError(true));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="announcement-board">
|
<section className="announcement-board">
|
||||||
<h2 className="announcement-heading">공지사항</h2>
|
<h2 className="announcement-heading">공지사항</h2>
|
||||||
|
{error && <p className="announcement-error">공지사항을 불러오지 못했습니다.</p>}
|
||||||
<ul className="announcement-list">
|
<ul className="announcement-list">
|
||||||
{list.map((item) => (
|
{list.map((item) => (
|
||||||
<li key={item.id} className="announcement-item">
|
<li key={item.id} className="announcement-item">
|
||||||
|
|||||||
@@ -70,6 +70,10 @@
|
|||||||
margin: 16px 0 0;
|
margin: 16px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.launch-hint-active {
|
||||||
|
color: #BACDB0;
|
||||||
|
}
|
||||||
|
|
||||||
.download-preparing {
|
.download-preparing {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: rgba(255, 255, 255, 0.4);
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
|||||||
@@ -1,28 +1,56 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/useAuth';
|
import { useAuth } from '../context/useAuth';
|
||||||
import { getDownloadInfo } from '../api/download';
|
import { getDownloadInfo } from '../api/download';
|
||||||
|
import { refreshToken } from '../api/auth';
|
||||||
import './DownloadSection.css';
|
import './DownloadSection.css';
|
||||||
|
|
||||||
export default function DownloadSection() {
|
export default function DownloadSection() {
|
||||||
const [info, setInfo] = useState(null);
|
const [info, setInfo] = useState(null);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
const [loadError, setLoadError] = useState(false);
|
||||||
|
const [launched, setLaunched] = useState(false);
|
||||||
|
const [launching, setLaunching] = useState(false);
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
const loadInfo = useCallback(() => {
|
||||||
|
setReady(false);
|
||||||
|
setLoadError(false);
|
||||||
getDownloadInfo()
|
getDownloadInfo()
|
||||||
.then((data) => { setInfo(data); setReady(true); })
|
.then((data) => { setInfo(data); setReady(true); })
|
||||||
.catch(() => setReady(true));
|
.catch(() => { setLoadError(true); setReady(true); });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handlePlay = () => {
|
useEffect(() => { loadInfo(); }, [loadInfo]);
|
||||||
|
|
||||||
|
const handlePlay = async () => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.location.href = 'a301://launch?token=' + user.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 = () => {
|
const handleDownloadLauncher = () => {
|
||||||
@@ -45,20 +73,37 @@ export default function DownloadSection() {
|
|||||||
<p className="download-meta">
|
<p className="download-meta">
|
||||||
{info.version} · {info.fileSize}
|
{info.version} · {info.fileSize}
|
||||||
</p>
|
</p>
|
||||||
<button onClick={handlePlay} className="btn-play">
|
<button onClick={handlePlay} className="btn-play" disabled={launching}>
|
||||||
게임 시작
|
{launching ? '준비 중...' : '게임 시작'}
|
||||||
</button>
|
</button>
|
||||||
{info?.launcherUrl && (
|
{info?.launcherUrl && (
|
||||||
<button onClick={handleDownloadLauncher} className="btn-launcher-download">
|
<button onClick={handleDownloadLauncher} className="btn-launcher-download">
|
||||||
런처 다운로드
|
런처 다운로드
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<p className="launch-hint">
|
{launched ? (
|
||||||
처음이거나 게임이 실행되지 않으면 런처를 다운로드해주세요.
|
<p className="launch-hint launch-hint-active">
|
||||||
</p>
|
게임이 실행되지 않나요? 런처를 다운로드한 뒤 한 번 실행해주세요.
|
||||||
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -8,14 +8,16 @@ export default function AnnouncementAdmin() {
|
|||||||
const [form, setForm] = useState({ title: '', content: '' });
|
const [form, setForm] = useState({ title: '', content: '' });
|
||||||
const [editingId, setEditingId] = useState(null);
|
const [editingId, setEditingId] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const load = () => getAnnouncements().then(setList);
|
const load = () => getAnnouncements().then(setList).catch(() => {});
|
||||||
useEffect(() => { load(); }, []);
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!form.title || !form.content) return;
|
if (!form.title || !form.content) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
try {
|
try {
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
await apiFetch(`/api/announcements/${editingId}`, {
|
await apiFetch(`/api/announcements/${editingId}`, {
|
||||||
@@ -31,6 +33,8 @@ export default function AnnouncementAdmin() {
|
|||||||
setForm({ title: '', content: '' });
|
setForm({ title: '', content: '' });
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
load();
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || '처리에 실패했습니다.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -39,17 +43,23 @@ export default function AnnouncementAdmin() {
|
|||||||
const handleEdit = (item) => {
|
const handleEdit = (item) => {
|
||||||
setEditingId(item.id);
|
setEditingId(item.id);
|
||||||
setForm({ title: item.title, content: item.content });
|
setForm({ title: item.title, content: item.content });
|
||||||
|
setError('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id) => {
|
const handleDelete = async (id) => {
|
||||||
if (!confirm('삭제하시겠습니까?')) return;
|
if (!confirm('삭제하시겠습니까?')) return;
|
||||||
await apiFetch(`/api/announcements/${id}`, { method: 'DELETE' });
|
try {
|
||||||
load();
|
await apiFetch(`/api/announcements/${id}`, { method: 'DELETE' });
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || '삭제에 실패했습니다.');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setForm({ title: '', content: '' });
|
setForm({ title: '', content: '' });
|
||||||
|
setError('');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -70,6 +80,7 @@ export default function AnnouncementAdmin() {
|
|||||||
value={form.content}
|
value={form.content}
|
||||||
onChange={(e) => setForm({ ...form, content: e.target.value })}
|
onChange={(e) => setForm({ ...form, content: e.target.value })}
|
||||||
/>
|
/>
|
||||||
|
{error && <p className="admin-error">{error}</p>}
|
||||||
<div className="admin-form-actions">
|
<div className="admin-form-actions">
|
||||||
<button className="btn-admin-primary" type="submit" disabled={loading}>
|
<button className="btn-admin-primary" type="submit" disabled={loading}>
|
||||||
{editingId ? '수정 완료' : '공지 등록'}
|
{editingId ? '수정 완료' : '공지 등록'}
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ function UploadForm({ title, hint, accept, endpoint, onSuccess }) {
|
|||||||
|
|
||||||
xhr.onload = () => {
|
xhr.onload = () => {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
|
if (xhr.status === 401) {
|
||||||
|
window.dispatchEvent(new Event('auth:unauthorized'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
onSuccess(JSON.parse(xhr.responseText));
|
onSuccess(JSON.parse(xhr.responseText));
|
||||||
setFile(null);
|
setFile(null);
|
||||||
@@ -50,6 +54,7 @@ function UploadForm({ title, hint, accept, endpoint, onSuccess }) {
|
|||||||
|
|
||||||
xhr.open('POST', `${BASE}${endpoint}?filename=${encodeURIComponent(file.name)}`);
|
xhr.open('POST', `${BASE}${endpoint}?filename=${encodeURIComponent(file.name)}`);
|
||||||
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||||
|
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
setError('');
|
setError('');
|
||||||
xhr.send(file);
|
xhr.send(file);
|
||||||
|
|||||||
@@ -5,27 +5,39 @@ import './AdminCommon.css';
|
|||||||
|
|
||||||
export default function UserAdmin() {
|
export default function UserAdmin() {
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
const { user: me } = useAuth();
|
const { user: me } = useAuth();
|
||||||
|
|
||||||
const load = () => getUsers().then(setUsers);
|
const load = () => getUsers().then(setUsers).catch(() => {});
|
||||||
useEffect(() => { load(); }, []);
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
const handleRoleToggle = async (u) => {
|
const handleRoleToggle = async (u) => {
|
||||||
const newRole = u.role === 'admin' ? 'user' : 'admin';
|
const newRole = u.role === 'admin' ? 'user' : 'admin';
|
||||||
if (!confirm(`${u.username}의 권한을 ${newRole}로 변경하시겠습니까?`)) return;
|
if (!confirm(`${u.username}의 권한을 ${newRole}로 변경하시겠습니까?`)) return;
|
||||||
await updateUserRole(u.id, newRole);
|
setError('');
|
||||||
load();
|
try {
|
||||||
|
await updateUserRole(u.id, newRole);
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || '권한 변경에 실패했습니다.');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (u) => {
|
const handleDelete = async (u) => {
|
||||||
if (!confirm(`${u.username} 계정을 삭제하시겠습니까?`)) return;
|
if (!confirm(`${u.username} 계정을 삭제하시겠습니까?`)) return;
|
||||||
await deleteUser(u.id);
|
setError('');
|
||||||
load();
|
try {
|
||||||
|
await deleteUser(u.id);
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || '삭제에 실패했습니다.');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-section">
|
<div className="admin-section">
|
||||||
<h2 className="admin-section-title">유저 관리</h2>
|
<h2 className="admin-section-title">유저 관리</h2>
|
||||||
|
{error && <p className="admin-error">{error}</p>}
|
||||||
<ul className="admin-list">
|
<ul className="admin-list">
|
||||||
{users.map((u) => (
|
{users.map((u) => (
|
||||||
<li key={u.id} className="admin-list-item">
|
<li key={u.id} className="admin-list-item">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import { login as apiLogin } from '../api/auth';
|
import { login as apiLogin, logout as apiLogout } from '../api/auth';
|
||||||
import { AuthContext } from './authContextValue';
|
import { AuthContext } from './authContextValue';
|
||||||
|
|
||||||
export function AuthProvider({ children }) {
|
export function AuthProvider({ children }) {
|
||||||
@@ -13,22 +13,34 @@ export function AuthProvider({ children }) {
|
|||||||
const login = useCallback(async (username, password) => {
|
const login = useCallback(async (username, password) => {
|
||||||
const data = await apiLogin(username, password);
|
const data = await apiLogin(username, password);
|
||||||
localStorage.setItem('token', data.token);
|
localStorage.setItem('token', data.token);
|
||||||
|
localStorage.setItem('refreshToken', data.refreshToken);
|
||||||
localStorage.setItem('username', data.username);
|
localStorage.setItem('username', data.username);
|
||||||
localStorage.setItem('role', data.role);
|
localStorage.setItem('role', data.role);
|
||||||
setUser({ token: data.token, username: data.username, role: data.role });
|
setUser({ token: data.token, username: data.username, role: data.role });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
// 로컬 세션만 정리 (토큰 만료·강제 로그아웃 시)
|
||||||
|
const clearSession = useCallback(() => {
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('refreshToken');
|
||||||
localStorage.removeItem('username');
|
localStorage.removeItem('username');
|
||||||
localStorage.removeItem('role');
|
localStorage.removeItem('role');
|
||||||
setUser(null);
|
setUser(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 정상 로그아웃: 서버 세션(액세스 + 리프레시)도 삭제
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await apiLogout();
|
||||||
|
} catch {}
|
||||||
|
clearSession();
|
||||||
|
}, [clearSession]);
|
||||||
|
|
||||||
|
// 401 응답 시 서버 호출 없이 로컬 세션만 정리
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.addEventListener('auth:unauthorized', logout);
|
window.addEventListener('auth:unauthorized', clearSession);
|
||||||
return () => window.removeEventListener('auth:unauthorized', logout);
|
return () => window.removeEventListener('auth:unauthorized', clearSession);
|
||||||
}, [logout]);
|
}, [clearSession]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, login, logout }}>
|
<AuthContext.Provider value={{ user, login, logout }}>
|
||||||
@@ -36,4 +48,3 @@ export function AuthProvider({ children }) {
|
|||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -164,3 +164,13 @@
|
|||||||
.login-back:hover {
|
.login-back:hover {
|
||||||
color: rgba(255, 255, 255, 0.7);
|
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 [loading, setLoading] = useState(false);
|
||||||
const navigate = useNavigate();
|
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) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
@@ -68,6 +84,12 @@ export default function RegisterPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{passwordStrength && (
|
||||||
|
<p className={`password-strength ${isPasswordWeak ? 'strength-weak' : ''}`}>
|
||||||
|
강도: {passwordStrength}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label htmlFor="confirm">비밀번호 확인</label>
|
<label htmlFor="confirm">비밀번호 확인</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
Reference in New Issue
Block a user