본문 바로가기

프론트엔드

[로그인 구현] 권한별 렌더링

1. 로그인 기능 구조

  • 로그인이 필요한 페이지와 그렇지 않은 페이지들

로그인이 불필요한 페이지

: 리액트 라우터 사용해서 컴포넌트 물어가도록 작성

로그인이 필요한 페이지

: 로그인 되어있는지 없는지 판단 후 처리

이렇게 처리될 경우, 반복되는 로그인 확인 코드&로직들이 발생한다.

  • 페이지 이동할 때마다 로그인이 되었는지 확인해야하기 때문에 반복되는 로그인 확인 코드&로직들이 발생한다.

반복되는 코드는 모듈화, 재사용하기

-> 껍데기 컴포넌트 Authorization은 로그인이 필요한 페이지들을 담아가서 로직에 맞게 렌더링해줌

실제 적용

동작 살펴보기

2. 레이아웃 컴포넌트

RouterInfo 활용해서 사이드바 정보 생성

사이드바, 헤더 등 레이아웃으로 감싸주기

3. 유저 권한에 따라 접근 제어되는 웹 만들기 (admin)

Mission:

- Admin 페이지를 router에 추가하고, 어드민 유저(username: blue, password: 1234!@#$)만 접근할 수 있도록 하기

 

router.tsx

  • Admin페이지를 router에 추가
  • 어드민 전용 페이지이거나 auth가 필요한 페이지는 GeneralLayout으로 감싸기.
  • 어드민 전용 페이지는 props로 isAdminPage={ true }를 전달
  • isAdminOnly 프로퍼티를 추가하여 admin 페이지로 가는 사이드바 요소를 선택적으로 렌더링 (어드민에게만 보이도록 하기)

GeneralLayout.tsx

  • 응답으로 받은 user의 userInfo.roles가 비어있다면 아무 권한이 없는 user이므로 로그인 페이지로 이동
  • Admin 전용 페이지 접근 시도시 userProfile.userInfo.roles에 admin이 없는 경우에는 page-a로 이동

Sidebar.tsx

  • element.isAdminOnly가 true일 때 admin user(userProfile.userInfo.roles에 'admin'이 포함됨)에게만 메뉴 보여주기

1. 라우터 업데이트하기

1) 페이지 객체 추가

 {
    id: 5,
    path: '/admin',
    label: '어드민 페이지',
    element: <AdminPage/>,
    withAuth: true,
    isAdminPage: true
  }

2) 라우터 업데이트

export const routers: RemixRouter = createBrowserRouter(
  // 어드민 전용 페이지이거나 auth가 필요한 페이지는 GeneralLayout으로 감싸기
  // 어드민 전용 페이지는 isAdminPage = true를 전달
  routerData.map((router) => {
    if (router.withAuth) {
      return {
        path: router.path,
        element: <GeneralLayout
          isAdminPage={ 'isAdminPage' in router && router.isAdminPage }>
          { router.element }
        </GeneralLayout>
      }
    } else {
      return {
        path: router.path,
        element: router.element
      }
    }
  })
)

3) 사이드바 넘겨주기

export const SidebarContent: SidebarElement[] = routerData.reduce((prev, router) => {
  // isAdminOnly 프로퍼티를 추가하여 admin 페이지로 가는 사이드바 요소를 선택적으로 렌더링 (어드민에게만 보이도록 하기)
  if (!router.withAuth) return prev

  return [
    ...prev,
    {
      id: router.id,
      path: router.path,
      label: router.label,
      isAdminOnly: 'isAdminPage' in router && router.isAdminPage
    }
  ]
}, [] as SidebarElement[])

2. 레이아웃 컴포넌트 업데이트

1) 로그인 실패

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

2) 어드민 페이지 관리

// Admin 전용 페이지 접근 시도시 userProfile.userInfo.roles에 admin이 없는 경우에는 page-a로 이동
  if (isAdminPage && !userProfile?.userInfo.roles.includes(AdminRole)) {
    routeTo('/page-a') // 케이스에 따라 404를 내려줘도 됨
    return <></>
  }

3) 사이드바 메뉴 선택적 렌더링

<ul>
      { sidebarContent
        .filter((element ) => {
          // element.isAdminOnly가 true일 때
          // admin user(userProfile.userInfo.roles에 'admin'이 포함됨)에게만 메뉴 보여주기
          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>

3. 권한별 자원 조회

Mission:

- 각 유저의 개인 아이템을 조회할 수 있다.

**- 어드민 유저의 경우, 모든 유저의 아이템을 조회할 수 있다. (어드민 페이지에서)

- 로그아웃 기능을 구현한다.

 

login.ts

  • GET, '/items' 호출
  • GET, '/all-items' 호출
  • POST, '/logout' 호출
  • /items, /all-items, /logout은 모두 body와 parameter가 없는 요청

GeneralLayout.tsx

  • Recoil atom UserAtom을 이용해 userProfile props 대체 및 삭제

Sidebar.tsx

  • Recoil atom UserAtom을 이용해 userProfile 값 대체 및 props type 삭제
  • Recoil atom UserAtom을 이용해 userProfile 값 집어넣기
  • 로그아웃 호출

PageA.tsx

  • getItems를 호출하여 userItem을 가져온 경우 상태 업데이트

AdminPage.tsx

  • getItems를 호출하여 userItem을 가져온 경우 상태 업데이트

1) 데이터 호출

- getItems, getAllItems

  • admin 페이지에서만 All Items 호출
// GET, '/items' 호출
export const getItems = async (): Promise<Item[] | null> => {
  const itemRes = await fetch(`${ BASE_URL }/items`, {
    method: 'GET',
    headers: {
      'Content-Type': 'application/json',
      credentials: 'include'
    }
  })

  return itemRes.ok ? itemRes.json() : null
}

// GET, '/all-items' 호출
export const getAllItems = async (): Promise<Item[] | null> => {
  const itemRes = await fetch(`${ BASE_URL }/all-items`, {
    method: 'GET',
    headers: {
      'Content-Type': 'application/json',
      credentials: 'include'
    }
  })

  return itemRes.ok ? itemRes.json() : null
}

2) 로그아웃

// POST, '/logout' 호출
export const logout = async (): Promise<void> => {
  await fetch(`${ BASE_URL }/logout`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      credentials: 'include'
    }
  })
}

3) 유저 정보 전역 저장

1. 유저 정보 넘기기

const GeneralLayout: React.FC<GeneralLayoutProps> = ({children, isAdminPage}) => {
  {/*Recoil atom `UserAtom`을 이용해 userProfile props 대체 및 삭제 */}
  const [userProfile, setUserProfile] = useRecoilState(UserAtom) // userProfile 전역 상태로 담기
  const {routeTo} = useRouter()

  const fetchUserProfile = useCallback(async () => {
    const userProfileResponse = await getCurrentUserInfo()

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

    {/*Recoil atom `UserAtom`을 이용해 setUserProfile props 대체 및 삭제 */}
    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(AdminRole)) {
    routeTo('/page-a')
    return <></>
  }

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

  return (<div className="general-layout">
    {/* Recoil atom `UserAtom`을 이용해 userProfile props 대체 및 삭제 */}
    <Sidebar sidebarContent={SidebarContent} />
    <div className="general-layout__body">
      { children }
    </div>
  </div>)
}

2.유저정보 받기

- 사이드바

interface SidebarProps {
  sidebarContent: SidebarElement[]
  // Recoil atom `UserAtom`을 이용해 userProfile 값 대체 및 props type 삭제
  // userProfile: User | null
}

// Recoil atom `UserAtom`을 이용해 userProfile 값 대체 및 props 삭제
const Sidebar: React.FC<SidebarProps> = ({ sidebarContent }) => {
  // Recoil atom `UserAtom`을 이용해 userProfile 값 집어넣기
  // hint: useRecoilValue
  const [userProfile, setUserProfile] = useRecoilState(UserAtom)

  const { currentPath, routeTo } = useRouter()

  const sidebarMenuClickHandler = (path: string) => {
    routeTo(path)
  }

  // 로그아웃 호출
  const logoutHandler = async () => {
    await logout()
    setUserProfile(null)
    routeTo('/')
  }
  // 로그아웃해도 클라이언트에서 세션id가 날아가진 않지만, 서버단에서 세션id를 destroy하기 때문에 어차피 사용못하게되서 굳이 안날려줘도됨

- 일반 페이지

const PageA = () => {
  const [ items, setItems ] = useState<Item[] | null>(null)
  const isUserItemsFetched = useRef(false)
    // fetch를 했다가 State를 바꿔버리기 때문에 리렌더링이 일어남
    // 근데 useEffect가 다시 렌더링되면서 또 fetch해오는 일이 발생하여
  	// 무한루프 방지 차원에서 넣어둠
	// 실제로 종종 쓰는 방식으로, react query사용하면 useRef사용하지 않아도 됨
  
  // getItems를 호출하여 userItem을 가져온 경우 상태 업데이트
  const fetchUserItems = useCallback(async () => {
    const userItems = await getItems()

    if (userItems !== null) setItems(userItems)
    
    isUserItemsFetched.current = true
  }, [])


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

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

export default PageA

- 어드민 페이지

const AdminPage = () => {
  const [ items, setItems ] = useState<Item[] | null>(null)
  const isUserItemsFetched = useRef(false)

  // getItems를 호출하여 userItem을 가져온 경우 상태 업데이트
  const fetchUserItems = useCallback(async () => {
    const userItems = await getAllItems()

    if (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>)
}

export default AdminPage