feat(auth): add cold-start token verification with loading state to app layout
All checks were successful
Frontend CI / ota (android) (push) Successful in 1m34s
Frontend CI / ota (ios) (push) Successful in 1m31s
Frontend CI / build-and-push-web (push) Successful in 18m8s
Frontend CI / build-android-apk (push) Successful in 35m6s

Move token verification from root layout SessionGate to (app) group layout,
enabling cold-start verification without blocking public pages (privacy/terms).
Show ActivityIndicator while verification is in progress, then redirect to
login if unverified.

BREAKING CHANGE: Authentication flow changed - token verification now occurs
in the (app) layout group instead of root layout SessionGate wrapper.
This commit is contained in:
lafay
2026-06-16 19:03:26 +08:00
parent 9b5e76b310
commit b2979311bb
5 changed files with 83 additions and 61 deletions

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "威友", "name": "威友",
"slug": "qojo", "slug": "qojo",
"version": "0.0.2", "version": "1.0.0",
"orientation": "default", "orientation": "default",
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",

View File

@@ -1,24 +1,54 @@
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { ActivityIndicator, View } from 'react-native';
import { Redirect } from 'expo-router'; import { Redirect } from 'expo-router';
import { AppRouteStack } from '../../src/app-navigation/AppRouteStack'; import { AppRouteStack } from '../../src/app-navigation/AppRouteStack';
import { messageManager, useAuthStore } from '../../src/stores'; import { messageManager, useAuthStore } from '../../src/stores';
import { useRegisterPushDevice } from '../../src/hooks'; import { useRegisterPushDevice } from '../../src/hooks';
import { hrefAuthLogin } from '../../src/navigation/hrefs'; import { hrefAuthLogin } from '../../src/navigation/hrefs';
import { useAppColors } from '../../src/theme';
export default function AppLayout() { export default function AppLayout() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const fetchCurrentUser = useAuthStore((s) => s.fetchCurrentUser);
const userID = useAuthStore((s) => s.currentUser?.id); const userID = useAuthStore((s) => s.currentUser?.id);
const colors = useAppColors();
const [verified, setVerified] = useState(false);
useEffect(() => {
// 冷启动 token 校验:仅鉴权分组 (app) 需要。
// 公开页(/privacy、/terms和 (auth) 分组在根布局直接渲染,不受此校验影响,
// 因此冷启动 401 不会把公开页顶到 /login。
fetchCurrentUser().finally(() => setVerified(true));
}, [fetchCurrentUser]);
useRegisterPushDevice(isAuthenticated, userID);
useEffect(() => { useEffect(() => {
messageManager.initialize(); messageManager.initialize();
}, []); }, []);
useRegisterPushDevice(isAuthenticated, userID); // 持久化状态显示已登录则立即渲染(后台静默校验)
if (isAuthenticated) {
if (!isAuthenticated) {
return <Redirect href={hrefAuthLogin()} />;
}
return <AppRouteStack />; return <AppRouteStack />;
} }
// 未登录且校验未完成,显示 loading
if (!verified) {
return (
<View
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.background.default,
}}
>
<ActivityIndicator size="large" color={colors.primary.main} />
</View>
);
}
// 校验完成仍未登录,跳转登录页
return <Redirect href={hrefAuthLogin()} />;
}

View File

@@ -1,5 +1,5 @@
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { useWindowDimensions } from 'react-native'; import { ActivityIndicator, View, useWindowDimensions } from 'react-native';
import { Redirect } from 'expo-router'; import { Redirect } from 'expo-router';
import { AppDesktopShell } from '../../src/app-navigation/AppDesktopShell'; import { AppDesktopShell } from '../../src/app-navigation/AppDesktopShell';
@@ -7,23 +7,50 @@ import { AppRouteStack } from '../../src/app-navigation/AppRouteStack';
import { BREAKPOINTS } from '../../src/hooks'; import { BREAKPOINTS } from '../../src/hooks';
import { messageManager, useAuthStore } from '../../src/stores'; import { messageManager, useAuthStore } from '../../src/stores';
import { hrefAuthLogin } from '../../src/navigation/hrefs'; import { hrefAuthLogin } from '../../src/navigation/hrefs';
import { useAppColors } from '../../src/theme';
export default function AppLayoutWeb() { export default function AppLayoutWeb() {
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const fetchCurrentUser = useAuthStore((s) => s.fetchCurrentUser);
const useDesktopShell = width >= BREAKPOINTS.desktop; const useDesktopShell = width >= BREAKPOINTS.desktop;
const colors = useAppColors();
const [verified, setVerified] = useState(false);
useEffect(() => {
// 冷启动 token 校验:仅鉴权分组 (app) 需要。
// 公开页(/privacy、/terms和 (auth) 分组在根布局直接渲染,不受此校验影响。
fetchCurrentUser().finally(() => setVerified(true));
}, [fetchCurrentUser]);
useEffect(() => { useEffect(() => {
messageManager.initialize(); messageManager.initialize();
}, []); }, []);
if (!isAuthenticated) { // 持久化状态显示已登录则立即渲染(后台静默校验)
return <Redirect href={hrefAuthLogin()} />; if (isAuthenticated) {
}
if (useDesktopShell) { if (useDesktopShell) {
return <AppDesktopShell />; return <AppDesktopShell />;
} }
return <AppRouteStack />; return <AppRouteStack />;
} }
// 未登录且校验未完成,显示 loading
if (!verified) {
return (
<View
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.background.default,
}}
>
<ActivityIndicator size="large" color={colors.primary.main} />
</View>
);
}
// 校验完成仍未登录,跳转登录页
return <Redirect href={hrefAuthLogin()} />;
}

View File

@@ -1,5 +1,5 @@
import '../src/polyfills'; import '../src/polyfills';
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef } from 'react';
import { AppState, AppStateStatus, Platform, View, ActivityIndicator } from 'react-native'; import { AppState, AppStateStatus, Platform, View, ActivityIndicator } from 'react-native';
import { Stack, useRouter } from 'expo-router'; import { Stack, useRouter } from 'expo-router';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
@@ -11,8 +11,6 @@ import * as SystemUI from 'expo-system-ui';
import { useFonts } from 'expo-font'; import { useFonts } from 'expo-font';
import { registerNotificationPresentationHandler } from '@/services/notification'; import { registerNotificationPresentationHandler } from '@/services/notification';
import { api } from '@/services/core';
import { wsService } from '@/services/core';
import { EventSubscriber } from '../src/infrastructure/EventSubscriber'; import { EventSubscriber } from '../src/infrastructure/EventSubscriber';
import { import {
ThemeBootstrap, ThemeBootstrap,
@@ -24,7 +22,6 @@ import {
import AppPromptBar from '../src/components/common/AppPromptBar'; import AppPromptBar from '../src/components/common/AppPromptBar';
import AppDialogHost from '../src/components/common/AppDialogHost'; import AppDialogHost from '../src/components/common/AppDialogHost';
import { installAlertOverride } from '@/services/ui'; import { installAlertOverride } from '@/services/ui';
import { useAuthStore } from '../src/stores';
import { checkForAPKUpdate } from '@/services/platform'; import { checkForAPKUpdate } from '@/services/platform';
import { CallScreen, IncomingCallModal, FloatingCallWindow } from '../src/components/call'; import { CallScreen, IncomingCallModal, FloatingCallWindow } from '../src/components/call';
import { jpushService } from '@/services/notification/jpushService'; import { jpushService } from '@/services/notification/jpushService';
@@ -104,36 +101,6 @@ function SystemChrome() {
return null; return null;
} }
function SessionGate({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const fetchCurrentUser = useAuthStore((s) => s.fetchCurrentUser);
const colors = useAppColors();
const [verified, setVerified] = useState(false);
useEffect(() => {
fetchCurrentUser().finally(() => setVerified(true));
}, [fetchCurrentUser]);
// If persisted state shows authenticated, render immediately (background verify)
// If not authenticated and not yet verified, show loading
if (!isAuthenticated && !verified) {
return (
<View
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.background.default,
}}
>
<ActivityIndicator size="large" color={colors.primary.main} />
</View>
);
}
// If verified and not authenticated, the Redirect in (app)/_layout.tsx handles it
return <>{children}</>;
}
function NotificationBootstrap() { function NotificationBootstrap() {
const appState = useRef<AppStateStatus>(AppState.currentState); const appState = useRef<AppStateStatus>(AppState.currentState);
const permissionRequested = useRef(false); const permissionRequested = useRef(false);
@@ -280,7 +247,6 @@ function ThemedStack() {
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} /> <StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
<SystemChrome /> <SystemChrome />
<EventSubscriber /> <EventSubscriber />
<SessionGate>
<NotificationBootstrap /> <NotificationBootstrap />
<APKUpdateBootstrap /> <APKUpdateBootstrap />
<CallKeepBootstrap /> <CallKeepBootstrap />
@@ -291,7 +257,6 @@ function ThemedStack() {
<Stack.Screen name="privacy" options={{ headerShown: false }} /> <Stack.Screen name="privacy" options={{ headerShown: false }} />
<Stack.Screen name="terms" options={{ headerShown: false }} /> <Stack.Screen name="terms" options={{ headerShown: false }} />
</Stack> </Stack>
</SessionGate>
</> </>
); );
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "with_you", "name": "with_you",
"version": "0.0.2", "version": "1.0.0",
"main": "expo-router/entry", "main": "expo-router/entry",
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",