feat(history): add multi-account and WhatsApp foundation
This commit is contained in:
@@ -1,28 +1,76 @@
|
||||
import { createBrowserRouter, Navigate, RouterProvider } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/app/store/auth'
|
||||
import { createBrowserRouter, Navigate, RouterProvider, useRouteError } from 'react-router-dom'
|
||||
import { isSessionTokenExpired, useAuthStore } from '@/app/store/auth'
|
||||
import { AppLayout } from '@/components/layout/AppLayout'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
|
||||
import { LoginPage } from '@/pages/auth/LoginPage'
|
||||
import { RegisterPage } from '@/pages/auth/RegisterPage'
|
||||
|
||||
function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const token = useAuthStore((s) => s.token)
|
||||
if (!token) return <Navigate to="/login" replace />
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
const expired = isSessionTokenExpired(token)
|
||||
useEffect(() => {
|
||||
if (token && expired) logout()
|
||||
}, [expired, logout, token])
|
||||
if (expired) return <Navigate to="/login?reason=session-expired" replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function GuestOnly({ children }: { children: ReactNode }) {
|
||||
const token = useAuthStore((s) => s.token)
|
||||
if (token) return <Navigate to="/dashboard" replace />
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
const expired = Boolean(token) && isSessionTokenExpired(token)
|
||||
useEffect(() => {
|
||||
if (expired) logout()
|
||||
}, [expired, logout])
|
||||
if (token && !expired) return <Navigate to="/dashboard" replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function RouteErrorFallback() {
|
||||
const error = useRouteError()
|
||||
|
||||
useEffect(() => {
|
||||
console.error('Route render failed', error)
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-slate-50 px-6">
|
||||
<section className="w-full max-w-md rounded-3xl border border-slate-200 bg-white p-8 text-center shadow-xl shadow-slate-200/60">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-blue-50 text-xl text-blue-600">
|
||||
!
|
||||
</div>
|
||||
<h1 className="mt-5 text-xl font-semibold tracking-tight text-slate-950">页面暂时无法显示</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||||
当前页面遇到异常。你可以重新加载;如果问题仍然存在,请返回工作台后重试。
|
||||
</p>
|
||||
<div className="mt-6 flex justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.location.reload()}
|
||||
className="rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-blue-700"
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
<a
|
||||
href="/dashboard"
|
||||
className="rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50"
|
||||
>
|
||||
返回工作台
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{ path: '/login', element: <GuestOnly><LoginPage /></GuestOnly> },
|
||||
{ path: '/register', element: <GuestOnly><RegisterPage /></GuestOnly> },
|
||||
{ path: '/login', element: <GuestOnly><LoginPage /></GuestOnly>, errorElement: <RouteErrorFallback /> },
|
||||
{ path: '/register', element: <GuestOnly><RegisterPage /></GuestOnly>, errorElement: <RouteErrorFallback /> },
|
||||
{
|
||||
path: '/',
|
||||
errorElement: <RouteErrorFallback />,
|
||||
element: (
|
||||
<RequireAuth>
|
||||
<AppLayout />
|
||||
|
||||
+21
-1
@@ -2,7 +2,7 @@ import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import type { AuthSession, Tenant, User } from '@/types'
|
||||
import type { RoleKey } from '@/config/roles'
|
||||
import { setTokenGetter } from '@/services/api/client'
|
||||
import { setTokenGetter, setUnauthorizedHandler } from '@/services/api/client'
|
||||
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
@@ -32,6 +32,26 @@ export const useAuthStore = create<AuthState>()(
|
||||
|
||||
// 让 API client 能读取 token(真实后端模式使用)
|
||||
setTokenGetter(() => useAuthStore.getState().token)
|
||||
setUnauthorizedHandler(() => {
|
||||
useAuthStore.getState().logout()
|
||||
if (!window.location.pathname.startsWith('/login')) {
|
||||
window.location.replace('/login?reason=session-expired')
|
||||
}
|
||||
})
|
||||
|
||||
export function isSessionTokenExpired(token: string | null): boolean {
|
||||
if (!token) return true
|
||||
try {
|
||||
const encoded = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = encoded.padEnd(Math.ceil(encoded.length / 4) * 4, '=')
|
||||
const payload = JSON.parse(window.atob(padded)) as {
|
||||
exp?: number
|
||||
}
|
||||
return !payload.exp || payload.exp * 1_000 <= Date.now()
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function useCurrentRole(): RoleKey {
|
||||
return useAuthStore((s) => s.user?.role ?? 'viewer')
|
||||
|
||||
Reference in New Issue
Block a user