feat(auth): 开启路由级登录拦截,未登录访问受保护页重定向登录页
This commit is contained in:
parent
9581a991be
commit
f9d0e3ba7f
|
|
@ -1,9 +1,13 @@
|
||||||
import { PropsWithChildren } from 'react'
|
import { PropsWithChildren } from 'react'
|
||||||
import { Provider } from 'react-redux'
|
import { Provider } from 'react-redux'
|
||||||
import { store } from './store'
|
import { store } from './store'
|
||||||
|
import { setupAuthInterceptors } from './utils/auth'
|
||||||
|
|
||||||
import './styles/reset.scss'
|
import './styles/reset.scss'
|
||||||
|
|
||||||
|
// 路由登录拦截需在任何导航发生前注册(模块加载时机,非组件渲染时机)
|
||||||
|
setupAuthInterceptors()
|
||||||
|
|
||||||
function App({ children }: PropsWithChildren) {
|
function App({ children }: PropsWithChildren) {
|
||||||
return <Provider store={store}>{children}</Provider>
|
return <Provider store={store}>{children}</Provider>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,10 @@ import { Loading } from '@/components/loading'
|
||||||
import { Empty } from '@/components/empty'
|
import { Empty } from '@/components/empty'
|
||||||
import { StateView } from '@/components/state-view'
|
import { StateView } from '@/components/state-view'
|
||||||
import { getEventList, type EventItem } from '@/api/event'
|
import { getEventList, type EventItem } from '@/api/event'
|
||||||
|
import { store } from '@/store'
|
||||||
|
import { storage } from '@/utils/storage'
|
||||||
import { ROUTES } from '@/constants/routes'
|
import { ROUTES } from '@/constants/routes'
|
||||||
import { navigateTo } from '@/utils/navigation'
|
import { navigateTo, reLaunch } from '@/utils/navigation'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/toast'
|
||||||
import { splitAlerts } from './splitAlerts'
|
import { splitAlerts } from './splitAlerts'
|
||||||
import styles from './index.module.scss'
|
import styles from './index.module.scss'
|
||||||
|
|
@ -31,7 +33,12 @@ export default function Index() {
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// 冷启动直达兜底:小程序入口页不受 addInterceptor 保护,未登录时踢到登录页
|
||||||
useDidShow(() => {
|
useDidShow(() => {
|
||||||
|
if (!store.getState().auth.token && !storage.get<string>('auth_token')) {
|
||||||
|
reLaunch(ROUTES.LOGIN)
|
||||||
|
return
|
||||||
|
}
|
||||||
loadAlerts()
|
loadAlerts()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { ROUTES } from '@/constants/routes'
|
||||||
|
import { store } from '@/store'
|
||||||
|
import { storage } from '@/utils/storage'
|
||||||
|
|
||||||
|
// 路由级登录拦截(auth guard)
|
||||||
|
// 原理:Taro.addInterceptor 洋葱拦截 navigateTo/reLaunch/redirectTo/switchTab,
|
||||||
|
// 未登录时改写目标 url 为登录页。冷启动直达场景由各受保护页面 useDidShow 兜底。
|
||||||
|
|
||||||
|
// 登录页及无需登录即可访问的页面(放行名单)
|
||||||
|
const PUBLIC_PAGES = new Set<string>([ROUTES.LOGIN])
|
||||||
|
|
||||||
|
// 页面是否需要登录态
|
||||||
|
function isProtectedPage(url: string): boolean {
|
||||||
|
const page = url.split('?')[0]
|
||||||
|
return !PUBLIC_PAGES.has(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLoggedIn(): boolean {
|
||||||
|
// 双读:store 内尚未恢复时(理论上仅冷启动瞬态)从 storage 兜底
|
||||||
|
return Boolean(store.getState().auth.token ?? storage.get<string>('auth_token'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 受保护页面跳转前校验登录态,未登录改投登录页
|
||||||
|
export function authRouteInterceptor(chain: Taro.Chain): Promise<unknown> {
|
||||||
|
const { url } = chain.requestParams
|
||||||
|
// addInterceptor 对所有 Taro API 生效(非仅导航类):无 url 的 API(toast/login 等)直接放行
|
||||||
|
if (typeof url === 'string' && url !== '' && isProtectedPage(url) && !isLoggedIn()) {
|
||||||
|
return chain.proceed({ ...chain.requestParams, url: ROUTES.LOGIN })
|
||||||
|
}
|
||||||
|
return chain.proceed(chain.requestParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupAuthInterceptors(): void {
|
||||||
|
Taro.addInterceptor(authRouteInterceptor)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
|
||||||
|
// auth.ts 顶层 import Taro,需 mock(拦截器测试本身不触达 Taro API)
|
||||||
|
vi.mock('@tarojs/taro', () => ({ default: {} }))
|
||||||
|
|
||||||
|
// store mock:authState 可变,支持模拟已登录/未登录两种状态
|
||||||
|
const authState = vi.hoisted(() => ({ token: null as string | null }))
|
||||||
|
vi.mock('@/store', () => ({
|
||||||
|
store: {
|
||||||
|
getState: () => ({ auth: { token: authState.token } })
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
// storage mock:内存 Map,用于验证 store 未恢复时的兜底读取
|
||||||
|
const mem = vi.hoisted(() => new Map<string, unknown>())
|
||||||
|
vi.mock('@/utils/storage', () => ({
|
||||||
|
storage: {
|
||||||
|
get: <T>(key: string): T | null => (mem.has(key) ? (mem.get(key) as T) : null),
|
||||||
|
set: <T>(key: string, value: T): void => { mem.set(key, value) },
|
||||||
|
remove: (key: string): void => { mem.delete(key) }
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { authRouteInterceptor } from '@/utils/auth'
|
||||||
|
import { ROUTES } from '@/constants/routes'
|
||||||
|
|
||||||
|
// 构造拦截器链:记录 proceed 收到的参数,返回受控 promise
|
||||||
|
function makeChain(url?: string) {
|
||||||
|
const received: unknown[] = []
|
||||||
|
const chain = {
|
||||||
|
requestParams: url === undefined ? {} : { url },
|
||||||
|
proceed(params: unknown) {
|
||||||
|
received.push(params)
|
||||||
|
return Promise.resolve('proceeded')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { chain, received }
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
authState.token = null
|
||||||
|
mem.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('authRouteInterceptor', () => {
|
||||||
|
it('未登录访问受保护页:url 被改写为登录页', async () => {
|
||||||
|
const { chain, received } = makeChain(ROUTES.HOME)
|
||||||
|
await authRouteInterceptor(chain as never)
|
||||||
|
expect(received[0]).toMatchObject({ url: ROUTES.LOGIN })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('已登录访问受保护页:原样放行', async () => {
|
||||||
|
authState.token = 'tk-1'
|
||||||
|
const { chain, received } = makeChain(ROUTES.DEVICE_ADD)
|
||||||
|
await authRouteInterceptor(chain as never)
|
||||||
|
expect(received[0]).toMatchObject({ url: ROUTES.DEVICE_ADD })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('未登录访问登录页:放行(登录页在公开名单)', async () => {
|
||||||
|
const { chain, received } = makeChain(ROUTES.LOGIN)
|
||||||
|
await authRouteInterceptor(chain as never)
|
||||||
|
expect(received[0]).toMatchObject({ url: ROUTES.LOGIN })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('带 query 的受保护页:仅取路径判断,跳登录页不带残留参数', async () => {
|
||||||
|
const { chain, received } = makeChain(`${ROUTES.FAMILY_DETAIL}?id=3`)
|
||||||
|
await authRouteInterceptor(chain as never)
|
||||||
|
expect(received[0]).toMatchObject({ url: ROUTES.LOGIN })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('store 无 token 但 storage 有(冷启动兜底):放行', async () => {
|
||||||
|
mem.set('auth_token', 'tk-saved')
|
||||||
|
const { chain, received } = makeChain(ROUTES.HOME)
|
||||||
|
await authRouteInterceptor(chain as never)
|
||||||
|
expect(received[0]).toMatchObject({ url: ROUTES.HOME })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('无 url 的 API 调用(toast/login 等,拦截器全局生效):原样放行', async () => {
|
||||||
|
const { chain, received } = makeChain(undefined)
|
||||||
|
await authRouteInterceptor(chain as never)
|
||||||
|
expect(received).toHaveLength(1)
|
||||||
|
expect(received[0]).toEqual({})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue