fix: SSAFY OAuth 로그인 수정 및 API 클라이언트 안정성 개선 (8건)
Some checks failed
Client CI/CD / test (push) Waiting to run
Client CI/CD / deploy (push) Has been cancelled

- SSAFY OAuth state 파라미터 전달 추가 (로그인 불능 해결)
- POST/PUT/DELETE 5xx 및 네트워크 에러 재시도 방지 (멱등 요청만 재시도)
- UserAdmin 페이지네이션 추가 (offset/limit)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 23:26:40 +09:00
parent ae013acd2d
commit 4e3a01428a
5 changed files with 31 additions and 13 deletions

View File

@@ -22,10 +22,10 @@ export async function getSSAFYLoginURL() {
return apiFetch('/api/auth/ssafy/login'); return apiFetch('/api/auth/ssafy/login');
} }
export async function ssafyCallback(code) { export async function ssafyCallback(code, state) {
return apiFetch('/api/auth/ssafy/callback', { return apiFetch('/api/auth/ssafy/callback', {
method: 'POST', method: 'POST',
body: JSON.stringify({ code }), body: JSON.stringify({ code, state }),
}); });
} }

View File

@@ -76,11 +76,14 @@ export async function apiFetch(path, options = {}, _retryCount = 0) {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
let res; let res;
const method = (options.method || 'GET').toUpperCase();
const isIdempotent = method === 'GET' || method === 'HEAD';
try { try {
res = await doFetch(path, options, token); res = await doFetch(path, options, token);
} catch (e) { } catch (e) {
// 네트워크 에러 (오프라인 등) — 재시도 // 네트워크 에러 (오프라인 등) — 멱등 요청만 재시도
if (_retryCount < 2) { if (isIdempotent && _retryCount < 2) {
await delay(1000 * (_retryCount + 1)); await delay(1000 * (_retryCount + 1));
return apiFetch(path, options, _retryCount + 1); return apiFetch(path, options, _retryCount + 1);
} }
@@ -111,8 +114,8 @@ export async function apiFetch(path, options = {}, _retryCount = 0) {
} }
} }
// 5xx 서버 에러 — 최대 2회 재시도 (exponential backoff) // 5xx 서버 에러 — 멱등 요청만 최대 2회 재시도 (exponential backoff)
if (res.status >= 500 && _retryCount < 2) { if (res.status >= 500 && isIdempotent && _retryCount < 2) {
await delay(1000 * (_retryCount + 1)); await delay(1000 * (_retryCount + 1));
return apiFetch(path, options, _retryCount + 1); return apiFetch(path, options, _retryCount + 1);
} }

View File

@@ -1,7 +1,7 @@
import { apiFetch } from './client'; import { apiFetch } from './client';
export function getUsers() { export function getUsers(offset = 0, limit = 20) {
return apiFetch('/api/users'); return apiFetch(`/api/users?offset=${offset}&limit=${limit}`);
} }
export function updateUserRole(id, role) { export function updateUserRole(id, role) {

View File

@@ -6,18 +6,24 @@ import { useConfirm } from '../confirm/useConfirm';
import s from './AdminCommon.module.css'; import s from './AdminCommon.module.css';
export default function UserAdmin() { export default function UserAdmin() {
const PAGE_SIZE = 20;
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState(false); const [fetchError, setFetchError] = useState(false);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(false);
const { user: me } = useAuth(); const { user: me } = useAuth();
const toast = useToast(); const toast = useToast();
const confirm = useConfirm(); const confirm = useConfirm();
const load = () => { const load = (off = offset) => {
setLoading(true); setLoading(true);
setFetchError(false); setFetchError(false);
getUsers() getUsers(off, PAGE_SIZE)
.then(setUsers) .then((data) => {
setUsers(data);
setHasMore(data.length === PAGE_SIZE);
})
.catch((err) => { .catch((err) => {
console.error('유저 목록 로드 실패:', err); console.error('유저 목록 로드 실패:', err);
setFetchError(true); setFetchError(true);
@@ -25,7 +31,7 @@ export default function UserAdmin() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}; };
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial data fetch on mount // eslint-disable-next-line react-hooks/set-state-in-effect -- initial data fetch on mount
useEffect(() => { load(); }, []); useEffect(() => { load(0); }, []);
const handleRoleToggle = async (u) => { const handleRoleToggle = async (u) => {
const newRole = u.role === 'admin' ? 'user' : 'admin'; const newRole = u.role === 'admin' ? 'user' : 'admin';
@@ -79,6 +85,14 @@ export default function UserAdmin() {
</li> </li>
))} ))}
</ul> </ul>
<div className={s.listActions} style={{ justifyContent: 'center', marginTop: '1rem' }}>
<button className={s.btnSecondary} disabled={offset === 0} onClick={() => { const prev = Math.max(0, offset - PAGE_SIZE); setOffset(prev); load(prev); }}>
이전
</button>
<button className={s.btnSecondary} disabled={!hasMore} onClick={() => { const next = offset + PAGE_SIZE; setOffset(next); load(next); }}>
다음
</button>
</div>
</div> </div>
); );
} }

View File

@@ -19,12 +19,13 @@ export default function SSAFYCallbackPage() {
called.current = true; called.current = true;
const code = searchParams.get('code'); const code = searchParams.get('code');
const state = searchParams.get('state');
if (!code) { if (!code) {
setError('인가 코드가 없습니다.'); // eslint-disable-line react-hooks/set-state-in-effect -- error state from URL param check setError('인가 코드가 없습니다.'); // eslint-disable-line react-hooks/set-state-in-effect -- error state from URL param check
return; return;
} }
ssafyCallback(code) ssafyCallback(code, state)
.then((data) => { .then((data) => {
setUserFromSSAFY(data); setUserFromSSAFY(data);
navigate('/', { replace: true }); navigate('/', { replace: true });