mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
feat: Add desktop OIDC authentication and onboarding improvements (#11569)
This commit is contained in:
@@ -1,4 +1,9 @@
|
||||
import { DataSyncConfig, MarketAuthorizationParams } from '@lobechat/electron-client-ipc';
|
||||
import {
|
||||
AuthorizationPhase,
|
||||
AuthorizationProgress,
|
||||
DataSyncConfig,
|
||||
MarketAuthorizationParams,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { BrowserWindow, shell } from 'electron';
|
||||
import crypto from 'node:crypto';
|
||||
import querystring from 'node:querystring';
|
||||
@@ -9,9 +14,11 @@ import { createLogger } from '@/utils/logger';
|
||||
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
// Create logger
|
||||
const logger = createLogger('controllers:AuthCtr');
|
||||
|
||||
const MAX_POLL_TIME = 2 * 60 * 1000; // 2 minutes (reduced from 5 minutes for better UX)
|
||||
const POLL_INTERVAL = 3000; // 3 seconds
|
||||
|
||||
/**
|
||||
* Authentication Controller
|
||||
* Implements OAuth authorization flow using intermediate page + polling mechanism
|
||||
@@ -107,6 +114,12 @@ export default class AuthCtr extends ControllerModule {
|
||||
await shell.openExternal(authUrl.toString());
|
||||
logger.debug('Opening authorization URL in default browser');
|
||||
|
||||
this.broadcastAuthorizationProgress({
|
||||
elapsed: 0,
|
||||
maxPollTime: MAX_POLL_TIME,
|
||||
phase: 'browser_opened',
|
||||
});
|
||||
|
||||
// Start polling for credentials
|
||||
this.startPolling();
|
||||
|
||||
@@ -117,6 +130,24 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel current authorization process
|
||||
*/
|
||||
@IpcMethod()
|
||||
async cancelAuthorization() {
|
||||
if (this.authRequestState) {
|
||||
logger.info('User cancelled authorization');
|
||||
this.clearAuthorizationState();
|
||||
this.broadcastAuthorizationProgress({
|
||||
elapsed: 0,
|
||||
maxPollTime: MAX_POLL_TIME,
|
||||
phase: 'cancelled',
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
return { error: 'No active authorization', success: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Request Market OAuth authorization (desktop)
|
||||
*/
|
||||
@@ -152,14 +183,29 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
logger.info('Starting credential polling');
|
||||
const pollInterval = 3000; // 3 seconds
|
||||
const maxPollTime = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// Broadcast initial state
|
||||
this.broadcastAuthorizationProgress({
|
||||
elapsed: 0,
|
||||
maxPollTime: MAX_POLL_TIME,
|
||||
phase: 'waiting_for_auth',
|
||||
});
|
||||
|
||||
this.pollingInterval = setInterval(async () => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
// Broadcast progress on every tick
|
||||
this.broadcastAuthorizationProgress({
|
||||
elapsed,
|
||||
maxPollTime: MAX_POLL_TIME,
|
||||
phase: 'waiting_for_auth',
|
||||
});
|
||||
|
||||
try {
|
||||
// Check if polling has timed out
|
||||
if (Date.now() - startTime > maxPollTime) {
|
||||
if (elapsed > MAX_POLL_TIME) {
|
||||
logger.warn('Credential polling timed out');
|
||||
this.clearAuthorizationState();
|
||||
this.broadcastAuthorizationFailed('Authorization timed out');
|
||||
@@ -173,6 +219,13 @@ export default class AuthCtr extends ControllerModule {
|
||||
logger.info('Successfully received credentials from polling');
|
||||
this.stopPolling();
|
||||
|
||||
// Broadcast verifying state
|
||||
this.broadcastAuthorizationProgress({
|
||||
elapsed,
|
||||
maxPollTime: MAX_POLL_TIME,
|
||||
phase: 'verifying',
|
||||
});
|
||||
|
||||
// Validate state parameter
|
||||
if (result.state !== this.authRequestState) {
|
||||
logger.error(
|
||||
@@ -198,7 +251,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
this.clearAuthorizationState();
|
||||
this.broadcastAuthorizationFailed('Polling error: ' + error.message);
|
||||
}
|
||||
}, pollInterval);
|
||||
}, POLL_INTERVAL);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -511,6 +564,21 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast authorization progress event
|
||||
*/
|
||||
private broadcastAuthorizationProgress(progress: AuthorizationProgress) {
|
||||
// Avoid logging too frequently
|
||||
// logger.debug('Broadcasting authorizationProgress event');
|
||||
const allWindows = BrowserWindow.getAllWindows();
|
||||
|
||||
for (const win of allWindows) {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send('authorizationProgress', progress);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast authorization failed event
|
||||
*/
|
||||
@@ -563,7 +631,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
// Hash codeVerifier using SHA-256
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(codeVerifier);
|
||||
const digest = await crypto.subtle.digest('SHA-256', data as unknown as NodeJS.BufferSource);
|
||||
const digest = await crypto.subtle.digest('SHA-256', data.buffer);
|
||||
|
||||
// Convert hash result to base64url encoding
|
||||
const challenge = Buffer.from(digest)
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"screen4.title": "您希望如何共享数据?",
|
||||
"screen4.title2": "您的选择将帮助我们改进",
|
||||
"screen4.title3": "您可以随时在设置中更改",
|
||||
"screen5.actions.cancel": "取消",
|
||||
"screen5.actions.connectToServer": "连接服务器",
|
||||
"screen5.actions.connecting": "正在连接…",
|
||||
"screen5.actions.signInCloud": "登录 LobeHub Cloud",
|
||||
@@ -65,6 +66,10 @@
|
||||
"screen5.actions.signingIn": "正在登录…",
|
||||
"screen5.actions.signingOut": "正在退出…",
|
||||
"screen5.actions.tryAgain": "重试",
|
||||
"screen5.auth.phase.browserOpened": "浏览器已打开,请前往登录…",
|
||||
"screen5.auth.phase.verifying": "正在验证凭证…",
|
||||
"screen5.auth.phase.waitingForAuth": "等待授权完成…",
|
||||
"screen5.auth.remaining": "剩余时间:{{time}}秒",
|
||||
"screen5.badge": "登录",
|
||||
"screen5.description": "登录以在所有设备间同步代理、群组、设置和上下文。",
|
||||
"screen5.errors.desktopOnlyOidc": "OIDC 授权仅在桌面端运行时可用。",
|
||||
|
||||
@@ -22,5 +22,9 @@ export type MainBroadcastParams<T extends MainBroadcastEventKey> = Parameters<
|
||||
MainBroadcastEvents[T]
|
||||
>[0];
|
||||
|
||||
export type { MarketAuthorizationParams } from './remoteServer';
|
||||
export type {
|
||||
AuthorizationPhase,
|
||||
AuthorizationProgress,
|
||||
MarketAuthorizationParams,
|
||||
} from './remoteServer';
|
||||
export type { OpenSettingsWindowOptions } from './windows';
|
||||
|
||||
@@ -2,11 +2,34 @@ export interface MarketAuthorizationParams {
|
||||
authUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization phase for progress tracking
|
||||
*/
|
||||
export type AuthorizationPhase =
|
||||
| 'browser_opened' // Browser has been opened for authorization
|
||||
| 'waiting_for_auth' // Waiting for user to complete browser login
|
||||
| 'verifying' // Received credentials, verifying with server
|
||||
| 'cancelled'; // Authorization was cancelled by user
|
||||
|
||||
/**
|
||||
* Authorization progress info for UI updates
|
||||
*/
|
||||
export interface AuthorizationProgress {
|
||||
/** Elapsed time in milliseconds since authorization started */
|
||||
elapsed: number;
|
||||
/** Maximum polling time in milliseconds */
|
||||
maxPollTime: number;
|
||||
/** Current authorization phase */
|
||||
phase: AuthorizationPhase;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从主进程广播的远程服务器相关事件
|
||||
*/
|
||||
export interface RemoteServerBroadcastEvents {
|
||||
authorizationFailed: (params: { error: string }) => void;
|
||||
/** Broadcast authorization progress for UI updates */
|
||||
authorizationProgress: (params: AuthorizationProgress) => void;
|
||||
authorizationRequired: (params: void) => void;
|
||||
authorizationSuccessful: (params: void) => void;
|
||||
remoteServerConfigUpdated: (params: void) => void;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useWatchBroadcast } from '@lobechat/electron-client-ipc';
|
||||
import { AuthorizationProgress, useWatchBroadcast } from '@lobechat/electron-client-ipc';
|
||||
import { Alert, Button, Center, Flexbox, Icon, Input, Text } from '@lobehub/ui';
|
||||
import { Divider } from 'antd';
|
||||
import { cssVar } from 'antd-style';
|
||||
@@ -9,6 +9,7 @@ import { memo, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { isDesktop } from '@/const/version';
|
||||
import { remoteServerService } from '@/services/electron/remoteServer';
|
||||
import { useElectronStore } from '@/store/electron';
|
||||
import { setDesktopAutoOidcFirstOpenHandled } from '@/utils/electron/autoOidc';
|
||||
|
||||
@@ -44,6 +45,7 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
|
||||
const { t } = useTranslation('desktop-onboarding');
|
||||
const [endpoint, setEndpoint] = useState('');
|
||||
const [cloudLoginStatus, setCloudLoginStatus] = useState<LoginStatus>('idle');
|
||||
const [authProgress, setAuthProgress] = useState<AuthorizationProgress | null>(null);
|
||||
const [selfhostLoginStatus, setSelfhostLoginStatus] = useState<LoginStatus>('idle');
|
||||
const [remoteError, setRemoteError] = useState<string | null>(null);
|
||||
const [isSigningOut, setIsSigningOut] = useState(false);
|
||||
@@ -164,29 +166,53 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
|
||||
useWatchBroadcast('authorizationSuccessful', async () => {
|
||||
setRemoteError(null);
|
||||
clearRemoteServerSyncError();
|
||||
setAuthProgress(null);
|
||||
await refreshServerConfig();
|
||||
});
|
||||
|
||||
useWatchBroadcast('authorizationFailed', ({ error }) => {
|
||||
setRemoteError(error);
|
||||
setAuthProgress(null);
|
||||
if (cloudLoginStatus === 'loading') setCloudLoginStatus('error');
|
||||
if (selfhostLoginStatus === 'loading') setSelfhostLoginStatus('error');
|
||||
});
|
||||
|
||||
useWatchBroadcast('authorizationProgress', (progress) => {
|
||||
setAuthProgress(progress);
|
||||
if (progress.phase === 'cancelled') {
|
||||
setCloudLoginStatus('idle');
|
||||
setAuthProgress(null);
|
||||
}
|
||||
});
|
||||
|
||||
const handleCancelAuth = async () => {
|
||||
await remoteServerService.cancelAuthorization();
|
||||
setCloudLoginStatus('idle');
|
||||
setAuthProgress(null);
|
||||
};
|
||||
|
||||
// 渲染 Cloud 登录内容
|
||||
const renderCloudContent = () => {
|
||||
if (cloudLoginStatus === 'success') {
|
||||
return (
|
||||
<Button
|
||||
block
|
||||
disabled={isSigningOut || isConnectingServer}
|
||||
icon={Cloud}
|
||||
onClick={handleSignOut}
|
||||
size={'large'}
|
||||
type={'default'}
|
||||
>
|
||||
{isSigningOut ? t('screen5.actions.signingOut') : t('screen5.actions.signOut')}
|
||||
</Button>
|
||||
<Flexbox gap={12} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
description={t('authResult.success.desc')}
|
||||
style={{ width: '100%' }}
|
||||
title={t('authResult.success.title')}
|
||||
type={'success'}
|
||||
/>
|
||||
<Button
|
||||
block
|
||||
disabled={isSigningOut || isConnectingServer}
|
||||
icon={Cloud}
|
||||
onClick={handleSignOut}
|
||||
size={'large'}
|
||||
type={'default'}
|
||||
>
|
||||
{isSigningOut ? t('screen5.actions.signingOut') : t('screen5.actions.signOut')}
|
||||
</Button>
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -212,19 +238,43 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (cloudLoginStatus === 'loading') {
|
||||
return (
|
||||
<Flexbox gap={8} style={{ width: '100%' }}>
|
||||
<Button block disabled={true} icon={Cloud} loading={true} size={'large'} type={'primary'}>
|
||||
{authProgress
|
||||
? t(`screen5.auth.phase.${authProgress.phase}`, {
|
||||
defaultValue: t('screen5.actions.signingIn'),
|
||||
})
|
||||
: t('screen5.actions.signingIn')}
|
||||
</Button>
|
||||
{authProgress && (
|
||||
<Flexbox align={'center'} horizontal justify={'space-between'}>
|
||||
<Text style={{ color: cssVar.colorTextDescription }} type={'secondary'}>
|
||||
{t('screen5.auth.remaining', {
|
||||
time: Math.round((authProgress.maxPollTime - authProgress.elapsed) / 1000),
|
||||
})}
|
||||
</Text>
|
||||
<Button onClick={handleCancelAuth} size={'small'} type={'text'}>
|
||||
{t('screen5.actions.cancel')}
|
||||
</Button>
|
||||
</Flexbox>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
block
|
||||
disabled={cloudLoginStatus === 'loading' || isConnectingServer}
|
||||
disabled={isConnectingServer}
|
||||
icon={Cloud}
|
||||
loading={cloudLoginStatus === 'loading'}
|
||||
loading={false}
|
||||
onClick={handleCloudLogin}
|
||||
size={'large'}
|
||||
type={'primary'}
|
||||
>
|
||||
{cloudLoginStatus === 'loading'
|
||||
? t('screen5.actions.signingIn')
|
||||
: t('screen5.actions.signInCloud')}
|
||||
{t('screen5.actions.signInCloud')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -233,16 +283,24 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
|
||||
const renderSelfhostContent = () => {
|
||||
if (selfhostLoginStatus === 'success') {
|
||||
return (
|
||||
<Button
|
||||
block
|
||||
disabled={isSigningOut || isConnectingServer}
|
||||
icon={Server}
|
||||
onClick={handleSignOut}
|
||||
size={'large'}
|
||||
type={'default'}
|
||||
>
|
||||
{isSigningOut ? t('screen5.actions.signingOut') : t('screen5.actions.signOut')}
|
||||
</Button>
|
||||
<Flexbox gap={12} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
description={t('authResult.success.desc')}
|
||||
style={{ width: '100%' }}
|
||||
title={t('authResult.success.title')}
|
||||
type={'success'}
|
||||
/>
|
||||
<Button
|
||||
block
|
||||
disabled={isSigningOut || isConnectingServer}
|
||||
icon={Server}
|
||||
onClick={handleSignOut}
|
||||
size={'large'}
|
||||
type={'default'}
|
||||
>
|
||||
{isSigningOut ? t('screen5.actions.signingOut') : t('screen5.actions.signOut')}
|
||||
</Button>
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { memo, useEffect } from 'react';
|
||||
|
||||
import { getDesktopOnboardingCompleted } from '@/app/[variants]/(desktop)/desktop-onboarding/storage';
|
||||
import { useElectronStore } from '@/store/electron';
|
||||
import {
|
||||
getDesktopAutoOidcFirstOpenHandled,
|
||||
@@ -28,6 +29,9 @@ const DesktopAutoOidcOnFirstOpen = memo(() => {
|
||||
useEffect(() => {
|
||||
if (!isInitRemoteServerConfig) return;
|
||||
|
||||
// Don't auto-trigger during onboarding flow.
|
||||
if (!getDesktopOnboardingCompleted()) return;
|
||||
|
||||
// If already connected or not in cloud mode, don't auto-trigger.
|
||||
if (dataSyncConfig.active) return;
|
||||
if (dataSyncConfig.storageMode !== 'cloud') return;
|
||||
|
||||
@@ -75,11 +75,16 @@ export default {
|
||||
|
||||
'screen5.actions.connectToServer': 'Connect to Server',
|
||||
'screen5.actions.connecting': 'Connecting...',
|
||||
'screen5.actions.cancel': 'Cancel',
|
||||
'screen5.actions.signInCloud': 'Sign in to LobeHub Cloud',
|
||||
'screen5.actions.signOut': 'Sign out',
|
||||
'screen5.actions.signingIn': 'Signing in...',
|
||||
'screen5.actions.signingOut': 'Signing out...',
|
||||
'screen5.actions.tryAgain': 'Try Again',
|
||||
'screen5.auth.phase.browserOpened': 'Browser opened, please sign in...',
|
||||
'screen5.auth.phase.waitingForAuth': 'Waiting for authorization...',
|
||||
'screen5.auth.phase.verifying': 'Verifying credentials...',
|
||||
'screen5.auth.remaining': 'Remaining: {{time}}s',
|
||||
'screen5.badge': 'Sign in',
|
||||
'screen5.description':
|
||||
'Sign in to sync Agents, Groups, settings, and Context across all devices.',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type DataSyncConfig, type MarketAuthorizationParams } from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { ensureElectronIpc } from '@/utils/electron/ipc';
|
||||
|
||||
class RemoteServerService {
|
||||
@@ -36,6 +37,13 @@ class RemoteServerService {
|
||||
requestMarketAuthorization = async (params: MarketAuthorizationParams) => {
|
||||
return ensureElectronIpc().auth.requestMarketAuthorization(params);
|
||||
};
|
||||
|
||||
/**
|
||||
* Cancel authorization
|
||||
*/
|
||||
cancelAuthorization = async () => {
|
||||
return ensureElectronIpc().auth.cancelAuthorization();
|
||||
};
|
||||
}
|
||||
|
||||
export const remoteServerService = new RemoteServerService();
|
||||
|
||||
Reference in New Issue
Block a user