From 05a08734baf2ffdbfc8b99737a83ba6eccbcaa3b Mon Sep 17 00:00:00 2001 From: Innei Date: Tue, 20 Jan 2026 01:04:03 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(desktop):=20resolve=20onboar?= =?UTF-8?q?ding=20navigation=20issues=20after=20logout=20(#11628)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🐛 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 * 🐛 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 --------- Signed-off-by: Innei --- scripts/generate-oidc-jwk.mjs | 4 +- .../desktop-onboarding/features/LoginStep.tsx | 67 +++++- .../(desktop)/desktop-onboarding/index.tsx | 191 +++++++++--------- .../desktop-onboarding/navigation.ts | 11 + .../(desktop)/desktop-onboarding/storage.ts | 32 ++- .../(desktop)/desktop-onboarding/types.ts | 11 + src/features/User/UserPanel/PanelContent.tsx | 21 +- 7 files changed, 207 insertions(+), 130 deletions(-) create mode 100644 src/app/[variants]/(desktop)/desktop-onboarding/navigation.ts create mode 100644 src/app/[variants]/(desktop)/desktop-onboarding/types.ts diff --git a/scripts/generate-oidc-jwk.mjs b/scripts/generate-oidc-jwk.mjs index 5bd83e9f9f..e8890cf274 100755 --- a/scripts/generate-oidc-jwk.mjs +++ b/scripts/generate-oidc-jwk.mjs @@ -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); diff --git a/src/app/[variants]/(desktop)/desktop-onboarding/features/LoginStep.tsx b/src/app/[variants]/(desktop)/desktop-onboarding/features/LoginStep.tsx index 0481b9e18d..a29cc1bad2 100644 --- a/src/app/[variants]/(desktop)/desktop-onboarding/features/LoginStep.tsx +++ b/src/app/[variants]/(desktop)/desktop-onboarding/features/LoginStep.tsx @@ -199,10 +199,13 @@ const LoginStep = memo(({ 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(({ onBack, onNext }) => { if (cloudLoginStatus === 'error') { return ( - <> + @@ -254,7 +256,7 @@ const LoginStep = memo(({ onBack, onNext }) => { > {t('screen5.actions.tryAgain')} - + ); } @@ -340,10 +342,9 @@ const LoginStep = memo(({ onBack, onNext }) => { if (selfhostLoginStatus === 'error') { return ( - + @@ -354,6 +355,47 @@ const LoginStep = memo(({ 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 ( + + + + {phaseText} + + + {remainingSeconds !== null ? ( + + {t('screen5.auth.remaining', { + time: remainingSeconds, + })} + + ) : ( +
+ )} + + + + ); + } + return ( {t(loginMethodMetas.selfhost.descriptionKey)} @@ -365,6 +407,11 @@ const LoginStep = memo(({ 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={} size={'large'} @@ -372,16 +419,14 @@ const LoginStep = memo(({ onBack, onNext }) => { value={endpoint} /> ); diff --git a/src/app/[variants]/(desktop)/desktop-onboarding/index.tsx b/src/app/[variants]/(desktop)/desktop-onboarding/index.tsx index 1e98099e59..28f09a9778 100644 --- a/src/app/[variants]/(desktop)/desktop-onboarding/index.tsx +++ b/src/app/[variants]/(desktop)/desktop-onboarding/index.tsx @@ -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.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 ; } - case 2: { - // 仅 macOS 显示权限页 + case DesktopOnboardingScreen.Permissions: { + // macOS-only screen; fallback to DataMode if platform doesn't support. if (!isMac) { - return ; + setCurrentScreen(DesktopOnboardingScreen.DataMode); + return null; } return ; } - case 3: { + case DesktopOnboardingScreen.DataMode: { return ; } - case 4: { + case DesktopOnboardingScreen.Login: { return ; } default: { diff --git a/src/app/[variants]/(desktop)/desktop-onboarding/navigation.ts b/src/app/[variants]/(desktop)/desktop-onboarding/navigation.ts new file mode 100644 index 0000000000..9a5212e74e --- /dev/null +++ b/src/app/[variants]/(desktop)/desktop-onboarding/navigation.ts @@ -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); +}; diff --git a/src/app/[variants]/(desktop)/desktop-onboarding/storage.ts b/src/app/[variants]/(desktop)/desktop-onboarding/storage.ts index 91d6a19b9d..327a6a4c77 100644 --- a/src/app/[variants]/(desktop)/desktop-onboarding/storage.ts +++ b/src/app/[variants]/(desktop)/desktop-onboarding/storage.ts @@ -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; diff --git a/src/app/[variants]/(desktop)/desktop-onboarding/types.ts b/src/app/[variants]/(desktop)/desktop-onboarding/types.ts new file mode 100644 index 0000000000..a24601331f --- /dev/null +++ b/src/app/[variants]/(desktop)/desktop-onboarding/types.ts @@ -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); +}; diff --git a/src/features/User/UserPanel/PanelContent.tsx b/src/features/User/UserPanel/PanelContent.tsx index 107264615e..672ee326b9 100644 --- a/src/features/User/UserPanel/PanelContent.tsx +++ b/src/features/User/UserPanel/PanelContent.tsx @@ -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; }