🐛 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

View File

@@ -21,7 +21,9 @@ async function generateJwks() {
console.error('正在生成 RSA 密钥对...');
// 生成 RS256 密钥对
const { privateKey } = await generateKeyPair('RS256');
const { privateKey } = await generateKeyPair('RS256', {
extractable: true,
});
// 导出为 JWK 格式
const jwk = await exportJWK(privateKey);

View File

@@ -199,10 +199,13 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
});
const handleCancelAuth = async () => {
await remoteServerService.cancelAuthorization();
setRemoteError(null);
clearRemoteServerSyncError();
setCloudLoginStatus('idle');
setSelfhostLoginStatus('idle');
setAuthProgress(null);
await remoteServerService.cancelAuthorization();
};
// 渲染 Cloud 登录内容
@@ -238,10 +241,9 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
if (cloudLoginStatus === 'error') {
return (
<>
<Flexbox style={{ width: '100%' }}>
<Alert
description={remoteError || t('authResult.failed.desc')}
style={{ width: '100%' }}
title={t('authResult.failed.title')}
type={'secondary'}
/>
@@ -254,7 +256,7 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
>
{t('screen5.actions.tryAgain')}
</Button>
</>
</Flexbox>
);
}
@@ -340,10 +342,9 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
if (selfhostLoginStatus === 'error') {
return (
<Flexbox gap={16}>
<Flexbox gap={16} style={{ width: '100%' }}>
<Alert
description={remoteError || t('authResult.failed.desc')}
style={{ width: '100%' }}
title={t('authResult.failed.title')}
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 (
<Flexbox gap={16} style={{ width: '100%' }}>
<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');
await electronSystemService.showContextMenu('edit');
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleSelfhostConnect();
}
}}
placeholder={t('screen5.selfhost.endpointPlaceholder')}
prefix={<Icon icon={Server} style={{ marginRight: 4 }} />}
size={'large'}
@@ -372,16 +419,14 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
value={endpoint}
/>
<Button
disabled={!endpoint.trim() || selfhostLoginStatus === 'loading' || isConnectingServer}
loading={selfhostLoginStatus === 'loading'}
disabled={!endpoint.trim() || isConnectingServer}
loading={false}
onClick={handleSelfhostConnect}
size={'large'}
style={{ width: '100%' }}
type={'primary'}
>
{selfhostLoginStatus === 'loading'
? t('screen5.actions.connecting')
: t('screen5.actions.connectToServer')}
{t('screen5.actions.connectToServer')}
</Button>
</Flexbox>
);

View File

@@ -14,40 +14,79 @@ import LoginStep from './features/LoginStep';
import PermissionsStep from './features/PermissionsStep';
import WelcomeStep from './features/WelcomeStep';
import {
clearDesktopOnboardingStep,
getDesktopOnboardingStep,
clearDesktopOnboardingScreen,
getDesktopOnboardingScreen,
setDesktopOnboardingCompleted,
setDesktopOnboardingStep,
setDesktopOnboardingScreen,
} from './storage';
import { DesktopOnboardingScreen, isDesktopOnboardingScreen } from './types';
const DesktopOnboardingPage = memo(() => {
const [searchParams, setSearchParams] = useSearchParams();
const [isMac, setIsMac] = useState(true);
const [isLoading, setIsLoading] = useState(true);
// 从 localStorage 或 URL query 参数获取初始步骤
// 优先使用 localStorage 以支持重启后恢复
const getInitialStep = useCallback(() => {
// First try localStorage (for app restart scenario)
const savedStep = getDesktopOnboardingStep();
if (savedStep !== null) {
return savedStep;
}
// Then try URL params
const stepParam = searchParams.get('step');
if (stepParam) {
const step = parseInt(stepParam, 10);
if (step >= 1 && step <= 4) return step;
}
return 1;
const flow = isMac
? [
DesktopOnboardingScreen.Welcome,
DesktopOnboardingScreen.Permissions,
DesktopOnboardingScreen.DataMode,
DesktopOnboardingScreen.Login,
]
: [
DesktopOnboardingScreen.Welcome,
DesktopOnboardingScreen.DataMode,
DesktopOnboardingScreen.Login,
];
const resolveScreenForPlatform = useCallback(
(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]);
const [currentStep, setCurrentStep] = useState(getInitialStep);
const [currentScreen, setCurrentScreen] = useState<DesktopOnboardingScreen>(
DesktopOnboardingScreen.Welcome,
);
// 持久化当前步骤到 localStorage
useEffect(() => {
setDesktopOnboardingStep(currentStep);
}, [currentStep]);
if (isLoading) return;
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(() => {
@@ -91,79 +130,48 @@ const DesktopOnboardingPage = memo(() => {
};
}, []);
// 监听 URL query 参数变化
// Listen URL changes: allow deep-linking between screens.
useEffect(() => {
const stepParam = searchParams.get('step');
if (stepParam) {
const step = parseInt(stepParam, 10);
if (step >= 1 && step <= 4 && step !== currentStep) {
setCurrentStep(step);
}
}
}, [searchParams, currentStep]);
if (isLoading) return;
const requested = getRequestedScreenFromUrl();
if (!requested) return;
const resolved = resolveScreenForPlatform(requested);
if (resolved !== currentScreen) setCurrentScreen(resolved);
}, [currentScreen, getRequestedScreenFromUrl, isLoading, resolveScreenForPlatform]);
const goToNextStep = useCallback(() => {
setCurrentStep((prev) => {
let nextStep: number;
// 如果是第1步WelcomeStep下一步根据平台决定
switch (prev) {
case 1: {
nextStep = isMac ? 2 : 3; // macOS 显示权限页,其他平台跳过
setCurrentScreen((prev) => {
const idx = flow.indexOf(prev);
const next = flow[idx + 1];
break;
}
case 2: {
// 如果是第2步PermissionsStep仅 macOS下一步是第3步
nextStep = 3;
if (!next) {
// Complete onboarding.
setDesktopOnboardingCompleted();
clearDesktopOnboardingScreen();
break;
}
case 3: {
// 如果是第3步DataModeStep下一步是第4步
nextStep = 4;
// 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('/');
});
break;
}
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;
}
return prev;
}
// 更新 URL query 参数
setSearchParams({ step: nextStep.toString() });
return nextStep;
setSearchParams({ screen: next });
return next;
});
}, [isMac, setSearchParams]);
const goToPreviousStep = useCallback(() => {
setCurrentStep((prev) => {
if (prev <= 1) return 1;
let prevStep: number;
// 如果当前是第3步DataModeStep上一步根据平台决定
if (prev === 3) {
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;
setCurrentScreen((prev) => {
const idx = flow.indexOf(prev);
const prevScreen = flow[Math.max(0, idx - 1)] ?? DesktopOnboardingScreen.Welcome;
setSearchParams({ screen: prevScreen });
return prevScreen;
});
}, [isMac, setSearchParams]);
@@ -172,21 +180,22 @@ const DesktopOnboardingPage = memo(() => {
}
const renderStep = () => {
switch (currentStep) {
case 1: {
switch (currentScreen) {
case DesktopOnboardingScreen.Welcome: {
return <WelcomeStep onNext={goToNextStep} />;
}
case 2: {
// macOS 显示权限页
case DesktopOnboardingScreen.Permissions: {
// macOS-only screen; fallback to DataMode if platform doesn't support.
if (!isMac) {
return <DataModeStep onBack={goToPreviousStep} onNext={goToNextStep} />;
setCurrentScreen(DesktopOnboardingScreen.DataMode);
return null;
}
return <PermissionsStep onBack={goToPreviousStep} onNext={goToNextStep} />;
}
case 3: {
case DesktopOnboardingScreen.DataMode: {
return <DataModeStep onBack={goToPreviousStep} onNext={goToNextStep} />;
}
case 4: {
case DesktopOnboardingScreen.Login: {
return <LoginStep onBack={goToPreviousStep} onNext={goToNextStep} />;
}
default: {

View File

@@ -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);
};

View File

@@ -1,5 +1,7 @@
import { DesktopOnboardingScreen, isDesktopOnboardingScreen } from './types';
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 = () => {
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;
try {
const step = window.localStorage.getItem(DESKTOP_ONBOARDING_STEP_KEY);
if (step) {
const parsedStep = Number.parseInt(step, 10);
if (parsedStep >= 1 && parsedStep <= 4) {
return parsedStep;
}
}
return null;
const screen = window.localStorage.getItem(DESKTOP_ONBOARDING_SCREEN_KEY);
if (!screen) return null;
if (!isDesktopOnboardingScreen(screen)) return null;
return screen;
} catch {
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;
try {
window.localStorage.setItem(DESKTOP_ONBOARDING_STEP_KEY, step.toString());
window.localStorage.setItem(DESKTOP_ONBOARDING_SCREEN_KEY, screen);
return true;
} catch {
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;
try {
window.localStorage.removeItem(DESKTOP_ONBOARDING_STEP_KEY);
window.localStorage.removeItem(DESKTOP_ONBOARDING_SCREEN_KEY);
return true;
} catch {
return false;

View File

@@ -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);
};

View File

@@ -1,15 +1,17 @@
import { ENABLE_BUSINESS_FEATURES } from '@lobechat/business-const';
import { Flexbox } from '@lobehub/ui';
import { useRouter } from '@/libs/next/navigation';
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 { DesktopOnboardingScreen } from '@/app/[variants]/(desktop)/desktop-onboarding/types';
import BusinessPanelContent from '@/business/client/features/User/BusinessPanelContent';
import BrandWatermark from '@/components/BrandWatermark';
import Menu from '@/components/Menu';
import { isDesktop } from '@/const/version';
import { enableBetterAuth, enableNextAuth } from '@/envs/auth';
import { useRouter } from '@/libs/next/navigation';
import { useUserStore } from '@/store/user';
import { authSelectors } from '@/store/user/selectors';
@@ -21,7 +23,7 @@ import { useMenu } from './useMenu';
const PanelContent = memo<{ closePopover: () => void }>(({ closePopover }) => {
const router = useRouter();
const navigate = useNavigate();
const isLoginWithAuth = useUserStore(authSelectors.isLoginWithAuth);
const [openSignIn, signOut] = useUserStore((s) => [s.openLogin, s.logout]);
const { mainItems, logoutItems } = useMenu();
@@ -35,17 +37,16 @@ const PanelContent = memo<{ closePopover: () => void }>(({ closePopover }) => {
if (isDesktop) {
closePopover();
// Desktop: clear OIDC tokens (electron main) + re-enter desktop onboarding at Screen5.
try {
const { remoteServerService } = await import('@/services/electron/remoteServer');
await remoteServerService.clearRemoteServerConfig();
} catch {
// Ignore: even if IPC is unavailable, still proceed to onboarding.
} catch (error) {
console.error(error);
} finally {
clearDesktopOnboardingCompleted();
signOut();
navigateToDesktopOnboarding(DesktopOnboardingScreen.Login);
}
clearDesktopOnboardingCompleted();
signOut();
navigate('/desktop-onboarding#5', { replace: true });
return;
}