From defcc26adbb3700f968b53a9b74ba3afbfb28f2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Tue, 18 Aug 2026 21:01:54 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20=EC=9D=B8=EC=A6=9D=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=EC=99=80=20=EA=B6=8C=ED=95=9C=20=EB=B6=80=EC=A1=B1?= =?UTF-8?q?=EC=9D=84=20=EC=83=81=ED=83=9C=20=EC=BD=94=EB=93=9C=EC=99=80=20?= =?UTF-8?q?=EB=B3=B8=EB=AC=B8=EC=9C=BC=EB=A1=9C=20=EA=B5=AC=EB=B6=84?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20(#232)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SecurityConfig에 exceptionHandling이 없어 Spring Security 기본 동작이 나갔다. 토큰이 없거나 만료·위조된 요청이 본문 없는 403을 받아서, Service 계층이 주는 권한 부족 403과 구분되지 않았고 다른 오류가 모두 쓰는 ErrorResponse 형식과도 어긋났다. 프론트 lib/api.ts에는 이미 401을 받으면 세션을 정리하는 분기가 있는데 백엔드가 401을 보내지 않아 동작하지 않는 코드였다. 이제 만료 토큰으로 접근하면 재로그인 경로가 실제로 타진다. AccessDeniedHandler도 함께 등록해 경로 단위 거부(/admin/**)가 Service 계층과 같은 ROLE-002 본문을 주도록 맞췄다. 같은 결함 유형이라 한 커밋에 넣었다. --- .../docgrid/global/config/SecurityConfig.java | 9 ++++ .../exception/RestAccessDeniedHandler.java | 41 +++++++++++++++++++ .../RestAuthenticationEntryPoint.java | 41 +++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 backend/src/main/java/com/opensource/docgrid/global/exception/RestAccessDeniedHandler.java create mode 100644 backend/src/main/java/com/opensource/docgrid/global/exception/RestAuthenticationEntryPoint.java diff --git a/backend/src/main/java/com/opensource/docgrid/global/config/SecurityConfig.java b/backend/src/main/java/com/opensource/docgrid/global/config/SecurityConfig.java index dc541525..6fe4cc9f 100644 --- a/backend/src/main/java/com/opensource/docgrid/global/config/SecurityConfig.java +++ b/backend/src/main/java/com/opensource/docgrid/global/config/SecurityConfig.java @@ -18,6 +18,8 @@ import com.opensource.docgrid.domain.auth.jwt.TokenBlacklistService; import com.opensource.docgrid.domain.mcp.security.McpApiKeyAuthFilter; import com.opensource.docgrid.domain.mcp.service.command.McpAccessTokenCommandService; +import com.opensource.docgrid.global.exception.RestAccessDeniedHandler; +import com.opensource.docgrid.global.exception.RestAuthenticationEntryPoint; import lombok.RequiredArgsConstructor; @@ -31,6 +33,8 @@ public class SecurityConfig { private final TokenBlacklistService tokenBlacklistService; private final RoleAuthorityService roleAuthorityService; private final McpAccessTokenCommandService mcpAccessTokenCommandService; + private final RestAuthenticationEntryPoint restAuthenticationEntryPoint; + private final RestAccessDeniedHandler restAccessDeniedHandler; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @@ -52,6 +56,11 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) + // 인증 실패와 권한 부족을 상태 코드로 구분하고, 본문 없는 기본 응답 대신 공통 ErrorResponse를 준다. + .exceptionHandling(handling -> handling + .authenticationEntryPoint(restAuthenticationEntryPoint) + .accessDeniedHandler(restAccessDeniedHandler) + ) /* * UsernamePasswordAuthenticationFilter는 위치 기준점(앵커)일 뿐이며, * 실제 목적은 두 필터(JwtAuthenticationFilter, McpApiKeyAuthFilter)가 최종 인증 판정(authorizeHttpRequests)보다 먼저 실행됨 diff --git a/backend/src/main/java/com/opensource/docgrid/global/exception/RestAccessDeniedHandler.java b/backend/src/main/java/com/opensource/docgrid/global/exception/RestAccessDeniedHandler.java new file mode 100644 index 00000000..8ffcdaab --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/global/exception/RestAccessDeniedHandler.java @@ -0,0 +1,41 @@ +package com.opensource.docgrid.global.exception; + +import java.io.IOException; + +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.opensource.docgrid.global.common.response.ErrorResponse; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +/** + * 인증은 됐지만 권한이 없는 요청에 403과 공통 ErrorResponse 본문을 반환한다. + * + *

Service 계층은 이미 PERMISSION_DENIED로 본문 있는 403을 주는데, SecurityConfig의 + * 경로 단위 거부만 본문 없이 나가고 있었다. 같은 의미의 응답이 형식까지 같도록 맞춘다. + */ +@Component +@RequiredArgsConstructor +public class RestAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException { + ErrorCode errorCode = ErrorCode.PERMISSION_DENIED; + response.setStatus(errorCode.getHttpStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + objectMapper.writeValue(response.getWriter(), ErrorResponse.of(errorCode, request)); + } +} diff --git a/backend/src/main/java/com/opensource/docgrid/global/exception/RestAuthenticationEntryPoint.java b/backend/src/main/java/com/opensource/docgrid/global/exception/RestAuthenticationEntryPoint.java new file mode 100644 index 00000000..75e3ac59 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/global/exception/RestAuthenticationEntryPoint.java @@ -0,0 +1,41 @@ +package com.opensource.docgrid.global.exception; + +import java.io.IOException; + +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.opensource.docgrid.global.common.response.ErrorResponse; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +/** + * 인증하지 않은 요청에 401과 공통 ErrorResponse 본문을 반환한다. + * + *

기본 동작은 본문 없는 403이라 권한 부족과 구분되지 않았다. Client가 만료·누락 Token을 + * 인지하고 다시 로그인할 수 있도록 상태 코드와 응답 형식을 다른 오류와 맞춘다. + */ +@Component +@RequiredArgsConstructor +public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException { + ErrorCode errorCode = ErrorCode.UNAUTHORIZED; + response.setStatus(errorCode.getHttpStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + objectMapper.writeValue(response.getWriter(), ErrorResponse.of(errorCode, request)); + } +} From 8e42919e4f0e134526fa7e8e5512d976f1dee2b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Tue, 18 Aug 2026 21:01:54 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=EC=82=AD=EC=A0=9C=EB=90=9C=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=EC=9D=98=20=EC=83=88=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=EB=A5=BC=20404=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC=ED=95=9C=EB=8B=A4=20(#232)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit soft delete된 문서는 상세·본문·파일·상태 조회와 수정, 중복 삭제가 모두 404 DOCUMENT-001을 주는데 새 버전 업로드만 409 DOCUMENT-VERSION-003이었다. validate()에 삭제 검사가 없어 DELETED 상태가 마지막 else로 떨어진 결과였다. 검사 위치는 소유자 확인 다음으로 잡았다. DocumentQueryService.getReadableDocument가 권한 확인 뒤에 삭제를 판별하는 것과 같은 순서다. 삭제 검사를 앞에 두면 타인의 삭제된 문서 존재 여부가 404와 403 차이로 드러난다. --- .../service/command/DocumentVersionUploadService.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/src/main/java/com/opensource/docgrid/domain/document/service/command/DocumentVersionUploadService.java b/backend/src/main/java/com/opensource/docgrid/domain/document/service/command/DocumentVersionUploadService.java index ed524875..35b5d70b 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/document/service/command/DocumentVersionUploadService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/document/service/command/DocumentVersionUploadService.java @@ -133,6 +133,11 @@ private void validate(Document document, Long userId, ValidatedFile file, String if (!document.getOwner().getId().equals(userId)) { throw new DocGridException(ErrorCode.PERMISSION_DENIED); } + // 삭제된 문서는 다른 조회·수정 경로와 같이 존재하지 않는 것으로 다룬다. + // 상태 분기까지 내려가면 이 경우만 409가 되어 나머지 API의 404와 어긋난다. + if (document.getStatus() == DocumentStatus.DELETED) { + throw new DocGridException(ErrorCode.DOCUMENT_NOT_FOUND); + } if (document.getSourceType() != DocumentSourceType.UPLOAD) { throw new DocGridException(ErrorCode.DOCUMENT_VERSION_NOT_ALLOWED); } From 7cc4eae435b1d61e33483d52cf6d454fc45d49fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Tue, 18 Aug 2026 21:01:54 +0900 Subject: [PATCH 3/5] =?UTF-8?q?test:=20=EC=9D=B8=EC=A6=9D=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=9D=91=EB=8B=B5=EA=B3=BC=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EB=B2=84=EC=A0=84=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EA=B2=80=EC=A6=9D=20(#232)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security 경계에서 나가는 응답은 Controller를 거치지 않아 GlobalExceptionHandler 테스트가 닿지 않으므로 EntryPoint와 AccessDeniedHandler를 직접 호출해 상태 코드, 오류 코드, 본문 형식과 UTF-8 인코딩을 고정했다. DocumentVersionUploadService는 단위 테스트가 없어 새로 만들었다. 삭제된 문서가 404를 주는지와 함께, 소유자가 아닌 요청은 삭제 여부보다 먼저 403으로 막혀 문서 존재 여부가 드러나지 않는지도 검증한다. --- .../DocumentVersionUploadServiceTest.java | 98 +++++++++++++++++++ .../exception/SecurityErrorResponseTest.java | 67 +++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 backend/src/test/java/com/opensource/docgrid/domain/document/service/command/DocumentVersionUploadServiceTest.java create mode 100644 backend/src/test/java/com/opensource/docgrid/global/exception/SecurityErrorResponseTest.java diff --git a/backend/src/test/java/com/opensource/docgrid/domain/document/service/command/DocumentVersionUploadServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/document/service/command/DocumentVersionUploadServiceTest.java new file mode 100644 index 00000000..034ea57e --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/document/service/command/DocumentVersionUploadServiceTest.java @@ -0,0 +1,98 @@ +package com.opensource.docgrid.domain.document.service.command; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import com.opensource.docgrid.domain.document.entity.Document; +import com.opensource.docgrid.domain.document.enums.DocumentSourceType; +import com.opensource.docgrid.domain.document.enums.DocumentStatus; +import com.opensource.docgrid.domain.document.enums.DocumentType; +import com.opensource.docgrid.domain.document.enums.VisibilityType; +import com.opensource.docgrid.domain.document.repository.DocumentRepository; +import com.opensource.docgrid.domain.document.repository.DocumentVersionRepository; +import com.opensource.docgrid.domain.document.service.ValidatedFile; +import com.opensource.docgrid.domain.embedding.repository.EmbeddingJobRepository; +import com.opensource.docgrid.domain.embedding.service.query.EmbeddingModelQueryService; +import com.opensource.docgrid.domain.sync.service.command.SyncEventWriter; +import com.opensource.docgrid.domain.user.entity.User; +import com.opensource.docgrid.domain.user.repository.UserRepository; +import com.opensource.docgrid.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; + +/** + * 새 버전 접수 전 검증 규칙을 확인한다. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("DocumentVersionUploadService 단위 테스트") +class DocumentVersionUploadServiceTest { + + private static final Long USER_ID = 7L; + private static final Long DOCUMENT_ID = 11L; + private static final String FILE_HASH = "hash"; + + @Mock private UserRepository userRepository; + @Mock private FileObjectResolutionService fileObjectResolutionService; + @Mock private DocumentRepository documentRepository; + @Mock private DocumentVersionRepository documentVersionRepository; + @Mock private EmbeddingJobRepository embeddingJobRepository; + @Mock private EmbeddingModelQueryService embeddingModelQueryService; + @Mock private SyncEventWriter syncEventWriter; + + @InjectMocks private DocumentVersionUploadService documentVersionUploadService; + + @Test + @DisplayName("예외 케이스: 삭제된 문서에 새 버전을 올리면 문서를 찾을 수 없다고 응답한다") + void prepare_throwsNotFound_whenDocumentDeleted() { + // 상태 분기까지 내려가면 이 경우만 409가 되어 나머지 조회·수정 API의 404와 어긋난다. + given(documentRepository.findById(DOCUMENT_ID)).willReturn(Optional.of(deletedDocument())); + + assertThatThrownBy(() -> documentVersionUploadService.prepare( + USER_ID, DOCUMENT_ID, validatedFile(), FILE_HASH + )) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.DOCUMENT_NOT_FOUND); + } + + @Test + @DisplayName("예외 케이스: 소유자가 아니면 삭제 여부보다 먼저 권한 없음으로 막는다") + void prepare_throwsPermissionDenied_whenNotOwner() { + // 삭제 검사가 앞서면 타인의 삭제된 문서 존재 여부가 드러나므로 순서를 고정한다. + given(documentRepository.findById(DOCUMENT_ID)).willReturn(Optional.of(deletedDocument())); + + assertThatThrownBy(() -> documentVersionUploadService.prepare( + USER_ID + 1, DOCUMENT_ID, validatedFile(), FILE_HASH + )) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.PERMISSION_DENIED); + } + + private Document deletedDocument() { + User owner = User.builder().build(); + ReflectionTestUtils.setField(owner, "id", USER_ID); + + Document document = Document.builder() + .owner(owner) + .title("삭제된 문서") + .documentType(DocumentType.PDF) + .sourceType(DocumentSourceType.UPLOAD) + .status(DocumentStatus.DELETED) + .visibility(VisibilityType.PRIVATE) + .build(); + ReflectionTestUtils.setField(document, "id", DOCUMENT_ID); + return document; + } + + private ValidatedFile validatedFile() { + return new ValidatedFile("new.pdf", "pdf", "application/pdf", 100L, DocumentType.PDF); + } +} diff --git a/backend/src/test/java/com/opensource/docgrid/global/exception/SecurityErrorResponseTest.java b/backend/src/test/java/com/opensource/docgrid/global/exception/SecurityErrorResponseTest.java new file mode 100644 index 00000000..24a6651d --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/global/exception/SecurityErrorResponseTest.java @@ -0,0 +1,67 @@ +package com.opensource.docgrid.global.exception; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.BadCredentialsException; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +/** + * Spring Security 경계에서 나가는 인증·권한 실패 응답의 상태 코드와 본문 형식을 검증한다. + * + *

기본 동작은 둘 다 본문 없는 403이라 Client가 재로그인 대상과 권한 부족을 구분할 수 없었다. + */ +@DisplayName("Security 예외 응답 단위 테스트") +class SecurityErrorResponseTest { + + // 실제 주입되는 Bean은 Spring Boot가 JavaTimeModule을 등록해 주므로 ErrorResponse.timestamp가 직렬화된다. + private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + + @Test + @DisplayName("인증하지 않은 요청은 401과 COMMON-007 본문을 받는다") + void entryPoint_writesUnauthorizedBody() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/documents"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + new RestAuthenticationEntryPoint(objectMapper) + .commence(request, response, new BadCredentialsException("no token")); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED.value()); + assertThat(response.getContentType()).startsWith(MediaType.APPLICATION_JSON_VALUE); + // 한글 메시지가 깨지지 않도록 인코딩까지 고정한다. + assertThat(response.getCharacterEncoding()).isEqualToIgnoringCase("UTF-8"); + + var body = objectMapper.readTree(response.getContentAsString()); + assertThat(body.get("code").asText()).isEqualTo(ErrorCode.UNAUTHORIZED.getCode()); + assertThat(body.get("message").asText()).isEqualTo(ErrorCode.UNAUTHORIZED.getMessage()); + assertThat(body.get("status").asInt()).isEqualTo(HttpStatus.UNAUTHORIZED.value()); + assertThat(body.get("success").asBoolean()).isFalse(); + assertThat(body.get("method").asText()).isEqualTo("GET"); + assertThat(body.get("path").asText()).isEqualTo("/api/documents"); + } + + @Test + @DisplayName("권한이 없는 요청은 403과 ROLE-002 본문을 받는다") + void accessDeniedHandler_writesForbiddenBody() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/admin/users"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + new RestAccessDeniedHandler(objectMapper) + .handle(request, response, new AccessDeniedException("denied")); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.FORBIDDEN.value()); + + var body = objectMapper.readTree(response.getContentAsString()); + // Service 계층이 던지는 PERMISSION_DENIED와 같은 코드라 Client가 한 경로로 처리할 수 있다. + assertThat(body.get("code").asText()).isEqualTo(ErrorCode.PERMISSION_DENIED.getCode()); + assertThat(body.get("status").asInt()).isEqualTo(HttpStatus.FORBIDDEN.value()); + } +} From dd756f08290046637cc5efe4815d9557ded1f3f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Tue, 18 Aug 2026 21:01:54 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=ED=8C=8C=EC=9D=BC=20=ED=81=AC=EA=B8=B0=20?= =?UTF-8?q?=ED=91=9C=EA=B8=B0=EC=99=80=20=EC=84=A4=EB=AA=85=20=EB=9D=BC?= =?UTF-8?q?=EB=B2=A8=20=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95=20(#232)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 업로드 모달이 크기를 MB로 고정 계산해 1KB 미만 파일이 0.00MB로 보였다. 문서 상세는 구간별 단위로 올바르게 표시하고 있어 같은 파일이 화면마다 다르게 보였다. formatBytes를 DocumentsPage에서 ui.tsx로 옮겨(formatDate 옆) 두 화면이 같은 함수를 쓰게 했다. 문서 정보 수정 모달의 설명 라벨이 "문제 설명"으로 되어 있어 "문서 설명"으로 고쳤다. --- frontend/app/components/UploadModal.tsx | 3 ++- frontend/app/components/ui.tsx | 8 ++++++++ frontend/app/features/DocumentsPage.tsx | 10 ++-------- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/frontend/app/components/UploadModal.tsx b/frontend/app/components/UploadModal.tsx index 39145b3b..4e10e833 100644 --- a/frontend/app/components/UploadModal.tsx +++ b/frontend/app/components/UploadModal.tsx @@ -3,6 +3,7 @@ import { FormEvent, useState } from "react"; import { apiRequest, errorMessage } from "../lib/api"; import type { DocumentUploadResponse, DocumentVersionUploadResponse } from "../lib/api-types"; +import { formatBytes } from "./ui"; export function UploadModal({ documentId, onClose, onSuccess }: { documentId?: number; @@ -44,7 +45,7 @@ export function UploadModal({ documentId, onClose, onSuccess }: {

{versionMode ? "새 버전 업로드" : "문서 업로드"}

{versionMode ? `문서 #${documentId}에 새 파일 버전을 추가합니다.` : "파일 저장 후 비동기 인덱싱 Job을 생성합니다."}

{error ?
{error}
: null} - + {!versionMode ? <>