Skip to content

Commit

Permalink
Merge pull request #105 from Myongji-Graduate/use-info-logic/#82
Browse files Browse the repository at this point in the history
Use info logic/#82
  • Loading branch information
seonghunYang authored May 21, 2024
2 parents f645e23 + d161298 commit ec223db
Show file tree
Hide file tree
Showing 26 changed files with 468 additions and 51 deletions.
17 changes: 17 additions & 0 deletions app/(sub-page)/components/navigation-bar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Image from 'next/image';
import logo from '../../../public/assets/logo.svg';
import Responsive from '../../ui/responsive';
import SideNavigationBar from './side-navigation-bar';
import UserInfoNavigator from '@/app/ui/user/user-info-navigator/user-info-navigator';
import SignButtonGroup from '@/app/ui/user/user-info-navigator/sign-button-group';

export default function NavigationBar() {
return (
<div className="absolute flex justify-between items-center p-4 border-b-[1px] w-full z-2">
<Image className="md:h-10 h-7 w-[110px] md:w-[150px]" width={150} height={100} src={logo} alt="main-logo" />
<Responsive maxWidth={1023}>
<SideNavigationBar header={<UserInfoNavigator />} content={<div>콘텐츠</div>} footer={<SignButtonGroup />} />
</Responsive>
</div>
);
}
39 changes: 39 additions & 0 deletions app/(sub-page)/components/side-navigation-bar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
'use client';
import { HamburgerMenuIcon } from '@radix-ui/react-icons';
import { Sheet, SheetTrigger, SheetContent, SheetHeader, SheetFooter } from '../../ui/view/molecule/sheet/sheet';
import { DIALOG_KEY } from '@/app/utils/key/dialog-key.util';
import useDialog from '@/app/hooks/useDialog';

interface SideNavigationBarProps {
header: React.ReactNode;
footer: React.ReactNode;
content: React.ReactNode;
}

export default function SideNavigationBar({ header, content, footer }: SideNavigationBarProps) {
const { isOpen, open, close } = useDialog(DIALOG_KEY.SIDE_NAVIGATION);

const handleSideNavOpen = (value: boolean) => {
if (value) {
open();
} else {
close();
}
};

return (
<Sheet open={isOpen} onOpenChange={handleSideNavOpen}>
<SheetTrigger className="h-6">
<HamburgerMenuIcon className="w-6 h-6 text-white" />
</SheetTrigger>
<SheetContent className="z-3">
<div className="flex h-full flex-col justify-between">
<SheetHeader>{header}</SheetHeader>
<div className="w-full h-1 rounded-full my-4 bg-gray-200" />
<div className="h-full">{content}</div>
<SheetFooter>{footer}</SheetFooter>
</div>
</SheetContent>
</Sheet>
);
}
2 changes: 1 addition & 1 deletion app/(sub-page)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Image from 'next/image';
import background from '../../public/assets/background.png';
import NavigationBar from '../ui/view/molecule/navigation-bar';
import NavigationBar from './components/navigation-bar';

interface LayoutProps {
children: React.ReactNode;
Expand Down
17 changes: 12 additions & 5 deletions app/(sub-page)/my/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,24 @@ import Drawer from '@/app/ui/view/molecule/drawer/drawer';
import { DIALOG_KEY } from '@/app/utils/key/dialog-key.util';
import { Suspense } from 'react';
import MyResultContainer from './components/my-result-container';
import SignButtonGroup from '@/app/ui/user/user-info-navigator/sign-button-group';
import Responsive from '@/app/ui/responsive';
import TakenLectureSkeleton from '@/app/ui/lecture/taken-lecture/taken-lecture-skeleton';

export default function MyPage() {
return (
<>
<ContentContainer className="flex">
<div className="hidden lg:w-[30%] lg:block">
<Suspense fallback={<UserInfoNavigatorSkeleton />}>
<UserInfoNavigator />
</Suspense>
</div>
<Responsive minWidth={1023}>
<div className="lg:w-[30%]">
<Suspense fallback={<UserInfoNavigatorSkeleton />}>
<UserInfoNavigator />
<div className="mt-9">
<SignButtonGroup />
</div>
</Suspense>
</div>
</Responsive>
<div className="w-full lg:w-[70%] lg:px-[20px] pt-12 pb-2 flex flex-col gap-12">
<MyResultContainer />
<Suspense fallback={<TakenLectureSkeleton />}>
Expand Down
21 changes: 21 additions & 0 deletions app/__test__/ui/user/user-info-navigator.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import '@testing-library/jest-dom';
import UserInfoNavigator from '@/app/ui/user/user-info-navigator/user-info-navigator';
import { render, screen } from '@testing-library/react';

jest.mock('next/headers', () => ({
cookies: jest.fn().mockReturnValue({
get: jest.fn().mockReturnValue({
value: 'fake-access-token',
}),
}),
}));

describe('UserInfoNavigator', () => {
it('UserInfoNavigator를 렌더링한다.', async () => {
render(await UserInfoNavigator());

expect(await screen.findByText(/장진욱/i)).toBeInTheDocument();
expect(await screen.findByText(/디지털콘텐츠디자인학과/i)).toBeInTheDocument();
expect(await screen.findByText(/60181666/i)).toBeInTheDocument();
});
});
49 changes: 48 additions & 1 deletion app/business/user/user.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { FormState } from '@/app/ui/view/molecule/form/form-root';
import { API_PATH } from '../api-path';
import { SignUpRequestBody, SignInRequestBody, ValidateTokenResponse } from './user.type';
import { SignUpRequestBody, SignInRequestBody, ValidateTokenResponse, UserDeleteRequestBody } from './user.type';
import { httpErrorHandler } from '@/app/utils/http/http-error-handler';
import { BadRequestError } from '@/app/utils/http/http-error';
import {
Expand All @@ -15,6 +15,53 @@ import { cookies } from 'next/headers';
import { isValidation } from '@/app/utils/zod/validation.util';
import { redirect } from 'next/navigation';

export async function signOut() {
cookies().delete('accessToken');
cookies().delete('refreshToken');

redirect('/sign-in');
}

export async function deleteUser(prevState: FormState, formData: FormData): Promise<FormState> {
try {
const body: UserDeleteRequestBody = {
password: formData.get('password') as string,
};

const response = await fetch(`${API_PATH.user}/delete-me`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${cookies().get('accessToken')?.value}`,
},
body: JSON.stringify(body),
});
const result = await response.json();

httpErrorHandler(response, result);
} catch (error) {
if (error instanceof BadRequestError) {
// 잘못된 요청 처리 로직
return {
isSuccess: false,
isFailure: true,
validationError: {},
message: error.message,
};
} else {
// 나머지 에러는 더 상위 수준에서 처리
throw error;
}
}

return {
isSuccess: true,
isFailure: false,
validationError: {},
message: '회원 탈퇴가 완료되었습니다.',
};
}

export async function validateToken(): Promise<ValidateTokenResponse | false> {
const accessToken = cookies().get('accessToken')?.value;
const refreshToken = cookies().get('refreshToken')?.value;
Expand Down
13 changes: 13 additions & 0 deletions app/business/user/user.query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ import { cookies } from 'next/headers';
import { isValidation } from '@/app/utils/zod/validation.util';
import { InitUserInfoResponse, UserInfoResponse } from './user.type';
import { UserInfoResponseSchema, InitUserInfoResponseSchema } from './user.validation';
import { UnauthorizedError } from '@/app/utils/http/http-error';

export async function auth(): Promise<InitUserInfoResponse | UserInfoResponse | undefined> {
try {
const result = await fetchUserInfo();
return result;
} catch (error) {
if (error instanceof UnauthorizedError) {
return;
}
throw error;
}
}

export async function fetchUserInfo(): Promise<InitUserInfoResponse | UserInfoResponse> {
try {
Expand Down
9 changes: 9 additions & 0 deletions app/business/user/user.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ export interface SignUpRequestBody {
engLv: string;
}

export interface SignInRequestBody {
authId: string;
password: string;
}

export interface UserDeleteRequestBody {
password: string;
}

export type SignInResponse = z.infer<typeof SignInResponseSchema>;

export type UserInfoResponse = z.infer<typeof UserInfoResponseSchema>;
Expand Down
4 changes: 4 additions & 0 deletions app/business/user/user.validation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { UserInfoResponse, InitUserInfoResponse } from './user.type';

export const UserInfoResponseSchema = z.object({
studentNumber: z.string(),
Expand Down Expand Up @@ -71,3 +72,6 @@ export const SignUpFormSchema = z
});
}
});
export function isInitUser(x: UserInfoResponse | InitUserInfoResponse): x is InitUserInfoResponse {
return typeof x.studentName === null;
}
3 changes: 2 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { Metadata } from 'next';
import './globals.css';
import NavigationBar from './ui/view/molecule/navigation-bar';
import { Toaster } from './ui/view/molecule/toast/toaster';
import Provider from './provider';
import MSWComponent from './mocks/msw-component.mock';
import UserDeleteModal from './ui/user/user-info-navigator/user-delete-modal';

export const metadata: Metadata = {
title: 'Create Next App',
Expand Down Expand Up @@ -31,6 +31,7 @@ export default function RootLayout({
</Provider>
</div>
<Toaster />
<UserDeleteModal />
</body>
</html>
);
Expand Down
9 changes: 9 additions & 0 deletions app/mocks/db.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type MockDatabaseAction = {
deleteTakenLecture: (lectureId: number) => boolean;
createUser: (user: SignUpRequestBody) => boolean;
signIn: (userData: SignInRequestBody) => boolean;
deleteUser: (authId: string, password: string) => boolean;
getCredits: () => CreditResponse[];
getUserInfo: (authId: string) => UserInfoResponse | InitUserInfoResponse;
getResultCategoryDetailInfo: () => ResultCategoryDetailResponse;
Expand Down Expand Up @@ -84,6 +85,14 @@ export const mockDatabase: MockDatabaseAction = {
}
return mockDatabaseStore.userInfo;
},
deleteUser: (authId: string, password: string) => {
const user = mockDatabaseStore.users.find((u) => u.authId === authId && u.password === password);
if (user) {
mockDatabaseStore.users = mockDatabaseStore.users.filter((u) => u.authId !== authId);
return true;
}
return false;
},
};

const initialState: MockDatabaseState = {
Expand Down
33 changes: 33 additions & 0 deletions app/mocks/handlers/user-handler.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import {
SignInResponse,
ValidateTokenResponse,
UserInfoResponse,
UserDeleteRequestBody,
InitUserInfoResponse,
} from '@/app/business/user/user.type';
import { ErrorResponseData } from '@/app/utils/http/http-error-handler';
import { StrictRequest } from 'msw';

function mockDecryptToken(token: string) {
if (token === 'fake-access-token') {
Expand All @@ -22,6 +24,21 @@ function mockDecryptToken(token: string) {
};
}

export const devModeAuthGuard = (request: StrictRequest<any>) => {
if (process.env.NODE_ENV === 'development') {
const accessToken = request.headers.get('Authorization')?.replace('Bearer ', '');
if (accessToken === 'undefined' || !accessToken) {
throw new Error('Unauthorized');
}

return mockDecryptToken(accessToken);
} else {
return {
authId: 'admin',
};
}
};

export const userHandlers = [
http.get<never, never, never>(`${API_PATH.auth}/failure`, async ({ request }) => {
await delay(500);
Expand All @@ -32,6 +49,22 @@ export const userHandlers = [
accessToken: 'fake-access-token',
});
}),
http.delete<never, UserDeleteRequestBody, never>(`${API_PATH.user}/delete-me`, async ({ request }) => {
try {
const { authId } = devModeAuthGuard(request);
const { password } = await request.json();

const result = mockDatabase.deleteUser(authId, password);

if (result) {
return HttpResponse.json({ status: 200 });
} else {
return HttpResponse.json({ status: 400, message: '비밀번호가 일치하지 않습니다' }, { status: 400 });
}
} catch {
return HttpResponse.json({ status: 401, message: 'Unauthorized' }, { status: 401 });
}
}),
http.get<never, never, UserInfoResponse | InitUserInfoResponse | ErrorResponseData>(
API_PATH.user,
async ({ request }) => {
Expand Down
2 changes: 1 addition & 1 deletion app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import mainBookBackground from '../public/assets/main-book-background.png';
import mainMyongjiLogo from '../public/assets/main-myongji-logo.png';
import graduationCap from '../public/assets/graduation-cap.png';
import Responsive from './ui/responsive';
import NavigationBar from './ui/view/molecule/navigation-bar';
import NavigationBar from './(sub-page)/components/navigation-bar';
import Button from './ui/view/atom/button/button';
import Link from 'next/link';

Expand Down
2 changes: 2 additions & 0 deletions app/store/dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ const initialState = {
[DIALOG_KEY.RESULT_CATEGORY]: false,
[DIALOG_KEY.DIALOG_TEST]: true,
[DIALOG_KEY.LECTURE_SEARCH]: false,
[DIALOG_KEY.USER_DELETE]: false,
[DIALOG_KEY.SIDE_NAVIGATION]: false,
};

const dialogAtom = atom(initialState);
Expand Down
14 changes: 5 additions & 9 deletions app/ui/user/user-info-card/user-info-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,14 @@ import { fetchUserInfo } from '@/app/business/user/user.query';
import { InitUserInfoResponse, UserInfoResponse } from '@/app/business/user/user.type';
import InitUserAnnounce from './init-user-announce';
import UserInfoContent from './user-info-content';
import { isInitUser } from '@/app/business/user/user.validation';

function renderUserInfo(data: UserInfoResponse | InitUserInfoResponse) {
isInitUser(data) ? <InitUserAnnounce /> : <UserInfoContent data={data} />;
}

async function UserInfoCard() {
const data = await fetchUserInfo();

function isInitUser(x: UserInfoResponse | InitUserInfoResponse): x is InitUserInfoResponse {
return typeof x.studentName === null;
}

function renderUserInfo(data: UserInfoResponse | InitUserInfoResponse) {
isInitUser(data) ? <InitUserAnnounce /> : <UserInfoContent data={data} />;
}

return <>{renderUserInfo(data)}</>;
}

Expand Down
20 changes: 20 additions & 0 deletions app/ui/user/user-info-navigator/sign-button-group.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { auth } from '@/app/business/user/user.query';
import SignOutButton from './sign-out-button';
import UserDeleteButton from './user-delete-button';
import SignInLinkButton from './sign-in-link-button';

export default async function SignButtonGroup() {
const userInfo = await auth();
return (
<div className="flex flex-col items-center mt-9 space-y-2">
{userInfo ? (
<>
<SignOutButton />
<UserDeleteButton />
</>
) : (
<SignInLinkButton />
)}
</div>
);
}
Loading

0 comments on commit ec223db

Please sign in to comment.