Files
gongxue-base/apps/taro/src/app/AppProvider.tsx
2026-07-12 19:26:57 +08:00

369 lines
13 KiB
TypeScript

import {
createContext,
type PropsWithChildren,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { appEnv, assertFrontendSecretsAreAbsent, ensureRuntimeConfigLoaded, isWeappRuntime, type AppEnv } from '@/env';
import type { ApiEnvelope, ApiSession, CurrentUser, TenantContext } from '@/types';
import { ApiError, clearSession, clearTenantContext, getSession, getTenantContext, resolveTenant } from '@/services/api';
import { loadCurrentUser, logout as logoutRequest, subscribeAuthChanges } from '@/services/auth';
import { emitSessionChange } from './session-events';
import { loadPlatformPermissions } from '@/services/platformAdmin';
import { loadTenantPermissions, type TenantPermissionsPayload } from '@/services/tenantAdmin';
import { currentPagePath, isPathAllowedForPortal, shouldGuardPath } from '@/services/routeGuard';
import { runtimeHost } from '@/capabilities/navigation';
import { activateStorageUser, clearActiveStorageUserData } from '@/capabilities/storage';
import {
hasPlatformPermission,
hasTenantMenuAccess,
hasTenantPermission,
objectRecord,
type PlatformAccessSnapshot,
type TenantAccessSnapshot,
} from './permissions';
import { visiblePortalNavigation, type PortalNavigationItem } from './portal-navigation';
export type BootstrapStatus =
| 'idle'
| 'loading-runtime'
| 'resolving-tenant'
| 'authenticating'
| 'ready'
| 'unauthenticated'
| 'forbidden'
| 'error';
interface AppState {
runtimeConfig: AppEnv;
tenant: TenantContext | null;
currentUser: CurrentUser | null;
session: ApiSession | null;
tenantAccess: TenantAccessSnapshot | null;
platformAccess: PlatformAccessSnapshot | null;
bootstrapStatus: BootstrapStatus;
bootstrapError: string;
}
interface RefreshOptions {
path?: string;
forceTenant?: boolean;
authenticatePublic?: boolean;
silent?: boolean;
}
interface AppContextValue extends AppState {
currentPath: string;
refresh: (options?: RefreshOptions) => Promise<boolean>;
refreshTenant: () => Promise<boolean>;
switchTenant: (tenantCode: string) => Promise<boolean>;
signOut: () => Promise<void>;
canTenant: (permission?: string) => boolean;
canPlatform: (permission?: string) => boolean;
canTenantMenu: (input: { menuKey?: string; moduleKey?: string; permission?: string }) => boolean;
navigationItems: PortalNavigationItem[];
}
function initialState(): AppState {
return {
runtimeConfig: { ...appEnv },
tenant: appEnv.portal === 'platform-admin' ? null : getTenantContext(),
currentUser: null,
session: appEnv.portal === 'platform-admin' ? null : getSession(),
tenantAccess: null,
platformAccess: null,
bootstrapStatus: 'idle',
bootstrapError: '',
};
}
const AppContext = createContext<AppContextValue | null>(null);
function currentUserFrom(payload: ApiEnvelope<CurrentUser> | null) {
return payload?.user || payload?.item || null;
}
function currentSessionFrom(payload: ApiEnvelope<CurrentUser> | null) {
const stored = getSession();
if (!payload?.session) return stored;
if (payload.session.source !== 'app_session' || stored?.source !== 'app_session') return payload.session;
return {
...payload.session,
...(stored?.token ? { token: stored.token } : {}),
};
}
function tenantAccessFrom(payload: TenantPermissionsPayload): TenantAccessSnapshot {
const current = payload.current || {};
return {
role: String(current.role || ''),
permissions: objectRecord(current.permissions),
templatePermissions: objectRecord(current.templatePermissions),
effectivePermissions: objectRecord(current.effectivePermissions),
menuPermissions: objectRecord(current.menuPermissions),
modulePermissions: objectRecord(current.modulePermissions),
fieldPermissions: objectRecord(current.fieldPermissions),
dataScope: objectRecord(current.dataScope),
roleDefaults: payload.roleDefaults || {},
};
}
function bootstrapFailureStatus(error: unknown): BootstrapStatus {
if (error instanceof ApiError && error.status === 401) return 'unauthenticated';
if (error instanceof ApiError && error.status === 403) return 'forbidden';
return 'error';
}
function bootstrapFailureMessage(error: unknown) {
return error instanceof Error ? error.message : '应用初始化失败';
}
export function AppProvider({ children, path }: PropsWithChildren<{ path: string }>) {
const [state, setState] = useState<AppState>(initialState);
const stateRef = useRef(state);
const requestIdRef = useRef(0);
const pathRef = useRef(path);
const authenticatedAtRef = useRef(0);
useEffect(() => {
stateRef.current = state;
}, [state]);
useEffect(() => {
pathRef.current = path;
}, [path]);
const refresh = useCallback(async (options: RefreshOptions = {}) => {
const requestId = ++requestIdRef.current;
const targetPath = options.path || pathRef.current || currentPagePath();
if (!options.silent) {
setState(previous => ({
...previous,
bootstrapStatus: 'loading-runtime',
bootstrapError: '',
}));
}
try {
assertFrontendSecretsAreAbsent();
await ensureRuntimeConfigLoaded();
if (isWeappRuntime() && !appEnv.tenantCode) throw new Error('小程序启动参数缺少 tenantCode');
if (requestId !== requestIdRef.current) return false;
if (!options.silent) {
setState(previous => ({
...previous,
runtimeConfig: { ...appEnv },
bootstrapStatus: 'resolving-tenant',
}));
}
let tenant = appEnv.portal === 'platform-admin'
? null
: (options.forceTenant ? null : getTenantContext());
if (appEnv.portal !== 'platform-admin' && !tenant) tenant = await resolveTenant({ host: runtimeHost() });
if (requestId !== requestIdRef.current) return false;
if (!isPathAllowedForPortal(targetPath)) {
setState(previous => ({
...previous,
runtimeConfig: { ...appEnv },
tenant,
bootstrapStatus: 'forbidden',
bootstrapError: '当前构建入口不包含该页面',
}));
return false;
}
const protectedPath = shouldGuardPath(targetPath);
const shouldAuthenticate = protectedPath || options.authenticatePublic;
if (!shouldAuthenticate) {
const storedSession = getSession();
setState(previous => ({
...previous,
runtimeConfig: { ...appEnv },
tenant,
currentUser: storedSession ? previous.currentUser : null,
session: storedSession,
tenantAccess: null,
platformAccess: null,
bootstrapStatus: 'ready',
bootstrapError: '',
}));
return true;
}
if (!options.silent) setState(previous => ({ ...previous, tenant, bootstrapStatus: 'authenticating' }));
let userPayload: ApiEnvelope<CurrentUser> | null = null;
let tenantAccess: TenantAccessSnapshot | null = null;
let platformAccess: PlatformAccessSnapshot | null = null;
if (appEnv.portal === 'tenant-admin') {
const [permissionPayload, optionalUserPayload] = await Promise.all([
loadTenantPermissions(),
loadCurrentUser().catch(() => null),
]);
tenantAccess = tenantAccessFrom(permissionPayload);
userPayload = optionalUserPayload;
const tenantUserId = String(permissionPayload.current?.userId || '');
if (!userPayload && tenantUserId) {
userPayload = {
user: {
id: tenantUserId,
primaryRole: tenantAccess.role,
roles: [tenantAccess.role],
},
};
}
} else if (appEnv.portal === 'platform-admin') {
const permissionPayload = await loadPlatformPermissions();
const item = permissionPayload.item;
platformAccess = {
permissions: objectRecord(item?.permissions),
effectivePermissions: objectRecord(item?.effective),
};
if (item?.userId) {
userPayload = {
user: {
id: item.userId,
primaryRole: item.primaryRole || 'platform_admin',
roles: ['platform_admin'],
},
};
}
} else {
userPayload = await loadCurrentUser();
}
if (requestId !== requestIdRef.current) return false;
let currentUser = currentUserFrom(userPayload);
if (currentUser && tenantAccess?.role) {
currentUser = {
...currentUser,
roles: Array.from(new Set([...(currentUser.roles || []), tenantAccess.role])),
};
}
if (currentUser?.id && tenant?.tenantId) activateStorageUser(tenant.tenantId, currentUser.id);
authenticatedAtRef.current = Date.now();
setState({
runtimeConfig: { ...appEnv },
tenant,
currentUser,
session: appEnv.portal === 'platform-admin' ? null : currentSessionFrom(userPayload),
tenantAccess,
platformAccess,
bootstrapStatus: 'ready',
bootstrapError: '',
});
return true;
} catch (error) {
if (requestId !== requestIdRef.current) return false;
setState(previous => ({
...previous,
runtimeConfig: { ...appEnv },
tenant: appEnv.portal === 'platform-admin' ? null : getTenantContext(),
currentUser: null,
session: appEnv.portal === 'platform-admin' ? null : getSession(),
tenantAccess: null,
platformAccess: null,
bootstrapStatus: bootstrapFailureStatus(error),
bootstrapError: bootstrapFailureMessage(error),
}));
return false;
}
}, []);
useEffect(() => {
const current = stateRef.current;
const canReuseAuthenticatedState = shouldGuardPath(path)
&& current.bootstrapStatus === 'ready'
&& current.tenant
&& current.currentUser
&& Date.now() - authenticatedAtRef.current < 60_000;
if (!canReuseAuthenticatedState) void refresh({ path });
}, [path, refresh]);
useEffect(() => subscribeAuthChanges(() => {
void refresh({ path: pathRef.current, authenticatePublic: true });
}), [refresh]);
useEffect(() => {
let disposed = false;
let unsubscribe: () => void = () => undefined;
import('@/services/supabase')
.then(({ subscribeSupabaseAuthChanges }) => subscribeSupabaseAuthChanges((event) => {
if (event === 'SIGNED_IN') clearSession({ emit: false });
emitSessionChange('supabase');
}))
.then(nextUnsubscribe => {
if (disposed) nextUnsubscribe();
else unsubscribe = nextUnsubscribe;
})
.catch(() => undefined);
return () => {
disposed = true;
unsubscribe();
};
}, [state.runtimeConfig.supabasePublishableKey, state.runtimeConfig.supabaseUrl]);
const refreshTenant = useCallback(() => refresh({ path: pathRef.current, forceTenant: true, silent: true }), [refresh]);
const switchTenant = useCallback(async (tenantCode: string) => {
if (appEnv.portal === 'platform-admin') throw new Error('平台后台不使用业务租户启动上下文');
if (runtimeHost() && !isWeappRuntime()) throw new Error('H5 租户由当前域名确定,不能使用 tenantCode 覆盖');
const normalizedCode = tenantCode.trim();
if (!normalizedCode) throw new Error('tenantCode 不能为空');
clearTenantContext();
appEnv.tenantCode = normalizedCode;
return refresh({ path: pathRef.current, forceTenant: true });
}, [refresh]);
const signOut = useCallback(async () => {
requestIdRef.current += 1;
try {
await logoutRequest();
} finally {
requestIdRef.current += 1;
authenticatedAtRef.current = 0;
if (stateRef.current.tenant?.tenantId) clearActiveStorageUserData(stateRef.current.tenant.tenantId);
setState(previous => ({
...previous,
currentUser: null,
session: null,
tenantAccess: null,
platformAccess: null,
bootstrapStatus: 'unauthenticated',
bootstrapError: '',
}));
}
}, []);
const value = useMemo<AppContextValue>(() => ({
...state,
currentPath: path,
refresh,
refreshTenant,
switchTenant,
signOut,
canTenant: permission => hasTenantPermission(state.tenantAccess, permission),
canPlatform: permission => hasPlatformPermission(state.platformAccess, permission),
canTenantMenu: input => hasTenantMenuAccess(state.tenantAccess, input),
navigationItems: visiblePortalNavigation({
portal: state.runtimeConfig.portal,
tenantAccess: state.tenantAccess,
platformAccess: state.platformAccess,
}),
}), [state, path, refresh, refreshTenant, switchTenant, signOut]);
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}
export function useApp() {
const value = useContext(AppContext);
if (!value) throw new Error('useApp must be used inside AppProvider');
return value;
}