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(