🐛 fix(desktop): resolve onboarding navigation issues after logout (#11628)

* 🐛 fix(desktop): resolve onboarding navigation issues after logout

- Refactor step-based navigation to screen-based navigation system
- Add DesktopOnboardingScreen enum for type-safe screen handling
- Fix screen persistence and URL synchronization
- Improve platform-specific screen resolution (macOS permissions)
- Extract navigation logic into reusable utility functions

* cleanup

Signed-off-by: Innei <tukon479@gmail.com>

* 🐛 fix(UserPanel): handle errors during remote server config clearance

- Added error handling for the remote server configuration clearance process in the UserPanel component.
- Ensured that the onboarding completion and sign-out actions are executed regardless of the error state.

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2026-01-20 01:04:03 +08:00
committed by GitHub
parent 777b561d68
commit 05a08734ba
7 changed files with 207 additions and 130 deletions
+3 -1
View File
@@ -21,7 +21,9 @@ async function generateJwks() {
console.error('正在生成 RSA 密钥对...'); console.error('正在生成 RSA 密钥对...');
// 生成 RS256 密钥对 // 生成 RS256 密钥对
const { privateKey } = await generateKeyPair('RS256'); const { privateKey } = await generateKeyPair('RS256', {
extractable: true,
});
// 导出为 JWK 格式 // 导出为 JWK 格式
const jwk = await exportJWK(privateKey); const jwk = await exportJWK(privateKey);
@@ -199,10 +199,13 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
}); });
const handleCancelAuth = async () => { const handleCancelAuth = async () => {
await remoteServerService.cancelAuthorization(); setRemoteError(null);
clearRemoteServerSyncError();
setCloudLoginStatus('idle'); setCloudLoginStatus('idle');
setSelfhostLoginStatus('idle'); setSelfhostLoginStatus('idle');
setAuthProgress(null); setAuthProgress(null);
await remoteServerService.cancelAuthorization();
}; };
// 渲染 Cloud 登录内容 // 渲染 Cloud 登录内容
@@ -238,10 +241,9 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
if (cloudLoginStatus === 'error') { if (cloudLoginStatus === 'error') {
return ( return (
<> <Flexbox style={{ width: '100%' }}>
<Alert <Alert
description={remoteError || t('authResult.failed.desc')} description={remoteError || t('authResult.failed.desc')}
style={{ width: '100%' }}
title={t('authResult.failed.title')} title={t('authResult.failed.title')}
type={'secondary'} type={'secondary'}
/> />
@@ -254,7 +256,7 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
> >
{t('screen5.actions.tryAgain')} {t('screen5.actions.tryAgain')}
</Button> </Button>
</> </Flexbox>
); );
} }
@@ -340,10 +342,9 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
if (selfhostLoginStatus === 'error') { if (selfhostLoginStatus === 'error') {
return ( return (
<Flexbox gap={16}> <Flexbox gap={16} style={{ width: '100%' }}>
<Alert <Alert
description={remoteError || t('authResult.failed.desc')} description={remoteError || t('authResult.failed.desc')}
style={{ width: '100%' }}
title={t('authResult.failed.title')} title={t('authResult.failed.title')}
type={'secondary'} type={'secondary'}
/> />
@@ -354,6 +355,47 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
); );
} }
if (selfhostLoginStatus === 'loading') {
const phaseText = t(authorizationPhaseI18nKeyMap[authProgress?.phase ?? 'browser_opened'], {
defaultValue: t('screen5.actions.connecting'),
});
const remainingSeconds = authProgress
? Math.max(0, Math.ceil((authProgress.maxPollTime - authProgress.elapsed) / 1000))
: null;
return (
<Flexbox gap={8} style={{ width: '100%' }}>
<Button
block
disabled={true}
icon={Server}
loading={true}
size={'large'}
type={'primary'}
>
{t('screen5.actions.connecting')}
</Button>
<Text style={{ color: cssVar.colorTextDescription }} type={'secondary'}>
{phaseText}
</Text>
<Flexbox align={'center'} horizontal justify={'space-between'}>
{remainingSeconds !== null ? (
<Text style={{ color: cssVar.colorTextDescription }} type={'secondary'}>
{t('screen5.auth.remaining', {
time: remainingSeconds,
})}
</Text>
) : (
<div />
)}
<Button onClick={handleCancelAuth} size={'small'} type={'text'}>
{t('screen5.actions.cancel')}
</Button>
</Flexbox>
</Flexbox>
);
}
return ( return (
<Flexbox gap={16} style={{ width: '100%' }}> <Flexbox gap={16} style={{ width: '100%' }}>
<Text color={cssVar.colorTextSecondary}>{t(loginMethodMetas.selfhost.descriptionKey)}</Text> <Text color={cssVar.colorTextSecondary}>{t(loginMethodMetas.selfhost.descriptionKey)}</Text>
@@ -365,6 +407,11 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
const { electronSystemService } = await import('@/services/electron/system'); const { electronSystemService } = await import('@/services/electron/system');
await electronSystemService.showContextMenu('edit'); await electronSystemService.showContextMenu('edit');
}} }}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleSelfhostConnect();
}
}}
placeholder={t('screen5.selfhost.endpointPlaceholder')} placeholder={t('screen5.selfhost.endpointPlaceholder')}
prefix={<Icon icon={Server} style={{ marginRight: 4 }} />} prefix={<Icon icon={Server} style={{ marginRight: 4 }} />}
size={'large'} size={'large'}
@@ -372,16 +419,14 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
value={endpoint} value={endpoint}
/> />
<Button <Button
disabled={!endpoint.trim() || selfhostLoginStatus === 'loading' || isConnectingServer} disabled={!endpoint.trim() || isConnectingServer}
loading={selfhostLoginStatus === 'loading'} loading={false}
onClick={handleSelfhostConnect} onClick={handleSelfhostConnect}
size={'large'} size={'large'}
style={{ width: '100%' }} style={{ width: '100%' }}
type={'primary'} type={'primary'}
> >
{selfhostLoginStatus === 'loading' {t('screen5.actions.connectToServer')}
? t('screen5.actions.connecting')
: t('screen5.actions.connectToServer')}
</Button> </Button>
</Flexbox> </Flexbox>
); );
@@ -14,40 +14,79 @@ import LoginStep from './features/LoginStep';
import PermissionsStep from './features/PermissionsStep'; import PermissionsStep from './features/PermissionsStep';
import WelcomeStep from './features/WelcomeStep'; import WelcomeStep from './features/WelcomeStep';
import { import {
clearDesktopOnboardingStep, clearDesktopOnboardingScreen,
getDesktopOnboardingStep, getDesktopOnboardingScreen,
setDesktopOnboardingCompleted, setDesktopOnboardingCompleted,
setDesktopOnboardingStep, setDesktopOnboardingScreen,
} from './storage'; } from './storage';
import { DesktopOnboardingScreen, isDesktopOnboardingScreen } from './types';
const DesktopOnboardingPage = memo(() => { const DesktopOnboardingPage = memo(() => {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [isMac, setIsMac] = useState(true); const [isMac, setIsMac] = useState(true);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
// 从 localStorage 或 URL query 参数获取初始步骤 const flow = isMac
// 优先使用 localStorage 以支持重启后恢复 ? [
const getInitialStep = useCallback(() => { DesktopOnboardingScreen.Welcome,
// First try localStorage (for app restart scenario) DesktopOnboardingScreen.Permissions,
const savedStep = getDesktopOnboardingStep(); DesktopOnboardingScreen.DataMode,
if (savedStep !== null) { DesktopOnboardingScreen.Login,
return savedStep; ]
} : [
// Then try URL params DesktopOnboardingScreen.Welcome,
const stepParam = searchParams.get('step'); DesktopOnboardingScreen.DataMode,
if (stepParam) { DesktopOnboardingScreen.Login,
const step = parseInt(stepParam, 10); ];
if (step >= 1 && step <= 4) return step;
} const resolveScreenForPlatform = useCallback(
return 1; (screen: DesktopOnboardingScreen) => {
if (!isMac && screen === DesktopOnboardingScreen.Permissions)
return DesktopOnboardingScreen.DataMode;
return screen;
},
[isMac],
);
const getRequestedScreenFromUrl = useCallback((): DesktopOnboardingScreen | null => {
const screenParam = searchParams.get('screen');
if (isDesktopOnboardingScreen(screenParam)) return screenParam;
return null;
}, [searchParams]); }, [searchParams]);
const [currentStep, setCurrentStep] = useState(getInitialStep); const [currentScreen, setCurrentScreen] = useState<DesktopOnboardingScreen>(
DesktopOnboardingScreen.Welcome,
);
// 持久化当前步骤到 localStorage
useEffect(() => { useEffect(() => {
setDesktopOnboardingStep(currentStep); if (isLoading) return;
}, [currentStep]);
const saved = getDesktopOnboardingScreen();
const requested = getRequestedScreenFromUrl();
const initial = resolveScreenForPlatform(requested ?? saved ?? DesktopOnboardingScreen.Welcome);
setCurrentScreen(initial);
// Canonicalize URL to `?screen=...`
const currentUrlScreen = searchParams.get('screen');
if (currentUrlScreen !== initial) {
setSearchParams({ screen: initial });
}
}, [
getRequestedScreenFromUrl,
isLoading,
resolveScreenForPlatform,
searchParams,
setSearchParams,
]);
// Persist current screen to localStorage.
useEffect(() => {
if (isLoading) return;
setDesktopOnboardingScreen(currentScreen);
}, [currentScreen, isLoading]);
// 设置窗口大小和可调整性 // 设置窗口大小和可调整性
useEffect(() => { useEffect(() => {
@@ -91,79 +130,48 @@ const DesktopOnboardingPage = memo(() => {
}; };
}, []); }, []);
// 监听 URL query 参数变化 // Listen URL changes: allow deep-linking between screens.
useEffect(() => { useEffect(() => {
const stepParam = searchParams.get('step'); if (isLoading) return;
if (stepParam) { const requested = getRequestedScreenFromUrl();
const step = parseInt(stepParam, 10); if (!requested) return;
if (step >= 1 && step <= 4 && step !== currentStep) { const resolved = resolveScreenForPlatform(requested);
setCurrentStep(step); if (resolved !== currentScreen) setCurrentScreen(resolved);
} }, [currentScreen, getRequestedScreenFromUrl, isLoading, resolveScreenForPlatform]);
}
}, [searchParams, currentStep]);
const goToNextStep = useCallback(() => { const goToNextStep = useCallback(() => {
setCurrentStep((prev) => { setCurrentScreen((prev) => {
let nextStep: number; const idx = flow.indexOf(prev);
// 如果是第1步(WelcomeStep),下一步根据平台决定 const next = flow[idx + 1];
switch (prev) {
case 1: {
nextStep = isMac ? 2 : 3; // macOS 显示权限页,其他平台跳过
break; if (!next) {
} // Complete onboarding.
case 2: { setDesktopOnboardingCompleted();
// 如果是第2步(PermissionsStep,仅 macOS),下一步是第3步 clearDesktopOnboardingScreen();
nextStep = 3;
break; // Restore window minimum size before hard reload (cleanup won't run due to hard navigation)
} electronSystemService
case 3: { .setWindowMinimumSize(APP_WINDOW_MIN_SIZE)
// 如果是第3步(DataModeStep),下一步是第4步 .catch(console.error)
nextStep = 4; .finally(() => {
// Use hard reload instead of SPA navigation to ensure the app boots with the new desktop state.
window.location.replace('/');
});
break; return prev;
}
case 4: {
// 如果是第4步(LoginStep),完成 onboarding
setDesktopOnboardingCompleted();
clearDesktopOnboardingStep(); // Clear persisted step since onboarding is complete
// Restore window minimum size before hard reload (cleanup won't run due to hard navigation)
electronSystemService
.setWindowMinimumSize(APP_WINDOW_MIN_SIZE)
.catch(console.error)
.finally(() => {
// Use hard reload instead of SPA navigation to ensure the app boots with the new desktop state.
window.location.replace('/');
});
return prev;
}
default: {
nextStep = prev + 1;
}
} }
// 更新 URL query 参数
setSearchParams({ step: nextStep.toString() }); setSearchParams({ screen: next });
return nextStep; return next;
}); });
}, [isMac, setSearchParams]); }, [isMac, setSearchParams]);
const goToPreviousStep = useCallback(() => { const goToPreviousStep = useCallback(() => {
setCurrentStep((prev) => { setCurrentScreen((prev) => {
if (prev <= 1) return 1; const idx = flow.indexOf(prev);
let prevStep: number; const prevScreen = flow[Math.max(0, idx - 1)] ?? DesktopOnboardingScreen.Welcome;
// 如果当前是第3步(DataModeStep),上一步根据平台决定 setSearchParams({ screen: prevScreen });
if (prev === 3) { return prevScreen;
prevStep = isMac ? 2 : 1;
} else if (prev === 2) {
// 如果当前是第2步(PermissionsStep),上一步是第1步
prevStep = 1;
} else {
prevStep = prev - 1;
}
// 更新 URL query 参数
setSearchParams({ step: prevStep.toString() });
return prevStep;
}); });
}, [isMac, setSearchParams]); }, [isMac, setSearchParams]);
@@ -172,21 +180,22 @@ const DesktopOnboardingPage = memo(() => {
} }
const renderStep = () => { const renderStep = () => {
switch (currentStep) { switch (currentScreen) {
case 1: { case DesktopOnboardingScreen.Welcome: {
return <WelcomeStep onNext={goToNextStep} />; return <WelcomeStep onNext={goToNextStep} />;
} }
case 2: { case DesktopOnboardingScreen.Permissions: {
// macOS 显示权限页 // macOS-only screen; fallback to DataMode if platform doesn't support.
if (!isMac) { if (!isMac) {
return <DataModeStep onBack={goToPreviousStep} onNext={goToNextStep} />; setCurrentScreen(DesktopOnboardingScreen.DataMode);
return null;
} }
return <PermissionsStep onBack={goToPreviousStep} onNext={goToNextStep} />; return <PermissionsStep onBack={goToPreviousStep} onNext={goToNextStep} />;
} }
case 3: { case DesktopOnboardingScreen.DataMode: {
return <DataModeStep onBack={goToPreviousStep} onNext={goToNextStep} />; return <DataModeStep onBack={goToPreviousStep} onNext={goToNextStep} />;
} }
case 4: { case DesktopOnboardingScreen.Login: {
return <LoginStep onBack={goToPreviousStep} onNext={goToNextStep} />; return <LoginStep onBack={goToPreviousStep} onNext={goToNextStep} />;
} }
default: { default: {
@@ -0,0 +1,11 @@
import { DesktopOnboardingScreen } from './types';
const DESKTOP_ONBOARDING_ROUTE = '/desktop-onboarding';
export const getDesktopOnboardingPath = (screen?: DesktopOnboardingScreen) => {
if (!screen) return DESKTOP_ONBOARDING_ROUTE;
return `${DESKTOP_ONBOARDING_ROUTE}?screen=${encodeURIComponent(screen)}`;
};
export const navigateToDesktopOnboarding = (screen?: DesktopOnboardingScreen) => {
location.href = getDesktopOnboardingPath(screen);
};
@@ -1,5 +1,7 @@
import { DesktopOnboardingScreen, isDesktopOnboardingScreen } from './types';
export const DESKTOP_ONBOARDING_STORAGE_KEY = 'lobechat:desktop:onboarding:completed:v1'; export const DESKTOP_ONBOARDING_STORAGE_KEY = 'lobechat:desktop:onboarding:completed:v1';
export const DESKTOP_ONBOARDING_STEP_KEY = 'lobechat:desktop:onboarding:step:v1'; export const DESKTOP_ONBOARDING_SCREEN_KEY = 'lobechat:desktop:onboarding:screen:v1';
export const getDesktopOnboardingCompleted = () => { export const getDesktopOnboardingCompleted = () => {
if (typeof window === 'undefined') return true; if (typeof window === 'undefined') return true;
@@ -35,33 +37,29 @@ export const clearDesktopOnboardingCompleted = () => {
}; };
/** /**
* Get the persisted onboarding step (for restoring after app restart) * Get the persisted onboarding screen (for restoring after app restart)
*/ */
export const getDesktopOnboardingStep = (): number | null => { export const getDesktopOnboardingScreen = () => {
if (typeof window === 'undefined') return null; if (typeof window === 'undefined') return null;
try { try {
const step = window.localStorage.getItem(DESKTOP_ONBOARDING_STEP_KEY); const screen = window.localStorage.getItem(DESKTOP_ONBOARDING_SCREEN_KEY);
if (step) { if (!screen) return null;
const parsedStep = Number.parseInt(step, 10); if (!isDesktopOnboardingScreen(screen)) return null;
if (parsedStep >= 1 && parsedStep <= 4) { return screen;
return parsedStep;
}
}
return null;
} catch { } catch {
return null; return null;
} }
}; };
/** /**
* Persist the current onboarding step * Persist the current onboarding screen
*/ */
export const setDesktopOnboardingStep = (step: number) => { export const setDesktopOnboardingScreen = (screen: DesktopOnboardingScreen) => {
if (typeof window === 'undefined') return false; if (typeof window === 'undefined') return false;
try { try {
window.localStorage.setItem(DESKTOP_ONBOARDING_STEP_KEY, step.toString()); window.localStorage.setItem(DESKTOP_ONBOARDING_SCREEN_KEY, screen);
return true; return true;
} catch { } catch {
return false; return false;
@@ -69,13 +67,13 @@ export const setDesktopOnboardingStep = (step: number) => {
}; };
/** /**
* Clear the persisted onboarding step (called when onboarding completes) * Clear the persisted onboarding screen (called when onboarding completes)
*/ */
export const clearDesktopOnboardingStep = () => { export const clearDesktopOnboardingScreen = () => {
if (typeof window === 'undefined') return false; if (typeof window === 'undefined') return false;
try { try {
window.localStorage.removeItem(DESKTOP_ONBOARDING_STEP_KEY); window.localStorage.removeItem(DESKTOP_ONBOARDING_SCREEN_KEY);
return true; return true;
} catch { } catch {
return false; return false;
@@ -0,0 +1,11 @@
export enum DesktopOnboardingScreen {
DataMode = 'data-mode',
Login = 'login',
Permissions = 'permissions',
Welcome = 'welcome',
}
export const isDesktopOnboardingScreen = (value: unknown): value is DesktopOnboardingScreen => {
if (typeof value !== 'string') return false;
return (Object.values(DesktopOnboardingScreen) as string[]).includes(value);
};
+11 -10
View File
@@ -1,15 +1,17 @@
import { ENABLE_BUSINESS_FEATURES } from '@lobechat/business-const'; import { ENABLE_BUSINESS_FEATURES } from '@lobechat/business-const';
import { Flexbox } from '@lobehub/ui'; import { Flexbox } from '@lobehub/ui';
import { useRouter } from '@/libs/next/navigation';
import { memo } from 'react'; import { memo } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { navigateToDesktopOnboarding } from '@/app/[variants]/(desktop)/desktop-onboarding/navigation';
import { clearDesktopOnboardingCompleted } from '@/app/[variants]/(desktop)/desktop-onboarding/storage'; import { clearDesktopOnboardingCompleted } from '@/app/[variants]/(desktop)/desktop-onboarding/storage';
import { DesktopOnboardingScreen } from '@/app/[variants]/(desktop)/desktop-onboarding/types';
import BusinessPanelContent from '@/business/client/features/User/BusinessPanelContent'; import BusinessPanelContent from '@/business/client/features/User/BusinessPanelContent';
import BrandWatermark from '@/components/BrandWatermark'; import BrandWatermark from '@/components/BrandWatermark';
import Menu from '@/components/Menu'; import Menu from '@/components/Menu';
import { isDesktop } from '@/const/version'; import { isDesktop } from '@/const/version';
import { enableBetterAuth, enableNextAuth } from '@/envs/auth'; import { enableBetterAuth, enableNextAuth } from '@/envs/auth';
import { useRouter } from '@/libs/next/navigation';
import { useUserStore } from '@/store/user'; import { useUserStore } from '@/store/user';
import { authSelectors } from '@/store/user/selectors'; import { authSelectors } from '@/store/user/selectors';
@@ -21,7 +23,7 @@ import { useMenu } from './useMenu';
const PanelContent = memo<{ closePopover: () => void }>(({ closePopover }) => { const PanelContent = memo<{ closePopover: () => void }>(({ closePopover }) => {
const router = useRouter(); const router = useRouter();
const navigate = useNavigate();
const isLoginWithAuth = useUserStore(authSelectors.isLoginWithAuth); const isLoginWithAuth = useUserStore(authSelectors.isLoginWithAuth);
const [openSignIn, signOut] = useUserStore((s) => [s.openLogin, s.logout]); const [openSignIn, signOut] = useUserStore((s) => [s.openLogin, s.logout]);
const { mainItems, logoutItems } = useMenu(); const { mainItems, logoutItems } = useMenu();
@@ -35,17 +37,16 @@ const PanelContent = memo<{ closePopover: () => void }>(({ closePopover }) => {
if (isDesktop) { if (isDesktop) {
closePopover(); closePopover();
// Desktop: clear OIDC tokens (electron main) + re-enter desktop onboarding at Screen5.
try { try {
const { remoteServerService } = await import('@/services/electron/remoteServer'); const { remoteServerService } = await import('@/services/electron/remoteServer');
await remoteServerService.clearRemoteServerConfig(); await remoteServerService.clearRemoteServerConfig();
} catch { } catch (error) {
// Ignore: even if IPC is unavailable, still proceed to onboarding. console.error(error);
} finally {
clearDesktopOnboardingCompleted();
signOut();
navigateToDesktopOnboarding(DesktopOnboardingScreen.Login);
} }
clearDesktopOnboardingCompleted();
signOut();
navigate('/desktop-onboarding#5', { replace: true });
return; return;
} }