Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
f6cbd5d
fix: DOM 중첩 경고 해결 (#147)
AndyH0ng Feb 10, 2026
16ee9a8
fix: 401 토스트 출력 로직 개선 (#148)
AndyH0ng Feb 10, 2026
a53d1aa
feat: 로그인 후 화면 갱신 (#149)
AndyH0ng Feb 10, 2026
7ad9eac
feat: 로그인 여부 확인 후에 스켈레톤 렌더 (#150)
AndyH0ng Feb 10, 2026
8afdd63
fix: CSP 널널하게 설정 (#147)
AndyH0ng Feb 10, 2026
5dc243d
fix: 이름 변경 후 캐시 업데이트 (#147)
AndyH0ng Feb 10, 2026
866926b
refactor: 코드 리뷰 반영 (#147)
AndyH0ng Feb 10, 2026
760db52
fix: 변환 상태 타입 추가 (#147)
AndyH0ng Feb 10, 2026
f149eec
design: 스켈레톤 사용성 개선 (#147)
AndyH0ng Feb 10, 2026
6a2db39
fix: 401 시에도 재시도하는 문제 수정 (#147)
AndyH0ng Feb 10, 2026
d07bffc
feat: 댓글/리액션 토글 시 토스트 삭제 (#147)
AndyH0ng Feb 10, 2026
7b8a5f7
feat: 댓글 최신순 정렬 (#147)
AndyH0ng Feb 10, 2026
451f9d7
feat: 토스트 정책 업데이트 (#147)
AndyH0ng Feb 10, 2026
d38fb3b
design: 토스트 라이팅 수정 (#147)
AndyH0ng Feb 10, 2026
0622ca0
chore: revert 스켈레톤 사용성 개선 (#147)
AndyH0ng Feb 10, 2026
22cf502
chore: 슬라이드 웹소켓 관련 코드 삭제 (#147)
AndyH0ng Feb 10, 2026
d55dc03
chore: 로그아웃 아이콘 추가 (#152)
AndyH0ng Feb 10, 2026
34d8a75
feat: 로그인 버튼 UI 업데이트 (#152)
AndyH0ng Feb 10, 2026
ace9a33
chore: 코드 리뷰 반영 (#152)
AndyH0ng Feb 10, 2026
1213eb9
Merge pull request #151 from TTORANG/fix/bugfix-147
AndyH0ng Feb 10, 2026
b01179e
Merge branch 'develop' into feat/login-ui-152
AndyH0ng Feb 10, 2026
2da0902
Merge pull request #153 from TTORANG/feat/login-ui-152
AndyH0ng Feb 10, 2026
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
2 changes: 1 addition & 1 deletion firebase.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"headers": [
{
"key": "Content-Security-Policy",
"value": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data: https:; font-src 'self' data: https://cdn.jsdelivr.net; connect-src 'self' https://ttorang-server-407623424780.asia-northeast3.run.app https://cdn.ttorang.com https://developers.kakao.com; media-src 'self' https://cdn.ttorang.com; frame-ancestors 'none'"
"value": "default-src 'self'; script-src 'self' https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data: https:; font-src 'self' data: https://cdn.jsdelivr.net; connect-src 'self' https://ttorang-server-407623424780.asia-northeast3.run.app https://cdn.ttorang.com https://developers.kakao.com https://cdn.jsdelivr.net; media-src 'self' https://cdn.ttorang.com; frame-ancestors 'none'"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Content-Security-Policy에서 script-src의 'unsafe-inline'과 'unsafe-eval'을 제거하여 보안을 강화한 점은 매우 좋습니다. 하지만 style-src에는 여전히 'unsafe-inline'이 남아있습니다. 이는 인라인 스타일을 허용하여 잠재적인 Cross-Site Scripting (XSS) 공격의 경로가 될 수 있습니다. 가능하다면 이 지시문을 제거하고 클래스 기반 스타일링을 사용하거나, 스타일을 위한 nonce 또는 hash를 사용하는 것을 고려해 보세요.

},
{
"key": "X-Frame-Options",
Expand Down
10 changes: 10 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { useEffect } from 'react';
import { RouterProvider } from 'react-router-dom';

import { useQueryClient } from '@tanstack/react-query';

import type { JwtPayloadDto } from '@/api/dto';
import { queryKeys } from '@/api/queryClient';
import { DevFab } from '@/components/common/DevFab';
import { router } from '@/router';
import { useAuthStore } from '@/stores/authStore';
Expand All @@ -10,6 +13,8 @@ import { parseJwtPayload } from '@/utils/jwt';

function App() {
useThemeListener();
const queryClient = useQueryClient();
const accessToken = useAuthStore((state) => state.accessToken);

useEffect(() => {
const handleMessage = (event: MessageEvent) => {
Expand Down Expand Up @@ -41,6 +46,11 @@ function App() {
return () => window.removeEventListener('message', handleMessage);
}, []);

useEffect(() => {
if (!accessToken) return;
void queryClient.invalidateQueries({ queryKey: queryKeys.presentations.lists() });
}, [accessToken, queryClient]);

return (
<>
<RouterProvider router={router} />
Expand Down
4 changes: 3 additions & 1 deletion src/api/errorHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ type ErrorHandler = (message: string) => void;
*/
const errorHandlers: Record<number, ErrorHandler> = {
401: () => {
// 세션 만료 시 로그아웃 처리
const { accessToken } = useAuthStore.getState();
// 로그인 상태일 때만 만료 처리 (비로그인 상태에서는 토스트 금지)
if (!accessToken) return;
useAuthStore.getState().logout();
showToast.error('로그인이 만료되었습니다.', '다시 로그인해주세요.');
},
Expand Down
6 changes: 3 additions & 3 deletions src/assets/icons/icon-logout.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 18 additions & 7 deletions src/components/common/FileDropzone.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export default function FileDropzone({
if (inputRef.current) inputRef.current.value = ''; // 같은 파일 다시 선택 가능하게 (선택창 value 초기화)
};

const handleDragEnter = (e: React.DragEvent<HTMLButtonElement>) => {
const handleDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
if (isBlocked) return;
Expand All @@ -70,13 +70,13 @@ export default function FileDropzone({
setIsDragging(true);
};

const handleDragOver = (e: React.DragEvent<HTMLButtonElement>) => {
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
if (isBlocked) return;
};

const handleDragLeave = (e: React.DragEvent<HTMLButtonElement>) => {
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
if (isBlocked) return;
Expand All @@ -85,7 +85,7 @@ export default function FileDropzone({
if (dragCounter.current === 0) setIsDragging(false);
};

const handleDrop = (e: React.DragEvent<HTMLButtonElement>) => {
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
// 드롭 시 카운터 초기화해서 다음 드래그 상태가 꼬이지 않도록 함
Expand Down Expand Up @@ -115,6 +115,14 @@ export default function FileDropzone({
const showDragOverlay = isDragging && !isBlocked;
const showUploadOverlay = isUploading;

const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (isBlocked) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openFileDialog();
}
};

return (
<div className="w-full mt-10">
<input
Expand All @@ -125,9 +133,12 @@ export default function FileDropzone({
onChange={(e) => handleFile(e.target.files)}
/>

<button
type="button"
<div
role="button"
tabIndex={isBlocked ? -1 : 0}
aria-disabled={isBlocked}
onClick={openFileDialog}
onKeyDown={handleKeyDown}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
Expand Down Expand Up @@ -187,7 +198,7 @@ export default function FileDropzone({
</button>
</div>
)}
</button>
</div>
</div>
);
}
114 changes: 111 additions & 3 deletions src/components/common/layout/LoginButton.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,124 @@
/**
* @file LoginButton.tsx
* @description 로그인 버튼 컴포넌트
* @description 로그인/프로필 버튼 컴포넌트
*
* 헤더 우측에 표시되는 로그인 링크입니다.
* 비로그인 상태: 로그인 버튼 (클릭 시 로그인 모달)
* 로그인 상태: 사용자 이름 + 프로필 이미지 (클릭 시 로그아웃/회원탈퇴 드롭다운)
*/
import { useState } from 'react';

import { apiClient } from '@/api/client';
import LoginIcon from '@/assets/icons/icon-login.svg?react';
import LogoutIcon from '@/assets/icons/icon-logout.svg?react';
import { Dropdown } from '@/components/common/Dropdown';
import { Modal } from '@/components/common/Modal';
import { useAuthStore } from '@/stores/authStore';
import { showToast } from '@/utils/toast';

import { HeaderButton } from './HeaderButton';

export function LoginButton() {
const user = useAuthStore((s) => s.user);
const openLoginModal = useAuthStore((s) => s.openLoginModal);
const logout = useAuthStore((s) => s.logout);

const [isWithdrawModalOpen, setIsWithdrawModalOpen] = useState(false);
const [isWithdrawing, setIsWithdrawing] = useState(false);

if (!user) {
return <HeaderButton text="로그인" icon={<LoginIcon />} onClick={openLoginModal} />;
}

const handleWithdraw = async () => {
setIsWithdrawing(true);
try {
await apiClient.delete(`/users/${user.id}`);
logout();
setIsWithdrawModalOpen(false);
showToast.success('회원 탈퇴가 완료되었습니다.');
} catch {
showToast.error('회원 탈퇴에 실패했습니다.', '잠시 후 다시 시도해주세요.');
} finally {
setIsWithdrawing(false);
}
};

return (
<>
<Dropdown
position="bottom"
align="end"
ariaLabel="사용자 메뉴"
trigger={
<button
type="button"
className="flex cursor-pointer items-center gap-2 text-body-s-bold text-gray-800 transition-colors hover:text-gray-600"
>
{user.name ?? '사용자'}
{user.profileImage ? (
<img
src={user.profileImage}
alt="프로필"
className="size-6 rounded-full object-cover"
/>
) : (
<div className="size-6 rounded-full bg-gray-200" />
)}
</button>
}
items={[
{
id: 'logout',
label: (
<span className="flex items-center gap-1">
로그아웃
<LogoutIcon className="size-6" />
</span>
),
onClick: logout,
variant: 'danger',
},
{
id: 'withdraw',
label: '회원 탈퇴',
onClick: () => setIsWithdrawModalOpen(true),
variant: 'danger',
},
]}
/>

return <HeaderButton text="로그인" icon={<LoginIcon />} onClick={openLoginModal} />;
<Modal
isOpen={isWithdrawModalOpen}
onClose={() => setIsWithdrawModalOpen(false)}
title="회원 탈퇴"
size="sm"
closeOnBackdropClick={!isWithdrawing}
closeOnEscape={!isWithdrawing}
>
<p className="text-body-m">
탈퇴하면 모든 데이터가 삭제되며 복구할 수 없습니다.
<br />
정말 탈퇴하시겠습니까?
</p>
<div className="mt-7 flex gap-3">
<button
className="flex-1 rounded-md bg-gray-100 py-3 font-bold text-gray-600 transition-colors hover:bg-gray-200 disabled:opacity-50"
type="button"
onClick={() => setIsWithdrawModalOpen(false)}
disabled={isWithdrawing}
>
취소
</button>
<button
className="flex-1 rounded-md bg-error py-3 font-bold text-white transition-colors hover:bg-error/90 disabled:opacity-50"
type="button"
onClick={handleWithdraw}
disabled={isWithdrawing}
>
{isWithdrawing ? '탈퇴 중...' : '탈퇴'}
</button>
</div>
</Modal>
</>
);
}
26 changes: 25 additions & 1 deletion src/hooks/queries/usePresentations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,14 @@ export function usePresentations(options?: { enabled?: boolean }) {
/**
* 프로젝트 목록 조회 (필터/검색/정렬 지원)
*/
export function usePresentationsWithFilters(params: GetPresentationsRequestDto) {
export function usePresentationsWithFilters(
params: GetPresentationsRequestDto,
options?: { enabled?: boolean },
) {
return useQuery({
queryKey: queryKeys.presentations.list(params),
queryFn: () => getPresentations(params),
enabled: options?.enabled ?? true,
});
}

Expand Down Expand Up @@ -67,6 +71,26 @@ export function useUpdatePresentation() {
? { ...old, title: updatePresentation.title, updatedAt: updatePresentation.updatedAt }
: old,
);
// 목록 캐시는 즉시 업데이트 (화면 전환 시 반영 지연 방지)
queryClient.setQueriesData<PresentationListResponse>(
{ queryKey: queryKeys.presentations.lists() },
(oldData) => {
if (!oldData) return oldData;
const nextPresentations = oldData.presentations.map((item) =>
item.projectId === updatePresentation.projectId
? {
...item,
title: updatePresentation.title,
updatedAt: updatePresentation.updatedAt,
}
: item,
);
return {
...oldData,
presentations: nextPresentations,
};
},
);
// 목록은 최신 데이터 반영을 위해 무효화
void queryClient.invalidateQueries({ queryKey: queryKeys.presentations.lists() });
},
Expand Down
10 changes: 9 additions & 1 deletion src/hooks/queries/useSlides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* 슬라이드 관련 TanStack Query 훅
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { isAxiosError } from 'axios';

import type { UpdateSlideTitleRequestDto } from '@/api/dto';
import { getSlides, updateSlide } from '@/api/endpoints/slides';
Expand All @@ -17,9 +18,16 @@ export function useSlides(projectId: string) {
queryKey: queryKeys.slides.list(projectId),
queryFn: () => getSlides(projectId),
enabled: !!projectId,
retry: false,
// 🔄 서버가 웹소켓 브로드캐스트를 안하므로 임시로 폴링 추가
// TODO: 서버에서 broadcastNewComment 호출 후 제거
refetchInterval: 3000, // 3초마다 자동 갱신
refetchInterval: (query) => {
const error = query.state.error;
if (isAxiosError(error) && error.response?.status === 401) {
return false;
}
return 3000;
}, // 3초마다 자동 갱신 (401이면 중단)
refetchIntervalInBackground: false, // 탭이 백그라운드일 때는 멈춤
});
}
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useAutoSaveScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export function useAutoSaveScript() {
try {
await mutateAsync({ slideId, data: { script } });
lastSavedRef.current = script;
showToast.success('저장 완료');
showToast.success('저장 완료', '대본이 자동으로 저장되었습니다.');
} catch {
showToast.error('저장 실패', '다시 시도해주세요.');
}
Expand Down
Loading
Loading