diff --git a/app/(admin)/admin/detail/[id]/account-history/page.tsx b/app/(admin)/admin/detail/[id]/account-history/page.tsx new file mode 100644 index 00000000..6ffff85d --- /dev/null +++ b/app/(admin)/admin/detail/[id]/account-history/page.tsx @@ -0,0 +1,189 @@ +import { + dehydrate, + HydrationBoundary, + QueryClient, +} from '@tanstack/react-query'; +import { ArrowRightIcon } from 'lucide-react'; +import { getAccountHistoriesInServer } from '@/features/admin/api/account-history.server'; +import { GetAccountHistoriesResponse } from '@/features/admin/api/types'; +import { formatHHMM, formatYYYYMMDD } from '@/shared/lib/time'; +import Badge from '@/shared/ui/badge'; + +export default async function AccountHistoryPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const queryClient = new QueryClient(); + const { id: memberId } = await params; + + // 서버 side에서 첫 페이지 데이터 미리 가져오기 + await queryClient.prefetchQuery({ + queryKey: ['accountHistory', memberId], + queryFn: () => getAccountHistoriesInServer({ memberId: Number(memberId) }), + }); + + const data: GetAccountHistoriesResponse = await queryClient.getQueryData([ + 'accountHistory', + memberId, + ]); + + return ( + +
+
+
+ + 계정 생성일 + +
+ {formatYYYYMMDD(data.joinedAt)} +
+
+ +
+ + 최근 로그인 + +
+ {data.loginMostRecentlyAt + ? formatYYYYMMDD(data.loginMostRecentlyAt) + : '기록 없음'} +
+
+ +
+ + 권한 + +
일반
+
+ +
+ + 계정 상태 + + + 활성 +
+
+ + + + +
+
+ ); +} + +function RecentLoginHistory({ + loginHists, +}: Pick) { + return ( +
+

최근 로그인 기록

+ + +
+ ); +} + +function RecentRoleChangeHistory({ + roleChangeHists, +}: Pick) { + return ( +
+

권한 변경 이력

+ + {roleChangeHists.length > 0 ? ( +
    + {roleChangeHists.map((hist) => ( +
  • +
    + {formatYYYYMMDD(hist.changedAt)} + {formatHHMM(hist.changedAt)} +
    + +
    + {hist.from} + + + + {hist.to} +
    +
  • + ))} +
+ ) : ( +
+ 변경 이력이 없습니다. +
+ )} +
+ ); +} + +function RecentStatusChangeHistory({ + memberStatusChangeHists, +}: Pick) { + return ( +
+

+ 계정 상태 변경 이력 +

+ + {memberStatusChangeHists.length > 0 ? ( +
    + {memberStatusChangeHists.map((hist) => ( +
  • +
    + {formatYYYYMMDD(hist.changedAt)} + {formatHHMM(hist.changedAt)} +
    + +
    + + {hist.from} + + + + + + {hist.to} + +
    +
  • + ))} +
+ ) : ( +
+ 변경 이력이 없습니다. +
+ )} +
+ ); +} diff --git a/app/(admin)/admin/detail/[id]/layout.tsx b/app/(admin)/admin/detail/[id]/layout.tsx new file mode 100644 index 00000000..66947cef --- /dev/null +++ b/app/(admin)/admin/detail/[id]/layout.tsx @@ -0,0 +1,30 @@ +import { ArrowLeftIcon } from 'lucide-react'; +import Link from 'next/link'; +import AdminDetailSideBar from '@/widgets/admin/ui/admin-detail-side-bar'; + +export default async function AdminDetailLayout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ id: string }>; +}) { + const { id: memberId } = await params; + + return ( +
+
+ + + +

사용자 상세 정보

+
+ +
+ + +
{children}
+
+
+ ); +} diff --git a/app/(admin)/admin/detail/[id]/page.tsx b/app/(admin)/admin/detail/[id]/page.tsx new file mode 100644 index 00000000..3774cfa8 --- /dev/null +++ b/app/(admin)/admin/detail/[id]/page.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation'; + +export default function AdminDetailByIdPage() { + notFound(); // /admin/detail/[id] 경로 404 처리 +} diff --git a/app/(admin)/admin/detail/[id]/profile/page.tsx b/app/(admin)/admin/detail/[id]/profile/page.tsx new file mode 100644 index 00000000..c400e549 --- /dev/null +++ b/app/(admin)/admin/detail/[id]/profile/page.tsx @@ -0,0 +1,140 @@ +import { QueryClient } from '@tanstack/react-query'; +import { getUserProfileInServer } from '@/entities/user/api/get-user-profile.server'; +import { GetUserProfileResponse } from '@/entities/user/api/types'; +import ProfileInfoCard from '@/entities/user/ui/profile-info-card'; +import CakeIcon from '@/features/my-page/ui/icon/cake.svg'; +import GithubIcon from '@/features/my-page/ui/icon/github-logo.svg'; +import GlobeIcon from '@/features/my-page/ui/icon/globe-simple.svg'; +import PhoneIcon from '@/features/my-page/ui/icon/phone.svg'; +import { getSincerityPresetByLevelName } from '@/shared/config/sincerity-temp-presets'; +import UserAvatar from '@/shared/ui/avatar'; +import Badge from '@/shared/ui/badge'; + +// todo: UserProfileModal과 거의 유사하여 나중에 리팩토링하기 +export default async function ProfilePage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const queryClient = new QueryClient(); + const { id: memberId } = await params; + + // 서버 side에서 첫 페이지 데이터 미리 가져오기 + await queryClient.prefetchQuery({ + queryKey: ['userProfile', memberId], + queryFn: () => getUserProfileInServer(Number(memberId)), + }); + + const profile: GetUserProfileResponse = await queryClient.getQueryData([ + 'userProfile', + memberId, + ]); + + const temperPreset = getSincerityPresetByLevelName( + profile.sincerityTemp.levelName, + ); + + return ( +
+
+ + +
+
+ {profile.memberProfile.mbti && ( + {profile.memberProfile.mbti} + )} + {profile.memberProfile.interests.slice(0, 4).map((interest) => ( + + {interest.name} + + ))} +
+ +
+
+ {profile.memberProfile.memberName} +
+ +
+ +
+ {profile.memberProfile.simpleIntroduction} +
+ +
+ } + value={profile.memberProfile.birthDate} + /> + } + value={profile.memberProfile.githubLink?.url} + /> + } value={profile.memberProfile.tel} /> + } + value={profile.memberProfile.blogOrSnsLink?.url} + /> +
+
+
+ +
+ + t.techStackName) + .join(', ')} + /> + t.label) + .join(', ')} + /> + + +
+
+ ); +} + +function Field({ icon, value }: { icon: React.ReactNode; value?: string }) { + return ( +
+ {icon} + + {value ?? ''} + +
+ ); +} diff --git a/app/(admin)/admin/detail/[id]/sincerity-temp/page.tsx b/app/(admin)/admin/detail/[id]/sincerity-temp/page.tsx new file mode 100644 index 00000000..ea0e590f --- /dev/null +++ b/app/(admin)/admin/detail/[id]/sincerity-temp/page.tsx @@ -0,0 +1,3 @@ +export default function SincerityTempPage() { + return
SincerityTempPage
; +} diff --git a/app/(admin)/admin/detail/[id]/study/page.tsx b/app/(admin)/admin/detail/[id]/study/page.tsx new file mode 100644 index 00000000..9c4cb849 --- /dev/null +++ b/app/(admin)/admin/detail/[id]/study/page.tsx @@ -0,0 +1,3 @@ +export default function StudyPage() { + return
StudyPage
; +} diff --git a/app/(admin)/admin/detail/page.tsx b/app/(admin)/admin/detail/page.tsx new file mode 100644 index 00000000..9292f648 --- /dev/null +++ b/app/(admin)/admin/detail/page.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation'; + +export default function AdminDetailPage() { + notFound(); // /admin/detail 경로 404 처리 +} diff --git a/app/(admin)/admin/page.tsx b/app/(admin)/admin/page.tsx index 2428c1be..46e92df5 100644 --- a/app/(admin)/admin/page.tsx +++ b/app/(admin)/admin/page.tsx @@ -3,8 +3,6 @@ import { HydrationBoundary, QueryClient, } from '@tanstack/react-query'; -import Image from 'next/image'; -import Link from 'next/link'; import { getMemberListInServer } from '@/features/admin/api/member-list.server'; import MemberListTable from '@/features/admin/ui/member-list-table'; @@ -19,41 +17,7 @@ export default async function AdminPage() { return ( -
- - -
- -
-
+
); } diff --git a/app/(admin)/layout.tsx b/app/(admin)/layout.tsx index 0c9f2213..6a18d503 100644 --- a/app/(admin)/layout.tsx +++ b/app/(admin)/layout.tsx @@ -5,6 +5,7 @@ import { clsx } from 'clsx'; import type { Metadata } from 'next'; import localFont from 'next/font/local'; import MainProvider from '@/app/provider'; +import AdminSideBar from '@/widgets/admin/ui/admin-side-bar'; export const metadata: Metadata = { title: 'ZERO-ONE', @@ -31,7 +32,13 @@ export default function AdminLayout({ {GTM_ID && } - {children} + +
+ + +
{children}
+
+
); diff --git a/middleware.ts b/middleware.ts index 2e47f1f0..8ba30306 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; +import { decodeJwt } from '@/shared/lib/jwt'; import { getServerCookie } from '@/shared/lib/server-cookie'; import { isNumeric } from '@/shared/lib/validation'; import { isApiError } from '@/shared/tanstack-query/api-error'; @@ -105,10 +106,27 @@ export async function middleware(request: NextRequest) { return NextResponse.redirect(mainUrl); } + if (request.nextUrl.pathname.startsWith('/admin')) { + const decodedJwt = decodeJwt(accessToken); + + if (!decodedJwt || !decodedJwt.roleIds.includes('ROLE_ADMIN')) { + const homeUrl = new URL('/', request.url); + + return NextResponse.redirect(homeUrl); + } + } + return response; } // middleware가 적용될 경로 설정 export const config = { - matcher: ['/', '/my-page', '/my-study', '/my-study-review', '/sign-up'], + matcher: [ + '/', + '/my-page', + '/my-study', + '/my-study-review', + '/sign-up', + '/admin/:path*', + ], }; diff --git a/src/features/admin/api/account-history.server.ts b/src/features/admin/api/account-history.server.ts new file mode 100644 index 00000000..48e48658 --- /dev/null +++ b/src/features/admin/api/account-history.server.ts @@ -0,0 +1,16 @@ +import { axiosServerInstance } from '@/shared/tanstack-query/axios.server'; +import { + GetAccountHistoriesRequest, + GetAccountHistoriesResponse, +} from './types'; + +// 회원 계정 이력 조회 +export const getAccountHistoriesInServer = async ({ + memberId, +}: GetAccountHistoriesRequest): Promise => { + const res = await axiosServerInstance.get( + `/admin/members/${memberId}/account-histories`, + ); + + return res.data.content; +}; diff --git a/src/features/admin/api/types.ts b/src/features/admin/api/types.ts index 071def3c..f85d2cb9 100644 --- a/src/features/admin/api/types.ts +++ b/src/features/admin/api/types.ts @@ -29,6 +29,27 @@ export interface GetMemberListResponse { }[]; } +export interface GetAccountHistoriesRequest { + memberId: number; +} + +export interface GetAccountHistoriesResponse { + memberId: number; + joinedAt: string; + loginMostRecentlyAt: string; + loginHists: string[]; + roleChangeHists: { + changedAt: string; + from: string; + to: string; + }[]; + memberStatusChangeHists: { + changedAt: string; + from: string; + to: string; + }[]; +} + export interface ChangeMemberStatusRequest { memberId: number; to: MemberStatus; diff --git a/src/features/admin/ui/logout-button.tsx b/src/features/admin/ui/logout-button.tsx new file mode 100644 index 00000000..5d31e82d --- /dev/null +++ b/src/features/admin/ui/logout-button.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { useLogoutMutation } from '@/features/auth/model/use-auth-mutation'; +import LogoutIcon from 'public/icons/logout.svg'; + +export default function LogoutButton() { + const { mutate: logout } = useLogoutMutation(); + + return ( + + ); +} diff --git a/src/shared/lib/jwt.ts b/src/shared/lib/jwt.ts new file mode 100644 index 00000000..491d9be3 --- /dev/null +++ b/src/shared/lib/jwt.ts @@ -0,0 +1,14 @@ +export const decodeJwt = (token: string) => { + const base64Url = token.split('.')[1]; + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + const jsonPayload = decodeURIComponent( + atob(base64) + .split('') + .map(function (c) { + return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); + }) + .join(''), + ); + + return JSON.parse(jsonPayload); +}; diff --git a/src/shared/lib/time.ts b/src/shared/lib/time.ts index 567c1d9e..704f878d 100644 --- a/src/shared/lib/time.ts +++ b/src/shared/lib/time.ts @@ -26,6 +26,14 @@ export const formatYYYYMMDD = (dateString: string) => { return onlyDate; }; +export const formatHHMM = (dateString: string) => { + const date = new Date(dateString); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + + return `${hours}:${minutes}`; +}; + export const formatKoreaYMD = (targetDate?: Date) => format(getKoreaDate(targetDate), 'yyyy-MM-dd'); diff --git a/src/shared/ui/tab-menu/index.tsx b/src/shared/ui/tab-menu/index.tsx new file mode 100644 index 00000000..cba11d4b --- /dev/null +++ b/src/shared/ui/tab-menu/index.tsx @@ -0,0 +1,41 @@ +import { cva } from 'class-variance-authority'; +import { cn } from '@/shared/shadcn/lib/utils'; + +interface TabMenuProps { + active?: boolean; + className?: string; + children: React.ReactNode; +} + +const tabMenuVariants = cva( + 'font-designer-14m rounded-100 w-full px-200 py-150', + { + variants: { + color: { + default: + 'bg-fill-neutral-subtle-default text-text-subtle hover:bg-fill-neutral-subtle-hover active:bg-fill-neutral-subtle-pressed', + active: 'bg-background-accent-blue-strong text-text-inverse', + }, + }, + defaultVariants: { + color: 'default', + }, + }, +); + +export default function TabMenu({ + active = false, + className, + children, +}: TabMenuProps) { + return ( +
+ {children} +
+ ); +} diff --git a/src/widgets/admin/ui/admin-detail-side-bar.tsx b/src/widgets/admin/ui/admin-detail-side-bar.tsx new file mode 100644 index 00000000..2052a7dc --- /dev/null +++ b/src/widgets/admin/ui/admin-detail-side-bar.tsx @@ -0,0 +1,48 @@ +'use client'; + +import Link from 'next/link'; + +import { usePathname } from 'next/navigation'; +import TabMenu from '@/shared/ui/tab-menu'; + +interface AdminDetailSideBarProps { + memberId: string; +} + +export default function AdminDetailSideBar({ + memberId, +}: AdminDetailSideBarProps) { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/src/widgets/admin/ui/admin-side-bar.tsx b/src/widgets/admin/ui/admin-side-bar.tsx new file mode 100644 index 00000000..84a09c56 --- /dev/null +++ b/src/widgets/admin/ui/admin-side-bar.tsx @@ -0,0 +1,58 @@ +import { QueryClient } from '@tanstack/react-query'; +import Link from 'next/link'; +import { getUserProfileInServer } from '@/entities/user/api/get-user-profile.server'; +import { GetUserProfileResponse } from '@/entities/user/api/types'; +import LogoutButton from '@/features/admin/ui/logout-button'; +import { getServerCookie } from '@/shared/lib/server-cookie'; +import UserAvatar from '@/shared/ui/avatar'; +import TabMenu from '@/shared/ui/tab-menu'; + +export default async function AdminSideBar() { + const queryClient = new QueryClient(); + + const memberIdStr = await getServerCookie('memberId'); + const memberId = Number(memberIdStr); + + if (!memberId) { + return null; + } + + // 서버 side에서 첫 페이지 데이터 미리 가져오기 + await queryClient.prefetchQuery({ + queryKey: ['userProfile', memberId], + queryFn: () => getUserProfileInServer(memberId), + }); + + const profile: GetUserProfileResponse = await queryClient.getQueryData([ + 'userProfile', + memberId, + ]); + + return ( + + ); +}