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