[React] 在 React Router 4 中建立具有權限檢核的 Private Route 組件

在開發具有登入功能的網站時,各功能頁面將所屬不同權限用戶使用,因此進入頁面前都需進行權限檢核;這篇文章將在 React Router 4 版本中實作一個符合基本需求的 PrivateRoute 組件,讓用戶在切換路由時進行權限的檢核,達到頁面功能權限控管的需求。

前言


如果有在關注 React 相關技術的朋友一定知道 react-router 從 v3 到 v4 有比較大的轉變,主要就是遵循 React 萬物皆組件的概念,所有路由都是組件 (具有完整的生命週期),當然就連 Redirect 功能也擁有一個自己的組件了,因此如果想讓路由具有權限檢核的功能,我們可以自行實作 private route 組件來滿足這個需求。

 

實作 PrivateRoute 組件


對於這個 Private Route 的需求如下:

  • 具有檢核中 loading 屏蔽效果 ( checking auth...)
  • 檢核權限 (token, function code ...)
  • 權限檢核失敗直接 redirect 登入頁面

 

建立組件

首先建立 PrivateRoute 組件並定義兩個狀態 (state) 分別為 isLoadingisAuthed 旗標,分別表示「是否權限正在確認中」及「是否通過授權」的內部組件狀態,後續依靠這兩個狀態來操作路由的動態效果。

import { Component } from 'react'
import { connect } from 'react-redux'
import { get } from 'lodash'

export class PrivateRoute extends Component {
  constructor (props) {
    super(props)

    this.state = {
      isLoading: true, // 是否於權限檢核中
      isAuthed: false  // 是否通過權限檢核
    }
  }
}

const mapStateToProps = state => ({
  // 登入系統後會於 redux 中註記登入狀態
  isLogin: get(state, 'auth.isLogin')
})

const mapDispatchToProps = dispatch => ({
})

export default connect(mapStateToProps, mapDispatchToProps)(PrivateRoute)

 

定義傳入參數

接著定義需要從外部傳入的必要資訊為何。首先當然就是該頁面需要顯示的組件 (component) ,這樣路由才會知道要顯示什麼東西在頁面上;另外由於 PrivateRoute 需要進行功能頁面的權控,因此需要傳入此功能頁面的編號 (funcCode),以此向後端作為功能識別,查看該用戶是否具有使用該功能的權限。

// ... 略 ...
import PropTypes from 'prop-types'

export class PrivateRoute extends Component {
  
  // ... 略 ...

  static propTypes = {
    component: PropTypes.any.isRequired,
    funcCode: PropTypes.string.isRequired
  }

}

 

實作驗證方法

接著建立 checkAuth 非同步方法,用來向遠端 API 驗證用戶權限,效果如下:

  • 切換權限檢核狀態 (作用是讓畫面被屏蔽,等待驗證結果)
  • 向遠端服務確認用戶權限是否能夠操作這個功能頁面
  • 更新權限檢核狀態 (移除屏蔽)
  • 更新權限檢核結果 (畫面互動)
// ... 略 ...
import toastr from 'toastr'

export class PrivateRoute extends Component {
  
  // ... 略 ...

  checkAuth = async () => {

    let isAuthed = false
    const { isLogin, funcCode } = this.props

    if (isLogin) {

      // 設定狀態為權限檢核中 ...
      this.setState(state => ({ ...state, isLoading: true }))

      // 與遠端 API 確認權限 ...
      // token 可以從 axios interceptor 透過 head 送到後端
      // funcCode 需要從外部取得送至後端驗證使用者是否有此功能的權限
      isAuthed = await api.checkAuthWithServer(funcCode)
    }

    if (!isAuthed) {
      // 無權限顯示提示訊息
      toastr.warning('無權使用,請先登入系統')
    }

    // 更新狀態 1.檢核結束 2.檢核結果
    this.setState(state => ({ ...state, isAuthed: isAuthed, isLoading: false }))
  }

}
更新組件狀態請記得使用 setState() 方法,這樣畫面才會重新渲染。

 

觸發驗證機制

再來決定哪個時候該進行檢核。依照組件的生命週期可以在「組件將被建立時 (componentWillMount)」及「組件接收的屬性變化時 (componentWillReceiveProps)」觸發檢核。

export class PrivateRoute extends Component {
  
  // ... 略 ...

  componentWillMount = async () => {
    await this.checkAuth()
  }

  componentWillReceiveProps = async (nextProps) => {
    if (nextProps.path !== this.props.path) {
      await this.checkAuth()
    }
  }

}

 

依狀態產生畫面

最後就是要依照狀態來決定畫面呈現的樣貌,說明如下:

  • 當狀態為權限檢核中,顯示 LoadingIndicator 組件來屏蔽畫面。
    (其中 LoadingIndicator  組件為筆者自建來顯示 spinner 動畫)
     
  • 當權限檢核完畢時,透過 Route 組件 render 畫面。
    • 有權限:渲染出外部傳入的 component 組件
    • 無權限:渲染出 Redirect 組件來將頁面導向登入頁
// ... 略 ...
import { Route } from 'react-router-dom'
import { Redirect } from 'react-router'
import { LoadingIndicator } from 'components'

export class PrivateRoute extends Component {
  
  // ... 略 ...

  render () {
    const { component: Component, ...rest } = this.props
    const { isLoading, isAuthed } = this.state

    return (
      isLoading === true
        ? <LoadingIndicator />
        : <Route {...rest} render={props => (
            isAuthed
              ? <Component {...props} />
              : <Redirect to={{ pathname: '/login', state: { from: props.location } }} />
          )} />
    )
  }
}

 

 

使用 PrivateRoute 組件


以下是一個功能頁面的根組件,可以在這組件中切換各子功能顯示區塊;當路由位置符合 PrivateRoute 設定的 path 位置時,會渲染出該 PrivateRoute 組件,此時就可以透過傳入的 funcCode 來檢核用戶是否具有使用權限,若有權限則顯示傳入 PrivateRoute 的 component 組件。

import React from 'react'
import { Redirect, Switch } from 'react-router-dom'
import { Container } from 'components'
import { PlayStyledComponents, PlayIntlUniversal, PlayReduxForm, PlayBasicUse } from 'pages'
import { PrivateRoute } from 'containers'

export default (props) => {
  const { match } = props

  return <Container>
    {/* ... 切換路由的連結(略) ... */}
    <Switch>
      <PrivateRoute funcCode='F01' path={`${match.url}/PlayBasicUse`} component={PlayBasicUse} />
      <PrivateRoute funcCode='F02' path={`${match.url}/PlayStyledComponents`} component={PlayStyledComponents} />
      <PrivateRoute funcCode='F03' path={`${match.url}/PlayIntlUniversal`} component={PlayIntlUniversal} />
      <PrivateRoute funcCode='F04' path={`${match.url}/PlayReduxForm`} component={PlayReduxForm} />
      <Redirect to={`${match.url}/PlayBasicUse`} /> {/* 預設頁面 */}
    </Switch>
  </Container>
}
其中 Switch 表示只會顯示第一個符合條件的 PrivateRoute 組件喔!

 

 

功能測試


用戶尚未登入系統,直接切換 URL 至特定 PrivateRoute 頁面時,權限檢核失敗並重新導向登入頁。

 

用戶登入系統後,每當切換路由時都會先屏蔽畫面,並發出 request 向後端驗證用戶是否具有權限;等待結果回傳後會直接解除屏蔽效果,依照驗證通過與否來決定畫面如何呈現。以下都是通過驗證的功能項,因此會直接顯示傳入各 PrivateRoute 的 component 組件於畫面上。

 

 

完整代碼


完整測試代碼如下,可以於此看出 PrivateRoute 全貌。

import { Component } from 'react'
import { connect } from 'react-redux'
import { get } from 'lodash'
import PropTypes from 'prop-types'
import toastr from 'toastr'
import { Route } from 'react-router-dom'
import { Redirect } from 'react-router'
import { LoadingIndicator } from 'components'


export class PrivateRoute extends Component {
  constructor (props) {
    super(props)

    this.state = {
      isLoading: true, // 是否於權限檢核中
      isAuthed: false  // 是否通過權限檢核
    }
  }

  static propTypes = {
    component: PropTypes.any.isRequired,
    funcCode: PropTypes.string.isRequired
  }

  checkAuth = async () => {
    let isAuthed = false
    const { isLogin, funcCode } = this.props

    if (isLogin) {
      // 設定狀態為權限檢核中 ...
      this.setState(state => ({ ...state, isLoading: true }))

      // 與遠端 API 確認權限 ...
      // token 可以從 axios interceptor 透過 head 送到後端
      // funcCode 需要從外部取得送至後端驗證使用者是否有此功能的權限
      isAuthed = await api.checkAuthWithServer(funcCode)
    }

    if (!isAuthed) {
      // 無權限顯示提示訊息
      toastr.warning('無權使用,請先登入系統')
    }

    // 更新狀態 1.檢核結束 2.檢核結果
    this.setState(state => ({ ...state, isAuthed: isAuthed, isLoading: false }))
  }

  componentWillMount = async () => {
    await this.checkAuth()
  }

  componentWillReceiveProps = async (nextProps) => {
    if (nextProps.location.pathname !== this.props.location.pathname) {
      await this.checkAuth()
    }
  }
  
  render () {
    const { component: Component, ...rest } = this.props
    const { isLoading, isAuthed } = this.state

    return (
      isLoading === true
        ? <LoadingIndicator />
        : <Route {...rest} render={props => (
            isAuthed
              ? <Component {...props} />
              : <Redirect to={{ pathname: '/login', state: { from: props.location } }} />
          )} />
    )
  }

}

const mapStateToProps = state => ({
  // 登入系統後會於 redux 中註記登入狀態
  isLogin: get(state, 'auth.isLogin')
})

const mapDispatchToProps = dispatch => ({
})

export default connect(mapStateToProps, mapDispatchToProps)(PrivateRoute)

 

測試代碼已上傳 GitHub 中,有興趣的朋友可以參考一下。
若有更好的建議或做法再請不吝指導一下囉! 感謝!

 


希望此篇文章可以幫助到需要的人

若內容有誤或有其他建議請不吝留言給筆者喔 !