feat(P21PWA2-1,P21PWA2-2,P21PWA2-3): 深链路由守卫/离线审批队列/SW缓存策略升级
这个提交包含在:
+133
-14
@@ -11,6 +11,7 @@ import {
|
||||
import { configureHttpAuth } from "../api/http";
|
||||
import type { AuthState, MeResponse } from "../api/types";
|
||||
import { changePassword as changePasswordApi } from "../api/auth";
|
||||
import { clearPwaRuntimeCaches } from "../pwa";
|
||||
import {
|
||||
fetchCurrentEmployee,
|
||||
loginWithPassword,
|
||||
@@ -19,12 +20,20 @@ import {
|
||||
} from "./authService";
|
||||
import { resolvePermissionsForRoles } from "./permissions";
|
||||
|
||||
type OfflineAuthSnapshot = {
|
||||
version: 1;
|
||||
me: MeResponse;
|
||||
permissions: string[];
|
||||
savedAt: string;
|
||||
};
|
||||
|
||||
type AuthContextValue = AuthState & {
|
||||
login: (identifier: string, password: string) => Promise<{ mustChangePassword: boolean }>;
|
||||
changePassword: (oldPassword: string, newPassword: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
bootstrapByRefresh: () => Promise<void>;
|
||||
clearSession: () => void;
|
||||
offlineAccessMode: boolean;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
@@ -38,13 +47,23 @@ const EMPTY_STATE: AuthState = {
|
||||
};
|
||||
|
||||
const MUST_CHANGE_PASSWORD_STORAGE_KEY = "hyb-oa.must_change_password";
|
||||
const OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY = "hyb-oa.auth_snapshot";
|
||||
const OFFLINE_AUTH_SNAPSHOT_VERSION = 1;
|
||||
const OFFLINE_AUTH_SNAPSHOT_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function isWindowAvailable() {
|
||||
return typeof window !== "undefined";
|
||||
}
|
||||
|
||||
function readMustChangePasswordFromStorage() {
|
||||
return typeof window !== "undefined" && window.localStorage.getItem(MUST_CHANGE_PASSWORD_STORAGE_KEY) === "1";
|
||||
return isWindowAvailable() && window.localStorage.getItem(MUST_CHANGE_PASSWORD_STORAGE_KEY) === "1";
|
||||
}
|
||||
|
||||
function writeMustChangePasswordToStorage(value: boolean) {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!isWindowAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (value) {
|
||||
window.localStorage.setItem(MUST_CHANGE_PASSWORD_STORAGE_KEY, "1");
|
||||
} else {
|
||||
@@ -52,22 +71,77 @@ function writeMustChangePasswordToStorage(value: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildStateWithUser(
|
||||
accessToken: string,
|
||||
me: MeResponse,
|
||||
mustChangePassword: boolean,
|
||||
): AuthState {
|
||||
return {
|
||||
accessToken,
|
||||
function readOfflineAuthSnapshot() {
|
||||
if (!isWindowAvailable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const rawValue = window.localStorage.getItem(OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY);
|
||||
if (!rawValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(rawValue) as Partial<OfflineAuthSnapshot> | null;
|
||||
if (!parsed || parsed.version !== OFFLINE_AUTH_SNAPSHOT_VERSION || !parsed.me || !Array.isArray(parsed.permissions) || typeof parsed.savedAt !== "string") {
|
||||
window.localStorage.removeItem(OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY);
|
||||
return null;
|
||||
}
|
||||
|
||||
const savedAt = new Date(parsed.savedAt);
|
||||
if (Number.isNaN(savedAt.getTime()) || Date.now() - savedAt.getTime() > OFFLINE_AUTH_SNAPSHOT_TTL_MS) {
|
||||
window.localStorage.removeItem(OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
version: OFFLINE_AUTH_SNAPSHOT_VERSION,
|
||||
me: parsed.me,
|
||||
permissions: parsed.permissions.map((item) => String(item)),
|
||||
savedAt: savedAt.toISOString(),
|
||||
} satisfies OfflineAuthSnapshot;
|
||||
} catch {
|
||||
window.localStorage.removeItem(OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeOfflineAuthSnapshot(me: MeResponse, permissions: string[]) {
|
||||
if (!isWindowAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot: OfflineAuthSnapshot = {
|
||||
version: OFFLINE_AUTH_SNAPSHOT_VERSION,
|
||||
me,
|
||||
permissions: resolvePermissionsForRoles(me.roleCodes ?? []),
|
||||
mustChangePassword,
|
||||
permissions,
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
window.localStorage.setItem(OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY, JSON.stringify(snapshot));
|
||||
}
|
||||
|
||||
function clearOfflineAuthSnapshot() {
|
||||
if (!isWindowAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY);
|
||||
}
|
||||
|
||||
function buildStateWithOfflineSnapshot(snapshot: OfflineAuthSnapshot): AuthState {
|
||||
return {
|
||||
accessToken: null,
|
||||
me: snapshot.me,
|
||||
permissions: snapshot.permissions,
|
||||
mustChangePassword: false,
|
||||
ready: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: PropsWithChildren) {
|
||||
const [state, setState] = useState<AuthState>(EMPTY_STATE);
|
||||
const [offlineAccessMode, setOfflineAccessMode] = useState(false);
|
||||
const tokenRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -76,7 +150,10 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
||||
|
||||
const clearSession = useCallback(() => {
|
||||
tokenRef.current = null;
|
||||
setOfflineAccessMode(false);
|
||||
writeMustChangePasswordToStorage(false);
|
||||
clearOfflineAuthSnapshot();
|
||||
void clearPwaRuntimeCaches();
|
||||
setState((previous) => ({
|
||||
...previous,
|
||||
accessToken: null,
|
||||
@@ -89,23 +166,48 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
||||
|
||||
const commitAuthenticatedUser = useCallback(
|
||||
(accessToken: string, me: MeResponse, mustChangePassword: boolean) => {
|
||||
const permissions = resolvePermissionsForRoles(me.roleCodes ?? []);
|
||||
tokenRef.current = accessToken;
|
||||
setOfflineAccessMode(false);
|
||||
writeMustChangePasswordToStorage(mustChangePassword);
|
||||
setState(buildStateWithUser(accessToken, me, mustChangePassword));
|
||||
writeOfflineAuthSnapshot(me, permissions);
|
||||
setState({
|
||||
accessToken,
|
||||
me,
|
||||
permissions,
|
||||
mustChangePassword,
|
||||
ready: true,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const activateOfflineSnapshot = useCallback((snapshot: OfflineAuthSnapshot) => {
|
||||
tokenRef.current = null;
|
||||
setOfflineAccessMode(true);
|
||||
setState(buildStateWithOfflineSnapshot(snapshot));
|
||||
}, []);
|
||||
|
||||
const bootstrapByRefresh = useCallback(async () => {
|
||||
const offlineSnapshot = readOfflineAuthSnapshot();
|
||||
if (isWindowAvailable() && window.navigator.onLine === false && offlineSnapshot) {
|
||||
activateOfflineSnapshot(offlineSnapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const refreshed = await refreshSession();
|
||||
const me = await fetchCurrentEmployee(refreshed.accessToken);
|
||||
const mustChangePassword = readMustChangePasswordFromStorage();
|
||||
commitAuthenticatedUser(refreshed.accessToken, me, mustChangePassword);
|
||||
} catch {
|
||||
if (isWindowAvailable() && window.navigator.onLine === false && offlineSnapshot) {
|
||||
activateOfflineSnapshot(offlineSnapshot);
|
||||
return;
|
||||
}
|
||||
clearSession();
|
||||
}
|
||||
}, [clearSession, commitAuthenticatedUser]);
|
||||
}, [activateOfflineSnapshot, clearSession, commitAuthenticatedUser]);
|
||||
|
||||
const login = useCallback(
|
||||
async (identifier: string, password: string) => {
|
||||
@@ -142,6 +244,7 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
||||
try {
|
||||
const refreshed = await refreshSession();
|
||||
tokenRef.current = refreshed.accessToken;
|
||||
setOfflineAccessMode(false);
|
||||
setState((previous) => ({
|
||||
...previous,
|
||||
accessToken: refreshed.accessToken,
|
||||
@@ -161,6 +264,21 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
||||
void bootstrapByRefresh();
|
||||
}, [bootstrapByRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!offlineAccessMode) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleOnline = () => {
|
||||
void bootstrapByRefresh();
|
||||
};
|
||||
|
||||
window.addEventListener("online", handleOnline);
|
||||
return () => {
|
||||
window.removeEventListener("online", handleOnline);
|
||||
};
|
||||
}, [bootstrapByRefresh, offlineAccessMode]);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
...state,
|
||||
@@ -169,8 +287,9 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
||||
logout,
|
||||
bootstrapByRefresh,
|
||||
clearSession,
|
||||
offlineAccessMode,
|
||||
}),
|
||||
[bootstrapByRefresh, changePassword, clearSession, login, logout, state],
|
||||
[bootstrapByRefresh, changePassword, clearSession, login, logout, offlineAccessMode, state],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
|
||||
在新工单中引用
屏蔽一个用户