feat(api-tokens): add PAT management UI, CSRF handling, and auth-loading fixes

Add an API tokens management route to create, list, and revoke PATs, showing
the plaintext token once on creation with scope and server-id selection.

Mirror the nz-csrf cookie into the X-CSRF-Token header on unsafe fetcher
methods (POST/PUT/PATCH/DELETE) for the server-side double-submit check, and
self-heal expired sessions via refresh-token without a recursive fetch loop.

Gate protected routes behind resolved auth state to avoid pre-auth SWR fetches,
and fix the login loading/race so stale probes cannot clobber the session.

Add i18n keys for the new screens across all locales.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
This commit is contained in:
naibaandcloudcode committed 2026-05-30 15:56:53 +00:00
1 parent f47d4a8772
commit f07c557029
37 files changed
+2041 -69

No files matched your search

+24 -2
View File
@@ -1,6 +1,6 @@
import { getProfile, login as loginRequest } from "@/api/user"
import { AuthContextProps } from "@/types"
import { createContext, useCallback, useContext, useEffect, useMemo } from "react"
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
import { toast } from "sonner"
@@ -9,6 +9,7 @@ import { useMainStore } from "./useMainStore"
const AuthContext = createContext<AuthContextProps>({
profile: undefined,
loading: true,
login: () => {},
loginOauth2: () => {},
logout: () => {},
@@ -17,16 +18,27 @@ const AuthContext = createContext<AuthContextProps>({
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const profile = useMainStore((store) => store.profile)
const setProfile = useMainStore((store) => store.setProfile)
const [loading, setLoading] = useState(true)
const { t } = useTranslation()
// An explicit login/logout (or its getProfile) resolving while the initial
// mount probe is still in flight must win: bump this so the stale probe's
// result is discarded instead of clobbering the authenticated state.
const authEpoch = useRef(0)
useEffect(() => {
const epoch = authEpoch.current
;(async () => {
try {
const user = await getProfile()
if (authEpoch.current !== epoch) return
user.role = user.role || 0
setProfile(user)
} catch {
if (authEpoch.current !== epoch) return
setProfile(undefined)
} finally {
setLoading(false)
}
})()
}, [setProfile])
@@ -37,6 +49,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
try {
await loginRequest(username, password)
const user = await getProfile()
authEpoch.current++
user.role = user.role || 0
setProfile(user)
navigate("/dashboard")
@@ -47,40 +60,49 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
} else {
toast(msg || t("NetworkError"))
}
} finally {
// An explicit login resolves auth regardless of the still-pending
// mount probe; clear loading so ProtectedRoute stops blanking.
setLoading(false)
}
}, [navigate, setProfile, t])
const loginOauth2 = useCallback(async () => {
try {
const user = await getProfile()
authEpoch.current++
user.role = user.role || 0
setProfile(user)
navigate("/dashboard")
} catch (error: any) {
toast(error.message)
} finally {
setLoading(false)
window.history.replaceState({}, document.title, window.location.pathname)
}
}, [navigate, setProfile])
const logout = useCallback(() => {
authEpoch.current++
document.cookie.split(";").forEach(function (c) {
document.cookie = c
.replace(/^ +/, "")
.replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/")
})
setProfile(undefined)
setLoading(false)
navigate("/dashboard/login", { replace: true })
}, [navigate, setProfile])
const value = useMemo(
() => ({
profile,
loading,
login,
loginOauth2,
logout,
}),
[profile, login, loginOauth2, logout],
[profile, loading, login, loginOauth2, logout],
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}