diff --git a/service/app/src/app/admin/reports/page.tsx b/service/app/src/app/admin/reports/page.tsx index 939d171f..39ad0b2f 100644 --- a/service/app/src/app/admin/reports/page.tsx +++ b/service/app/src/app/admin/reports/page.tsx @@ -29,7 +29,9 @@ export default function AdminReportsPage() {
-

신고접수

+

+ 신고접수 +

diff --git a/service/app/src/app/admin/users/page.tsx b/service/app/src/app/admin/users/page.tsx index e892220f..cfc14aec 100644 --- a/service/app/src/app/admin/users/page.tsx +++ b/service/app/src/app/admin/users/page.tsx @@ -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
AdminPage-Users
+ 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 ( + + } + currentPage={currentPage} + totalPages={totalPages} + onPageChange={handlePageChange} + > + + + ) } diff --git a/service/app/src/entities/report/api/getAdminUsers.ts b/service/app/src/entities/report/api/getAdminUsers.ts new file mode 100644 index 00000000..cd16e895 --- /dev/null +++ b/service/app/src/entities/report/api/getAdminUsers.ts @@ -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 => { + const { page = 0 } = params + const queryString = page > 0 ? `?page=${page}` : "" + const response = await fetchClient.get( + `admin/users${queryString}`, + ) + return response.json().then((res) => res.data) +} diff --git a/service/app/src/entities/report/api/index.ts b/service/app/src/entities/report/api/index.ts index dcf16248..40ca01f3 100644 --- a/service/app/src/entities/report/api/index.ts +++ b/service/app/src/entities/report/api/index.ts @@ -1,3 +1,4 @@ export * from "./getAdminReports" +export * from "./getAdminUsers" export * from "./getReportDetail" export * from "./updateAdminReportStatus" diff --git a/service/app/src/entities/report/hooks/index.ts b/service/app/src/entities/report/hooks/index.ts index 97e7dc3d..c1c4b27c 100644 --- a/service/app/src/entities/report/hooks/index.ts +++ b/service/app/src/entities/report/hooks/index.ts @@ -1 +1,2 @@ export * from "./useAdminReports" +export * from "./useAdminUsers" diff --git a/service/app/src/entities/report/hooks/useAdminUsers.ts b/service/app/src/entities/report/hooks/useAdminUsers.ts new file mode 100644 index 00000000..f60e4e11 --- /dev/null +++ b/service/app/src/entities/report/hooks/useAdminUsers.ts @@ -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, + } +} diff --git a/service/app/src/entities/report/model/types.ts b/service/app/src/entities/report/model/types.ts index 21323aa0..adb59aa0 100644 --- a/service/app/src/entities/report/model/types.ts +++ b/service/app/src/entities/report/model/types.ts @@ -1,6 +1,32 @@ import z from "zod" import type { ReportReasonCode } from "@/entities/game/model/report" +import { reportReasonCodeSchema } from "@/entities/game/model/report" + +export interface AdminUser { + nickname: string + email: string + blockReason: ReportReasonCode | null + blockedAt: string | null + blocked: boolean +} + +export const adminUserSchema = z.object({ + nickname: z.string(), + email: z.string(), + blockReason: reportReasonCodeSchema.nullable(), + blockedAt: z.string().nullable(), + blocked: z.boolean(), +}) + +export interface AdminUsersResponse { + content: AdminUser[] + page: number + size: number + totalElements: number + totalPages: number + hasNext: boolean +} export interface ReportedGame { id: number diff --git a/service/app/src/entities/suspension/api/index.ts b/service/app/src/entities/suspension/api/index.ts index 26352323..67c9c1d3 100644 --- a/service/app/src/entities/suspension/api/index.ts +++ b/service/app/src/entities/suspension/api/index.ts @@ -1 +1,2 @@ export * from "./blockUsers" +export * from "./unblockUsers" diff --git a/service/app/src/entities/suspension/api/unblockUsers.ts b/service/app/src/entities/suspension/api/unblockUsers.ts new file mode 100644 index 00000000..98578a5f --- /dev/null +++ b/service/app/src/entities/suspension/api/unblockUsers.ts @@ -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("admin/users/unblock", { + json: payload, + }) + + return response.json() +} diff --git a/service/app/src/entities/suspension/model/types.ts b/service/app/src/entities/suspension/model/types.ts index c53b6cf8..918eb8d2 100644 --- a/service/app/src/entities/suspension/model/types.ts +++ b/service/app/src/entities/suspension/model/types.ts @@ -18,5 +18,10 @@ export const blockUsersRequestSchema = z.object({ banList: z.array(banListItemSchema).min(1), }) +export const unblockUsersRequestSchema = z.object({ + emails: z.array(z.string().min(1)).min(1), +}) + export type BanListItem = z.infer export type BlockUsersRequest = z.infer +export type UnblockUsersRequest = z.infer diff --git a/service/app/src/mocks/handlers/admin.ts b/service/app/src/mocks/handlers/admin.ts index 228d0f4a..e5c9bad3 100644 --- a/service/app/src/mocks/handlers/admin.ts +++ b/service/app/src/mocks/handlers/admin.ts @@ -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() return [ @@ -170,8 +202,19 @@ 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()) @@ -179,5 +222,73 @@ export const createAdminHandlers = (initialReports: AdminReport[]) => { 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 }) + } + }), ] } diff --git a/service/app/src/shared/ui/admin-layout/AdminPageLayout.tsx b/service/app/src/shared/ui/admin-layout/AdminPageLayout.tsx new file mode 100644 index 00000000..6fcf310b --- /dev/null +++ b/service/app/src/shared/ui/admin-layout/AdminPageLayout.tsx @@ -0,0 +1,38 @@ +import { Pagination } from "@ject-5-fe/design/components/pagination" +import type { ReactNode } from "react" + +interface AdminPageLayoutProps { + header: ReactNode + children: ReactNode + currentPage: number + totalPages: number + onPageChange: (page: number) => void +} + +export const AdminPageLayout = ({ + header, + children, + currentPage, + totalPages, + onPageChange, +}: AdminPageLayoutProps) => { + return ( +
+
+
+ {header} + {children} +
+
+ {totalPages > 0 && ( + + )} +
+
+
+ ) +} diff --git a/service/app/src/shared/ui/admin-layout/index.ts b/service/app/src/shared/ui/admin-layout/index.ts new file mode 100644 index 00000000..c6b0ce10 --- /dev/null +++ b/service/app/src/shared/ui/admin-layout/index.ts @@ -0,0 +1 @@ +export { AdminPageLayout } from "./AdminPageLayout" diff --git a/service/app/src/widgets/admin/users-table/hooks/useEmailSelection.ts b/service/app/src/widgets/admin/users-table/hooks/useEmailSelection.ts new file mode 100644 index 00000000..fee43062 --- /dev/null +++ b/service/app/src/widgets/admin/users-table/hooks/useEmailSelection.ts @@ -0,0 +1,43 @@ +import { useCallback, useState } from "react" + +interface UseEmailSelectionReturn { + selectedEmails: Set + handleSelectChange: (email: string, checked: boolean) => void + handleSelectAll: (emails: string[], checked: boolean) => void + clearSelection: () => void +} + +export const useEmailSelection = (): UseEmailSelectionReturn => { + const [selectedEmails, setSelectedEmails] = useState>(new Set()) + + const handleSelectChange = useCallback((email: string, checked: boolean) => { + setSelectedEmails((prev) => { + const next = new Set(prev) + if (checked) { + next.add(email) + } else { + next.delete(email) + } + return next + }) + }, []) + + const handleSelectAll = useCallback((emails: string[], checked: boolean) => { + if (checked) { + setSelectedEmails(new Set(emails)) + } else { + setSelectedEmails(new Set()) + } + }, []) + + const clearSelection = useCallback(() => { + setSelectedEmails(new Set()) + }, []) + + return { + selectedEmails, + handleSelectChange, + handleSelectAll, + clearSelection, + } +} diff --git a/service/app/src/widgets/admin/users-table/hooks/useUserBlockMutations.ts b/service/app/src/widgets/admin/users-table/hooks/useUserBlockMutations.ts new file mode 100644 index 00000000..15f4f0fe --- /dev/null +++ b/service/app/src/widgets/admin/users-table/hooks/useUserBlockMutations.ts @@ -0,0 +1,33 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query" + +import { blockUsers } from "@/entities/suspension/api/blockUsers" +import { unblockUsers } from "@/entities/suspension/api/unblockUsers" + +export const useUserBlockMutations = () => { + const queryClient = useQueryClient() + + const blockMutation = useMutation({ + mutationFn: (emails: string[]) => + blockUsers({ + banList: emails.map((email) => ({ email })), + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["adminUsers"] }) + }, + }) + + const unblockMutation = useMutation({ + mutationFn: (emails: string[]) => + unblockUsers({ + emails, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["adminUsers"] }) + }, + }) + + return { + blockMutation, + unblockMutation, + } +} diff --git a/service/app/src/widgets/admin/users-table/index.ts b/service/app/src/widgets/admin/users-table/index.ts new file mode 100644 index 00000000..a653c7a7 --- /dev/null +++ b/service/app/src/widgets/admin/users-table/index.ts @@ -0,0 +1,4 @@ +export { useEmailSelection } from "./hooks/useEmailSelection" +export { useUserBlockMutations } from "./hooks/useUserBlockMutations" +export { UsersPageHeader } from "./ui/UsersPageHeader" +export { UsersTable } from "./ui/UsersTable" diff --git a/service/app/src/widgets/admin/users-table/ui/UserRow.tsx b/service/app/src/widgets/admin/users-table/ui/UserRow.tsx new file mode 100644 index 00000000..58c8b3a8 --- /dev/null +++ b/service/app/src/widgets/admin/users-table/ui/UserRow.tsx @@ -0,0 +1,60 @@ +import { Checkbox } from "@ject-5-fe/design/components/checkBox" +import { memo } from "react" + +import { REPORT_REASON_LABELS } from "@/entities/game/model/report" +import type { AdminUser } from "@/entities/report/model/types" +import { + AdminTableGroup, + AdminTableRow, + AdminTableText, +} from "@/shared/ui/admin-table" + +interface UserRowProps { + item: AdminUser + selected: boolean + onSelect: (email: string, checked: boolean) => void +} + +export const UserRow = memo(function UserRow({ + item, + selected, + onSelect, +}: UserRowProps) { + const blockReasonLabel = item.blockReason + ? REPORT_REASON_LABELS[item.blockReason] + : "" + + return ( +
+
+ onSelect(item.email, checked)} + /> +
+ + + + {item.nickname} + + + {item.email} + + + + + + {blockReasonLabel} + + + {item.blockedAt ?? ""} + + + + + {item.blocked ? "차단됨" : ""} + + +
+ ) +}) diff --git a/service/app/src/widgets/admin/users-table/ui/UsersPageHeader.tsx b/service/app/src/widgets/admin/users-table/ui/UsersPageHeader.tsx new file mode 100644 index 00000000..40b5073b --- /dev/null +++ b/service/app/src/widgets/admin/users-table/ui/UsersPageHeader.tsx @@ -0,0 +1,64 @@ +import { + DestructiveSolidBoxButton, + PrimaryBoxButton, +} from "@ject-5-fe/design/components/button" +import { + Control as InputControl, + Field as InputField, + Root as InputRoot, +} from "@ject-5-fe/design/components/input" +import { useState } from "react" + +interface UsersPageHeaderProps { + onSearch: (query: string) => void + onBlock: () => void + onUnblock: () => void +} + +export const UsersPageHeader = ({ + onSearch, + onBlock, + onUnblock, +}: UsersPageHeaderProps) => { + const [inputValue, setInputValue] = useState("") + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault() + onSearch(inputValue.trim()) + } + } + + return ( +
+
+

+ 사용자 관리 +

+ + + + + +
+
+ + 차단 + + + 차단 해제 + +
+
+ ) +} diff --git a/service/app/src/widgets/admin/users-table/ui/UsersTable.tsx b/service/app/src/widgets/admin/users-table/ui/UsersTable.tsx new file mode 100644 index 00000000..3157c077 --- /dev/null +++ b/service/app/src/widgets/admin/users-table/ui/UsersTable.tsx @@ -0,0 +1,81 @@ +import { Checkbox } from "@ject-5-fe/design/components/checkBox" + +import type { AdminUser } from "@/entities/report/model/types" +import { + AdminTableGroup, + AdminTableRow, + AdminTableText, +} from "@/shared/ui/admin-table" + +import { UserRow } from "./UserRow" + +interface UsersTableProps { + items: AdminUser[] + selectedEmails: Set + onSelectChange: (email: string, checked: boolean) => void + onSelectAll: (checked: boolean) => void +} + +export const UsersTable = ({ + items, + selectedEmails, + onSelectChange, + onSelectAll, +}: UsersTableProps) => { + const allSelected = + items.length > 0 && items.every((item) => selectedEmails.has(item.email)) + + return ( +
+
+
+ +
+ + + + 이름 + + + 이메일 + + + + + + 정지 사유 + + + 정지 일자 + + + + + 차단 여부 + + +
+ + {items.map((item) => ( + + ))} +
+ ) +} diff --git a/shared/design/src/components/checkBox/index.tsx b/shared/design/src/components/checkBox/index.tsx index a991a0d6..d5035cce 100644 --- a/shared/design/src/components/checkBox/index.tsx +++ b/shared/design/src/components/checkBox/index.tsx @@ -85,7 +85,7 @@ export const Checkbox = forwardRef(