forked from wangziqi/gongxue-base
369 lines
13 KiB
TypeScript
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;
|
|
}
|