-
Notifications
You must be signed in to change notification settings - Fork 0
관리자 유저 관리 페이지 생성 #272
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
관리자 유저 관리 페이지 생성 #272
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,106 @@ | ||
| "use client" | ||
|
|
||
| import { useCallback, useMemo, useState } from "react" | ||
|
|
||
| import { useAdminUsers } from "@/entities/report/hooks/useAdminUsers" | ||
| import { AdminPageLayout } from "@/shared/ui/admin-layout" | ||
| import { | ||
| useEmailSelection, | ||
| UsersPageHeader, | ||
| UsersTable, | ||
| useUserBlockMutations, | ||
| } from "@/widgets/admin/users-table" | ||
|
|
||
| import { openBlockConfirmModal } from "./_components/dialog/blockConfirmModal" | ||
| import { openUnblockConfirmModal } from "./_components/dialog/unblockConfirmModal" | ||
|
|
||
| export default function AdminUsersPage() { | ||
| return <div>AdminPage-Users</div> | ||
| const [currentPage, setCurrentPage] = useState(1) | ||
| const [searchQuery, setSearchQuery] = useState("") | ||
| const pageIndex = currentPage - 1 | ||
|
|
||
| const { users = [], totalPages } = useAdminUsers({ | ||
| page: pageIndex, | ||
| }) | ||
|
|
||
| const { | ||
| selectedEmails, | ||
| handleSelectChange, | ||
| handleSelectAll, | ||
| clearSelection, | ||
| } = useEmailSelection() | ||
|
|
||
| const { blockMutation, unblockMutation } = useUserBlockMutations() | ||
|
|
||
| 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], | ||
| ) | ||
|
|
||
| const handlePageChange = useCallback( | ||
| (page: number) => { | ||
| setCurrentPage(page) | ||
| clearSelection() | ||
| }, | ||
| [clearSelection], | ||
| ) | ||
|
|
||
| const handleSelectAllUsers = useCallback( | ||
| (checked: boolean) => { | ||
| handleSelectAll(userEmails, checked) | ||
| }, | ||
| [handleSelectAll, userEmails], | ||
| ) | ||
|
|
||
| const handleBlock = useCallback(() => { | ||
| openBlockConfirmModal({ | ||
| onConfirm: () => { | ||
| blockMutation.mutate(Array.from(selectedEmails), { | ||
| onSuccess: clearSelection, | ||
| }) | ||
| }, | ||
| }) | ||
| }, [blockMutation, selectedEmails, clearSelection]) | ||
|
|
||
| const handleUnblock = useCallback(() => { | ||
| openUnblockConfirmModal({ | ||
| onConfirm: () => { | ||
| unblockMutation.mutate(Array.from(selectedEmails), { | ||
| onSuccess: clearSelection, | ||
| }) | ||
| }, | ||
| }) | ||
| }, [unblockMutation, selectedEmails, clearSelection]) | ||
|
|
||
| return ( | ||
| <AdminPageLayout | ||
| header={ | ||
| <UsersPageHeader | ||
| onSearch={setSearchQuery} | ||
| onBlock={handleBlock} | ||
| onUnblock={handleUnblock} | ||
| /> | ||
| } | ||
| currentPage={currentPage} | ||
| totalPages={totalPages} | ||
| onPageChange={handlePageChange} | ||
| > | ||
| <UsersTable | ||
| items={filteredUsers} | ||
| selectedEmails={selectedEmails} | ||
| onSelectChange={handleSelectChange} | ||
| onSelectAll={handleSelectAllUsers} | ||
| /> | ||
| </AdminPageLayout> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,18 @@ | ||||||||||||||||||||||||||||
| import { fetchClient } from "@/shared/api/fetchClient" | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| import type { AdminUsersResponse } from "../model/types" | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| export interface GetAdminUsersParams { | ||||||||||||||||||||||||||||
| page?: number | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| export const getAdminUsers = async ( | ||||||||||||||||||||||||||||
| params: GetAdminUsersParams = {}, | ||||||||||||||||||||||||||||
| ): Promise<AdminUsersResponse> => { | ||||||||||||||||||||||||||||
| 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) | ||||||||||||||||||||||||||||
|
Comment on lines
+12
to
+17
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 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
같은 report 엔티티의 ♻️ 제안된 수정- return response.json().then((res) => res.data)
+ const res = await response.json()
+ return res.data참고: 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| export * from "./getAdminReports" | ||
| export * from "./getAdminUsers" | ||
| export * from "./getReportDetail" | ||
| export * from "./updateAdminReportStatus" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| export * from "./useAdminReports" | ||
| export * from "./useAdminUsers" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import { useQuery } from "@tanstack/react-query" | ||
|
|
||
| import { getAdminUsers } from "../api/getAdminUsers" | ||
| import type { AdminUser } from "../model/types" | ||
|
|
||
| interface UseAdminUsersParams { | ||
| page?: number | ||
| enabled?: boolean | ||
| } | ||
|
|
||
| interface UseAdminUsersReturn { | ||
| users: AdminUser[] | ||
| currentPage: number | ||
| totalPages: number | ||
| totalElements: number | ||
| isLoading: boolean | ||
| error: Error | null | ||
| } | ||
|
|
||
| export const useAdminUsers = ({ | ||
| page = 0, | ||
| enabled = true, | ||
| }: UseAdminUsersParams = {}): UseAdminUsersReturn => { | ||
| const { data, isLoading, error } = useQuery({ | ||
| queryKey: ["adminUsers", { page }] as const, | ||
| queryFn: async () => { | ||
| const response = await getAdminUsers({ page }) | ||
| return response | ||
| }, | ||
| enabled, | ||
| staleTime: 5 * 60 * 1000, | ||
| retry: 2, | ||
| }) | ||
|
|
||
| return { | ||
| users: data?.content ?? [], | ||
| currentPage: data?.page ?? page, | ||
| totalPages: data?.totalPages ?? 0, | ||
| totalElements: data?.totalElements ?? 0, | ||
| isLoading, | ||
| error: error as Error | null, | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| export * from "./blockUsers" | ||
| export * from "./unblockUsers" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| import { fetchClient } from "@/shared/api/fetchClient" | ||
|
|
||
| import type { UnblockUsersRequest } from "../model/types" | ||
|
|
||
| export const unblockUsers = async (payload: UnblockUsersRequest) => { | ||
| const response = await fetchClient.post<null>("admin/users/unblock", { | ||
| json: payload, | ||
| }) | ||
|
|
||
| return response.json() | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,12 +3,17 @@ import { http, HttpResponse } from "msw" | |
| import type { | ||
| AdminReport, | ||
| AdminReportsResponse, | ||
| AdminUser, | ||
| AdminUsersResponse, | ||
| } from "@/entities/report/model/types" | ||
| import { | ||
| adminReportUpdateRequestSchema, | ||
| reportIdParamsSchema, | ||
| } from "@/entities/report/model/types" | ||
| import { blockUsersRequestSchema } from "@/entities/suspension/model/types" | ||
| import { | ||
| blockUsersRequestSchema, | ||
| unblockUsersRequestSchema, | ||
| } from "@/entities/suspension/model/types" | ||
|
|
||
| import { internalServerError, loginRequiredError } from "../data/common" | ||
| import { | ||
|
|
@@ -24,8 +29,35 @@ const MSW_BASE_URL = process.env.MSW_BASE_URL || "http://localhost:3000" | |
|
|
||
| const PAGE_SIZE = 7 | ||
|
|
||
| const BLOCK_REASONS = [ | ||
| "VIOLENT_OR_DISTURBING_CONTENT", | ||
| "SEXUAL_CONTENT", | ||
| "CYBERBULLYING_OR_HARASSMENT", | ||
| "SUICIDE_OR_SELF_HARM", | ||
| "FRAUD_OR_MISINFORMATION", | ||
| "SPAM_OR_PROMOTION", | ||
| "PRIVACY_VIOLATION", | ||
| "INTELLECTUAL_PROPERTY_INFRINGEMENT", | ||
| ] as const | ||
|
|
||
| const generateMockUsers = (count: number): AdminUser[] => { | ||
| return Array.from({ length: count }, (_, i) => { | ||
| const blocked = i % 3 === 0 | ||
| return { | ||
| nickname: `user_${i + 1}`, | ||
| email: `user${i + 1}@example.com`, | ||
| blockReason: blocked ? BLOCK_REASONS[i % BLOCK_REASONS.length] : null, | ||
| blockedAt: blocked | ||
| ? `2026-01-${String((i % 28) + 1).padStart(2, "0")}` | ||
| : null, | ||
| blocked, | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| export const createAdminHandlers = (initialReports: AdminReport[]) => { | ||
| const store: AdminReport[] = structuredClone(initialReports) | ||
| const usersStore: AdminUser[] = generateMockUsers(32) | ||
| const blockedEmails = new Set<string>() | ||
|
Comment on lines
58
to
61
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 초기 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 |
||
|
|
||
| return [ | ||
|
|
@@ -170,14 +202,93 @@ export const createAdminHandlers = (initialReports: AdminReport[]) => { | |
| return HttpResponse.json(gameMissingFieldsError(), { status: 400 }) | ||
| } | ||
|
|
||
| parsed.data.banList.forEach(({ email }) => { | ||
| parsed.data.banList.forEach(({ email, reason }) => { | ||
| blockedEmails.add(email) | ||
| const userIndex = usersStore.findIndex((u) => u.email === email) | ||
| if (userIndex !== -1) { | ||
| usersStore[userIndex] = { | ||
| ...usersStore[userIndex], | ||
| blocked: true, | ||
| blockReason: | ||
| (reason as (typeof BLOCK_REASONS)[number]) || | ||
| "VIOLENT_OR_DISTURBING_CONTENT", | ||
| blockedAt: new Date().toISOString().split("T")[0], | ||
| } | ||
| } | ||
| }) | ||
|
|
||
| return HttpResponse.json(gameSuccessResponse()) | ||
| } catch { | ||
| return HttpResponse.json(internalServerError, { status: 500 }) | ||
| } | ||
| }), | ||
|
|
||
| http.post(`${MSW_BASE_URL}/admin/users/unblock`, async ({ request }) => { | ||
| try { | ||
| const cookieHeader = request.headers.get("Cookie") | ||
| if (!validateSessionCookie(cookieHeader)) { | ||
| return HttpResponse.json(loginRequiredError, { status: 401 }) | ||
| } | ||
|
|
||
| const json = await request.json() | ||
| const parsed = unblockUsersRequestSchema.safeParse(json) | ||
|
|
||
| if (!parsed.success) { | ||
| return HttpResponse.json(gameMissingFieldsError(), { status: 400 }) | ||
| } | ||
|
|
||
| parsed.data.emails.forEach((email) => { | ||
| blockedEmails.delete(email) | ||
| const userIndex = usersStore.findIndex((u) => u.email === email) | ||
| if (userIndex !== -1) { | ||
| usersStore[userIndex] = { | ||
| ...usersStore[userIndex], | ||
| blocked: false, | ||
| blockReason: null, | ||
| blockedAt: null, | ||
| } | ||
| } | ||
| }) | ||
|
|
||
| return HttpResponse.json(gameSuccessResponse()) | ||
| } catch { | ||
| return HttpResponse.json(internalServerError, { status: 500 }) | ||
| } | ||
| }), | ||
|
|
||
| http.get(`${MSW_BASE_URL}/admin/users`, ({ request }) => { | ||
| try { | ||
| const cookieHeader = request.headers.get("Cookie") | ||
| if (!validateSessionCookie(cookieHeader)) { | ||
| return HttpResponse.json(loginRequiredError, { status: 401 }) | ||
| } | ||
|
|
||
| const url = new URL(request.url) | ||
| const pageParam = url.searchParams.get("page") | ||
| const page = pageParam ? Math.max(0, parseInt(pageParam, 10)) : 0 | ||
|
|
||
| const totalElements = usersStore.length | ||
| const totalPages = Math.ceil(totalElements / PAGE_SIZE) | ||
|
|
||
| const startIndex = page * PAGE_SIZE | ||
| const endIndex = startIndex + PAGE_SIZE | ||
|
|
||
| const paginatedUsers = usersStore.slice(startIndex, endIndex) | ||
| const hasNext = page < totalPages - 1 | ||
|
|
||
| const response: AdminUsersResponse = { | ||
| content: paginatedUsers, | ||
| page, | ||
| size: PAGE_SIZE, | ||
| totalElements, | ||
| totalPages, | ||
| hasNext, | ||
| } | ||
|
|
||
| return HttpResponse.json(generateSuccessResponse(response)) | ||
| } catch { | ||
| return HttpResponse.json(internalServerError, { status: 500 }) | ||
| } | ||
| }), | ||
| ] | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
프론트엔드 필터링과 백엔드 페이지네이션 간의 불일치 문제가 있습니다.
filteredUsers는 클라이언트에서 필터링되지만,totalPages는 백엔드 전체 데이터 기준입니다. 검색 시 필터링된 결과 수와 페이지네이션이 맞지 않아 사용자 혼란을 야기할 수 있습니다.예: 100명의 유저가 10페이지에 걸쳐 있을 때 검색으로 2명만 필터링되어도 여전히 10페이지가 표시됩니다.
권장 해결 방안:
?search=query&page=n)Also applies to: 94-96
🤖 Prompt for AI Agents