Post

원티드 프리온보딩 - 로그인 기능 구현하기 4 (실습)

원티드 프리온보딩 - 로그인 기능 구현하기 4강 실습

실습 코드

유저 권한에 따라 접근이 제어되는 웹 만들기

1. 어드민 페이지 추가하기

isAdminPage: 어드민 페이지 여부

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
const routerData = [
  {
    id: 0,
    path: "/",
    label: "Home",
    element: <Home />,
    withAuth: false
  },
  {
    id: 1,
    path: "/login",
    label: "로그인",
    element: <Login />,
    withAuth: false
  },
  {
    id: 2,
    path: "/page-a",
    label: "페이지 A",
    element: <PageA />,
    withAuth: true
  },
  {
    id: 3,
    path: "/page-b",
    label: "페이지 B",
    element: <PageB />,
    withAuth: true
  },
  {
    id: 4,
    path: "/page-c",
    label: "페이지 C",
    element: <PageC />,
    withAuth: true
  },
  {
    id: 5,
    path: "/admin",
    label: "어드민 페이지",
    element: <AdminPage />,
    withAuth: true,
    isAdminPage: true
  }
];

2. 어드민 전용 페이지는 isAdminPage의 값 전달시키기

isAdminPage={router.isAdminPage}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 어드민 전용 페이지는 isAdminPage = true를 전달
export const routers = createBrowserRouter(
  routerData.map((router) => {
    if (router.withAuth) {
      return {
        path: router.path,
        element: (
          <GeneralLayout isAdminPage={router.isAdminPage}>
            {router.element}
          </GeneralLayout>
        )
      };
    } else {
      return {
        path: router.path,
        element: router.element
      };
    }
  })
);

3. 사이드바(메뉴)에 admin 페이지로 이동하는 요소는 admin에게만 보여주도록 하기

isAdminOnly를 추가해 선택적으로 보여지도록 설정

1
2
3
4
5
6
7
8
9
10
11
12
13
export const SidebarContent = routerData.reduce((prev, router) => {
  if (!router.withAuth) return prev;

  return [
    ...prev,
    {
      id: router.id,
      path: router.path,
      label: router.label,
      isAdminOnly: router.isAdminPage
    }
  ];
}, []);

4. 사이드바 컴포넌트에서 필터를 통해 isAdminOnly가 true이고, userInfo의 role에 ‘admin’이 포함된 경우에만 admin으로 이동할 수 있는 요소 렌더링하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
const Sidebar = ({ sidebarContent, userProfile }) => {
  const { currentPath, routeTo } = useRouter();

  const sidebarMenuClickHandler = (path) => {
    routeTo(path);
  };

  return (
    <div className="sidebar">
      <h3 className="sidebar-title">실습 3</h3>
      <ul>
        {sidebarContent
          .filter((element) => {
            return element.isAdminOnly
              ? userProfile?.userInfo.roles.includes("admin")
              : !!userProfile;
          })
          .map((element) => {
            return (
              <li
                key={element.path}
                className={
                  currentPath === element.path
                    ? "sidebar-menu selected"
                    : "sidebar-menu"
                }
                onClick={() => sidebarMenuClickHandler(element.path)}
              >
                {element.label}
              </li>
            );
          })}
      </ul>
      <div>
        {userProfile ? (
          <div className="sidebar-footer">
            {userProfile?.userInfo?.name}
            {userProfile?.userInfo?.roles.includes("admin") ? "(admin)" : ""} 환영합니다.
            <button className={"small"} onClick={logoutHandler}>
              로그아웃
            </button>
          </div>
        ) : (
          <div>로그인이 필요합니다.</div>
        )}
      </div>
    </div>
  );
};

export default Sidebar;

■ 응답받은 userInfo의 roles에 따라 페이지 이동시키기 (권한이 없는 유저가 페이지로 이동 못하도록 막기)

routTo()로 해당 페이지로 이동 & return <></> 아무것도 담겨있지 않은 것을 반환

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
const GeneralLayout = ({ children, isAdminPage }) => {
  const [userProfile, setUserProfile] = useState(null);
  const { routeTo } = useRouter();

  /* 유저 정보 가져오기*/
  const fetchUserProfile = useCallback(async () => {
    const userProfileResponse = await getCurrentUserInfo();

    if (userProfileResponse === null) {
      routeTo("/login");
      return;
    }

    setUserProfile(userProfileResponse);
  }, []);

  useEffect(() => {
    fetchUserProfile();
  }, [children]);

  // 응답으로 받은 user의 userInfo.roles가 비어있다면 아무 권한이 없는 user이므로 로그인 페이지로 이동
  if (userProfile?.userInfo.roles.length === 0) {
    routeTo("/login");
    return <></>;
  }

  // Admin 전용 페이지 접근 시도시 userProfile.userInfo.roles에 admin이 없는 경우에는 page-a로 이동
  if (isAdminPage && !userProfile?.userInfo.roles.includes("admin")) {
    routeTo("/page-a");
    return <></>;
  }

  if (userProfile === null) return <div>loading...</div>;

  return (
    <div className="general-layout">
      <Sidebar sidebarContent={SidebarContent} userProfile={userProfile} />
      <div className="general-layout__body">{children}</div>
    </div>
  );
};

export default GeneralLayout;

■ 권한별 자원 조회하기

일반유저의 page-a 화면어드민 유저의 page-a화면어드민 페이지
일반유저어드민어드민2

1. 아이템 가져오는 함수 작성

서버가 하는 일

  • 아이템 가져오는 함수: DB에 있는 아이템들 중에 소유자를 확인하고 데이터 클라이언트에게 보내기
  • 모든 아이템 가져오는 함수: 요청한 유저의 roles에 admin이 포함되어있는지 여부를 확인하고 데이터 클라이언트에게 보내기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// api/login.js

// 아이템을 가져오는 함수
export const getItems = async () => {
  const itemsRes = await fetch(`${BASE_URL}/items`, {
    method: "GET",
    headers: {
      "Content-Type": "application/json",
      credentials: "include"
    }
  });
  return itemsRes.ok ? itemsRes.json() : null;
};

// 모든 아이템을 가져오는 함수
export const getAllItems = async () => {
  const allItemsRes = await fetch(`${BASE_URL}/all-items`, {
    method: "GET",
    headers: {
      "Content-Type": "application/json",
      credentials: "include"
    }
  });
  return allItemsRes.ok ? allItemsRes.json() : null;
};

2. adminPage & page-a에서 아이템 가져오기

isUserItemsFetched 처음 렌더될때만 가져오도록 설정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// pages/AdminPage.jsx
const AdminPage = () => {
  const [items, setItems] = useState(null);
  const isUserItemsFetched = useRef(false);

  const fetchUserItems = useCallback(async () => {
    const userItems = await getAllItems();
    if (!userItems || userItems !== null) setItems(userItems);
    isUserItemsFetched.current = true;
  }, []);

  useEffect(() => {
    if (!isUserItemsFetched.current) fetchUserItems();
  }, []);

  return (
    <div>
      <h1>AdminPage</h1>
      <p>
        roles 배열 안에 'admin' 가진 유저에게만 접근을 허용합니다. 또한, 모든
        아이템 목록을 가져옵니다. (어드민 전용 API)
      </p>
      <ItemList items={items} />
    </div>
  );
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// pages/PageA.jsx
const PageA = () => {
  const [items, setItems] = useState(null);
  const isUserItemsFetched = useRef(false);

  const fetchUserItems = useCallback(async () => {
    const userItems = await getItems();
    if (!userItems || userItems !== null) setItems(userItems);

    isUserItemsFetched.current = true;
  }, []);

  useEffect(() => {
    if (!isUserItemsFetched.current) fetchUserItems();
  }, []);

  return (
    <div>
      <h1>Page A</h1>
      <ItemList items={items} />
    </div>
  );
};

■ 로그아웃 기능 구현하기

세션기반 로그인일 경우 로그아웃을 할 때 쿠키에 저장되어 있는 값이 사라지지는 않고, 서버에서 해당 쿠키가 파괴되었다고 응답함
아무 의미가 없는 쿠키만 남아있음 (쿠키 유효기간이 만료되면 자동으로 삭제됨)

1
2
3
4
5
6
7
8
9
10
// api/login.js
export const logout = async () => {
  await fetch(`${BASE_URL}/logout`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      credentials: "include"
    }
  });
};
This post is licensed under CC BY 4.0 by the author.