관리자 유저 관리 페이지 생성#272
Conversation
Walkthrough관리자 사용자 관리 페이지를 새로 구현했습니다. 사용자 검색, 차단/차단 해제, 페이지 네이션 기능을 포함하는 완전한 CRUD 인터페이스를 추가했으며, 관련 API 엔드포인트, React Query 훅, UI 컴포넌트들을 함께 구성했습니다. Changes
Sequence Diagram(s)sequenceDiagram
participant User as 사용자
participant UI as 관리자 페이지
participant Query as React Query
participant API as API 클라이언트
participant Server as 백엔드/Mock
User->>UI: 페이지 접속
UI->>Query: useAdminUsers 호출 (page=0)
Query->>API: getAdminUsers({ page: 0 })
API->>Server: GET /admin/users?page=0
Server-->>API: AdminUsersResponse
API-->>Query: 데이터 반환
Query-->>UI: users, currentPage, totalPages, isLoading
UI->>UI: 사용자 목록 렌더링
User->>UI: 검색어 입력 후 Enter
UI->>UI: searchQuery 상태 업데이트, 필터링 적용
UI->>UI: 필터된 사용자 목록 표시
User->>UI: 사용자 체크박스 선택
UI->>UI: useEmailSelection으로 선택 상태 관리
User->>UI: '차단' 버튼 클릭
UI->>UI: 확인 모달 표시
User->>UI: 확인
UI->>Query: blockMutation (selectedEmails)
Query->>API: blockUsers({ banList: [...] })
API->>Server: POST /admin/users/block
Server-->>API: 성공
API-->>Query: 응답
Query->>Query: "adminUsers" 캐시 무효화
UI->>Query: useAdminUsers 자동 갱신
Query->>API: getAdminUsers 다시 호출
API->>Server: GET /admin/users?page=currentPage
Server-->>API: 갱신된 AdminUsersResponse
API-->>Query: 데이터 반환
Query-->>UI: 업데이트된 목록
UI->>UI: 선택 상태 초기화, 목록 갱신
User->>UI: '차단 해제' 버튼 클릭
UI->>UI: 확인 모달 표시
User->>UI: 확인
UI->>Query: unblockMutation (selectedEmails)
Query->>API: unblockUsers({ emails: [...] })
API->>Server: POST /admin/users/unblock
Server-->>API: 성공
API-->>Query: 응답
Query->>Query: "adminUsers" 캐시 무효화
UI->>Query: useAdminUsers 자동 갱신
Query->>API: getAdminUsers 다시 호출
API->>Server: GET /admin/users
Server-->>API: 갱신된 AdminUsersResponse
API-->>Query: 데이터 반환
Query-->>UI: 업데이트된 목록
UI->>UI: 선택 상태 초기화, 목록 갱신
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
❌ 테스트 실패 테스트가 실패했습니다. Actions 로그를 확인해주세요. |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Fix all issues with AI agents
In `@service/app/src/app/admin/users/page.tsx`:
- Around line 35-48: The client-side filtering in filteredUsers (useMemo) using
searchQuery/users causes pagination mismatch with the backend-derived
totalPages; either move search to the backend by updating the API calls to
accept a search query and page parameter (so the data returned and totalPages
reflect the filtered results), or change the UI logic in page.tsx to detect
active searchQuery and hide/disable pagination controls (totalPages) while using
filteredUsers; locate and update the useMemo for filteredUsers and userEmails as
well as any component or prop that renders totalPages to implement the chosen
approach.
In `@service/app/src/entities/report/api/getAdminUsers.ts`:
- Around line 12-17: The current getAdminUsers implementation uses
response.json().then(...) which is inconsistent with getReportDetail and
getAdminReports; update getAdminUsers (and similarly getAdminReports) to use
const res = await response.json() and then return res.data, ensuring you await
response.json() instead of chaining .then; locate the fetch call in
getAdminUsers (the
fetchClient.get<AdminUsersResponse>(`admin/users${queryString}`) and the
variable response) and replace the .then pattern with the awaited assignment to
res and return res.data, keeping the existing page/queryString logic intact.
In `@service/app/src/mocks/handlers/admin.ts`:
- Around line 58-61: The blockedEmails Set is initialized empty while
generateMockUsers may produce users with a blocked flag, causing inconsistent
responses; inside createAdminHandlers after usersStore is created, iterate
usersStore (AdminUser objects) and add each user's email to blockedEmails when
user.blocked (or equivalent property) is true so blockedEmails and usersStore
start synchronized.
In `@shared/design/src/components/checkBox/index.tsx`:
- Line 97: The hidden input in the CheckBox component currently uses the classes
in className={cn("peer absolute size-0 opacity-0", className)} which can hide it
from screen readers; replace those classes with the accessible pattern used
elsewhere (use "peer sr-only") so the line becomes className={cn("peer sr-only",
className)} to match radioField and file upload components and ensure
WCAG-compatible screen-reader accessibility.
🧹 Nitpick comments (7)
service/app/src/entities/suspension/model/types.ts (1)
21-23: 이메일 형식 검증 추가 고려현재
z.string().min(1)은 빈 문자열만 방지합니다. Zod 4에서는z.email()을 사용하여 이메일 형식을 검증할 수 있습니다. 기존banListItemSchema와의 일관성을 위해 두 스키마 모두에 이메일 검증을 추가하는 것을 고려해 보세요.♻️ 제안된 수정
export const unblockUsersRequestSchema = z.object({ - emails: z.array(z.string().min(1)).min(1), + emails: z.array(z.email()).min(1), })service/app/src/widgets/admin/users-table/ui/UsersPageHeader.tsx (2)
25-30: 검색 버튼 추가 고려 (선택 사항)현재 Enter 키로만 검색이 트리거됩니다. 접근성과 사용성 향상을 위해 검색 아이콘 버튼을 추가하거나, 입력 필드의
leftIcon타입에 맞는 검색 아이콘에 클릭 이벤트를 연결하는 것을 고려해 보세요.
54-61: 선택된 사용자 없을 때 버튼 비활성화 고려
onBlock과onUnblock버튼이 선택 상태와 관계없이 항상 활성화되어 있습니다. 부모 컴포넌트에서 선택 상태를 관리하므로,disabledprop을 추가하여 선택된 사용자가 없을 때 버튼을 비활성화하는 것을 고려해 보세요.♻️ 제안된 수정
interface UsersPageHeaderProps { onSearch: (query: string) => void onBlock: () => void onUnblock: () => void + isSelectionEmpty?: boolean } export const UsersPageHeader = ({ onSearch, onBlock, onUnblock, + isSelectionEmpty = true, }: UsersPageHeaderProps) => {<div className="flex shrink-0 gap-16"> - <DestructiveSolidBoxButton size="lg" onClick={onBlock}> + <DestructiveSolidBoxButton size="lg" onClick={onBlock} disabled={isSelectionEmpty}> 차단 </DestructiveSolidBoxButton> - <PrimaryBoxButton size="lg" onClick={onUnblock}> + <PrimaryBoxButton size="lg" onClick={onUnblock} disabled={isSelectionEmpty}> 차단 해제 </PrimaryBoxButton> </div>service/app/src/shared/ui/admin-layout/AdminPageLayout.tsx (1)
1-38: 파일명을 camelCase로 변경하는 것을 권장합니다.코딩 가이드라인에 따르면 파일명은 camelCase여야 합니다.
AdminPageLayout.tsx→adminPageLayout.tsx로 변경을 고려해 주세요.그 외 컴포넌트 로직과 페이지네이션 조건부 렌더링은 잘 구현되어 있습니다.
As per coding guidelines: "All file names must be camelCase (not PascalCase or kebab-case)"
service/app/src/widgets/admin/users-table/ui/UsersTable.tsx (1)
1-81: 파일명을 camelCase로 변경하는 것을 권장합니다.
UsersTable.tsx→usersTable.tsx로 변경을 고려해 주세요.컴포넌트 구현은 깔끔하며, ARIA 레이블 적용과 select-all 로직이 잘 작성되어 있습니다.
As per coding guidelines: "All file names must be camelCase (not PascalCase or kebab-case)"
service/app/src/widgets/admin/users-table/ui/UserRow.tsx (1)
1-60: 파일명을 camelCase로 변경하는 것을 권장합니다.
UserRow.tsx→userRow.tsx로 변경을 고려해 주세요.
memo사용과blockReasonLabelnull 처리가 적절하게 구현되어 있습니다.As per coding guidelines: "All file names must be camelCase (not PascalCase or kebab-case)"
service/app/src/entities/report/hooks/useAdminUsers.ts (1)
24-33:queryFn단순화를 권장합니다.async 래퍼가 불필요합니다.
getAdminUsers를 직접 전달할 수 있습니다:♻️ 제안된 리팩토링
const { data, isLoading, error } = useQuery({ queryKey: ["adminUsers", { page }] as const, - queryFn: async () => { - const response = await getAdminUsers({ page }) - return response - }, + queryFn: () => getAdminUsers({ page }), enabled, staleTime: 5 * 60 * 1000, retry: 2, })
| const filteredUsers = useMemo(() => { | ||
| if (!searchQuery) return users | ||
| const query = searchQuery.toLowerCase() | ||
| return users.filter( | ||
| (user) => | ||
| user.nickname.toLowerCase().includes(query) || | ||
| user.email.toLowerCase().includes(query), | ||
| ) | ||
| }, [users, searchQuery]) | ||
|
|
||
| const userEmails = useMemo( | ||
| () => filteredUsers.map((u) => u.email), | ||
| [filteredUsers], | ||
| ) |
There was a problem hiding this comment.
프론트엔드 필터링과 백엔드 페이지네이션 간의 불일치 문제가 있습니다.
filteredUsers는 클라이언트에서 필터링되지만, totalPages는 백엔드 전체 데이터 기준입니다. 검색 시 필터링된 결과 수와 페이지네이션이 맞지 않아 사용자 혼란을 야기할 수 있습니다.
예: 100명의 유저가 10페이지에 걸쳐 있을 때 검색으로 2명만 필터링되어도 여전히 10페이지가 표시됩니다.
권장 해결 방안:
- 검색을 백엔드 API로 이동 (
?search=query&page=n) - 또는 검색 중에는 페이지네이션을 숨기거나 비활성화
Also applies to: 94-96
🤖 Prompt for AI Agents
In `@service/app/src/app/admin/users/page.tsx` around lines 35 - 48, The
client-side filtering in filteredUsers (useMemo) using searchQuery/users causes
pagination mismatch with the backend-derived totalPages; either move search to
the backend by updating the API calls to accept a search query and page
parameter (so the data returned and totalPages reflect the filtered results), or
change the UI logic in page.tsx to detect active searchQuery and hide/disable
pagination controls (totalPages) while using filteredUsers; locate and update
the useMemo for filteredUsers and userEmails as well as any component or prop
that renders totalPages to implement the chosen approach.
| const { page = 0 } = params | ||
| const queryString = page > 0 ? `?page=${page}` : "" | ||
| const response = await fetchClient.get<AdminUsersResponse>( | ||
| `admin/users${queryString}`, | ||
| ) | ||
| return response.json().then((res) => res.data) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's examine the actual file
cat -n service/app/src/entities/report/api/getAdminUsers.tsRepository: JECT-Study/JECT-5-FE
Length of output: 704
🏁 Script executed:
# Check for similar query string patterns in other API files
rg -n "queryString.*page|page.*queryString" --type=ts service/app/src/entities/ -A 2 -B 2Repository: JECT-Study/JECT-5-FE
Length of output: 1070
🏁 Script executed:
# Look for pagination patterns in API files
rg -n "page.*\?" --type=ts service/app/src/entities/ | head -20Repository: JECT-Study/JECT-5-FE
Length of output: 909
🏁 Script executed:
# Check async patterns - .json().then() vs await in API files
rg -n "\.json\(\)\.then\(" --type=ts service/app/src/entities/ -A 1 -B 1Repository: JECT-Study/JECT-5-FE
Length of output: 518
🏁 Script executed:
# Check for other response.json() patterns to understand codebase conventions
rg -n "response\.json\(\)" --type=ts service/app/src/entities/ -A 2 | head -40Repository: JECT-Study/JECT-5-FE
Length of output: 2086
response.json().then() 패턴을 await로 통일하세요.
같은 report 엔티티의 getReportDetail.ts에서는 const res = await response.json() 패턴을 사용하고 있으므로, 일관성을 위해 이 파일도 동일하게 변경해야 합니다. getAdminReports.ts도 같은 패턴을 사용하므로 함께 수정하세요.
♻️ 제안된 수정
- return response.json().then((res) => res.data)
+ const res = await response.json()
+ return res.data참고: page > 0 조건의 의도(0-인덱싱 여부)는 백엔드 API 사양과 검증 필요합니다.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const { page = 0 } = params | |
| const queryString = page > 0 ? `?page=${page}` : "" | |
| const response = await fetchClient.get<AdminUsersResponse>( | |
| `admin/users${queryString}`, | |
| ) | |
| return response.json().then((res) => res.data) | |
| const { page = 0 } = params | |
| const queryString = page > 0 ? `?page=${page}` : "" | |
| const response = await fetchClient.get<AdminUsersResponse>( | |
| `admin/users${queryString}`, | |
| ) | |
| const res = await response.json() | |
| return res.data |
🤖 Prompt for AI Agents
In `@service/app/src/entities/report/api/getAdminUsers.ts` around lines 12 - 17,
The current getAdminUsers implementation uses response.json().then(...) which is
inconsistent with getReportDetail and getAdminReports; update getAdminUsers (and
similarly getAdminReports) to use const res = await response.json() and then
return res.data, ensuring you await response.json() instead of chaining .then;
locate the fetch call in getAdminUsers (the
fetchClient.get<AdminUsersResponse>(`admin/users${queryString}`) and the
variable response) and replace the .then pattern with the awaited assignment to
res and return res.data, keeping the existing page/queryString logic intact.
| export const createAdminHandlers = (initialReports: AdminReport[]) => { | ||
| const store: AdminReport[] = structuredClone(initialReports) | ||
| const usersStore: AdminUser[] = generateMockUsers(32) | ||
| const blockedEmails = new Set<string>() |
There was a problem hiding this comment.
초기 blocked 상태와 blockedEmails 동기화가 필요합니다.
usersStore에 이미 차단된 유저가 존재하는데 blockedEmails는 빈 상태라, 일부 응답에서 차단 상태가 불일치할 수 있습니다. 초기화 시 동기화해 두는 편이 안전합니다.
🔧 제안 수정안
- const usersStore: AdminUser[] = generateMockUsers(32)
- const blockedEmails = new Set<string>()
+ const usersStore: AdminUser[] = generateMockUsers(32)
+ const blockedEmails = new Set(
+ usersStore.filter((user) => user.blocked).map((user) => user.email),
+ )🤖 Prompt for AI Agents
In `@service/app/src/mocks/handlers/admin.ts` around lines 58 - 61, The
blockedEmails Set is initialized empty while generateMockUsers may produce users
with a blocked flag, causing inconsistent responses; inside createAdminHandlers
after usersStore is created, iterate usersStore (AdminUser objects) and add each
user's email to blockedEmails when user.blocked (or equivalent property) is true
so blockedEmails and usersStore start synchronized.
| id={id} | ||
| type="checkbox" | ||
| className={cn("peer sr-only", className)} | ||
| className={cn("peer absolute size-0 opacity-0", className)} |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n shared/design/src/components/checkBox/index.tsx | head -100 | tail -20Repository: JECT-Study/JECT-5-FE
Length of output: 744
🌐 Web query:
Tailwind CSS sr-only vs absolute size-0 opacity-0 accessibility screen reader hidden input
💡 Result:
What sr-only does (Tailwind)
Tailwind’s sr-only is the classic “visually hidden but still in the accessibility tree” pattern: it keeps the element present for screen readers while making it effectively invisible on screen using position:absolute, 1px sizing, clipping, and overflow hidden. (v3.tailwindcss.com)
Why absolute + size-0 is risky
Setting an element to width: 0 / height: 0 (or similar “0px sizing” tricks) is not recommended if you want screen readers to read it—many AT/browser combinations will effectively ignore it. (webaim.org)
opacity-0 (and why it can be a problem)
opacity: 0 generally does not remove the element from the accessibility tree; it can still be focusable/tabbable and announced, which can create “ghost” focus targets if you don’t manage focus and interaction carefully. (ionic.io)
What actually hides from screen readers
display: none/visibility: hiddenremove content from screen readers (and the accessibility tree). (developer.mozilla.org)aria-hidden="true"removes content from the accessibility API, and should not be used on focusable elements. (developer.mozilla.org)
Practical guidance (common “hidden input” cases)
- Hidden labels/instructions (text): use
sr-only. (v3.tailwindcss.com) - Custom checkbox/radio (real input should remain accessible): avoid
size-0; use a “visually hidden” pattern likesr-onlyso the input remains operable and announced. (v3.tailwindcss.com) - Hiding something visually but keeping it available to AT: prefer clipping/off-screen techniques (
sr-only) overopacity-0unless you intentionally want it focusable while invisible. (v3.tailwindcss.com)
Citations:
- 1: https://v3.tailwindcss.com/docs/screen-readers?utm_source=openai
- 2: https://webaim.org/techniques/css/invisiblecontent/?utm_source=openai
- 3: https://ionic.io/docs/accessibility/visibility?utm_source=openai
- 4: https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/Accessibility/CSS_and_JavaScript?utm_source=openai
- 5: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-hidden?utm_source=openai
- 6: https://v3.tailwindcss.com/docs/screen-readers?utm_source=openai
- 7: https://v3.tailwindcss.com/docs/screen-readers?utm_source=openai
- 8: https://v3.tailwindcss.com/docs/screen-readers?utm_source=openai
🌐 Web query:
sr-only class screen reader accessibility best practices form inputs
💡 Result:
Best practices for using an sr-only / “visually hidden” class with form inputs
- Prefer a real
<label>for every input and programmatically associate it with the control usingfor+ matchingid. If you don’t want the label visible, hide the label visually (e.g., withsr-only), but keep it in the DOM. [1] - Don’t hide labels with
display:noneorvisibility:hidden—that removes them from the accessibility tree for assistive tech. Use a “visually hidden” CSS technique instead. [1] - Avoid relying on
placeholderas the label. Placeholders are fallback naming, can be low quality, and disappear when users type; use<label>as the primary method. [3] - Use
aria-labelonly when the label is genuinely clear from surrounding context and you intentionally have no visible label; otherwise a<label>is usually better. [1] - If there is visible label text, make sure the accessible name matches/includes it (important for speech recognition; WCAG “Label in Name”). Don’t override a visible label with a different
aria-label. [2], [4]
Common “sr-only / visually hidden” CSS pattern (example)
.sr-only {
border: 0;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
}(Concept shown in WAI’s “visually hidden” label example.) [1]
Example: hidden label, accessible input
<label for="email" class="sr-only">Email address</label>
<input id="email" name="email" type="email" autocomplete="email">[1]
Sources: W3C WAI Forms tutorial (labels + hiding labels correctly) [1]; W3C WCAG techniques for “Label in Name” [2], [4].
🏁 Script executed:
rg "sr-only" shared/design/src/components/ -A 2 -B 2Repository: JECT-Study/JECT-5-FE
Length of output: 2038
커스텀 체크박스의 숨겨진 입력 요소는 sr-only를 사용해야 합니다
absolute size-0 opacity-0은 스크린 리더에서 요소를 무시하는 경향이 있어 접근성 기준 (WCAG)을 충족하지 않습니다. 같은 디자인 시스템 내 라디오버튼 컴포넌트(radioField/index.tsx)와 파일 업로드 컴포넌트에서는 peer sr-only를 사용 중이므로, 체크박스도 동일하게 변경하세요: className={cn("peer sr-only", className)}
🤖 Prompt for AI Agents
In `@shared/design/src/components/checkBox/index.tsx` at line 97, The hidden input
in the CheckBox component currently uses the classes in className={cn("peer
absolute size-0 opacity-0", className)} which can hide it from screen readers;
replace those classes with the accessible pattern used elsewhere (use "peer
sr-only") so the line becomes className={cn("peer sr-only", className)} to match
radioField and file upload components and ensure WCAG-compatible screen-reader
accessibility.
|
Deploy preview for re-creation ready! ✅ Preview Built with commit f4d5b35. |
📝 설명
🛠️ 주요 변경 사항
리뷰 시 고려해야 할 사항
Summary by CodeRabbit
릴리스 노트
New Features
Style
✏️ Tip: You can customize this high-level summary in your review settings.