mirror of
https://github.com/infiniflow/ragflow.git
synced 2025-12-24 15:36:50 +08:00
Feat: Add box connector (#11845)
### What problem does this PR solve? Feat: Add box connector ### Type of change - [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
1
web/src/assets/svg/data-source/box.svg
Normal file
1
web/src/assets/svg/data-source/box.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="41" height="22" xmlns="http://www.w3.org/2000/svg"><path d="M39.7 19.2c.5.7.4 1.6-.2 2.1-.7.5-1.7.4-2.2-.2l-3.5-4.5-3.4 4.4c-.5.7-1.5.7-2.2.2-.7-.5-.8-1.4-.3-2.1l4-5.2-4-5.2c-.5-.7-.3-1.7.3-2.2.7-.5 1.7-.3 2.2.3l3.4 4.5L37.3 7c.5-.7 1.4-.8 2.2-.3.7.5.7 1.5.2 2.2L35.8 14l3.9 5.2zm-18.2-.6c-2.6 0-4.7-2-4.7-4.6 0-2.5 2.1-4.6 4.7-4.6s4.7 2.1 4.7 4.6c-.1 2.6-2.2 4.6-4.7 4.6zm-13.8 0c-2.6 0-4.7-2-4.7-4.6 0-2.5 2.1-4.6 4.7-4.6s4.7 2.1 4.7 4.6c0 2.6-2.1 4.6-4.7 4.6zM21.5 6.4c-2.9 0-5.5 1.6-6.8 4-1.3-2.4-3.9-4-6.9-4-1.8 0-3.4.6-4.7 1.5V1.5C3.1.7 2.4 0 1.6 0 .7 0 0 .7 0 1.5v12.6c.1 4.2 3.5 7.5 7.7 7.5 3 0 5.6-1.7 6.9-4.1 1.3 2.4 3.9 4.1 6.8 4.1 4.3 0 7.8-3.4 7.8-7.7.1-4.1-3.4-7.5-7.7-7.5z" fill="#0071F7"/></svg>
|
||||
|
After Width: | Height: | Size: 723 B |
@ -0,0 +1,448 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import message from '@/components/ui/message';
|
||||
import {
|
||||
pollBoxWebAuthResult,
|
||||
startBoxWebAuth,
|
||||
} from '@/services/data-source-service';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
export type BoxTokenFieldProps = {
|
||||
value?: string;
|
||||
onChange: (value: any) => void;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
type BoxCredentials = {
|
||||
client_id?: string;
|
||||
client_secret?: string;
|
||||
redirect_uri?: string;
|
||||
authorization_code?: string;
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
};
|
||||
|
||||
type BoxAuthStatus = 'idle' | 'waiting' | 'success' | 'error';
|
||||
|
||||
const parseBoxCredentials = (content?: string): BoxCredentials | null => {
|
||||
if (!content) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
return {
|
||||
client_id: parsed.client_id,
|
||||
client_secret: parsed.client_secret,
|
||||
redirect_uri: parsed.redirect_uri,
|
||||
authorization_code: parsed.authorization_code ?? parsed.code,
|
||||
access_token: parsed.access_token,
|
||||
refresh_token: parsed.refresh_token,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const BoxTokenField = ({ value, onChange }: BoxTokenFieldProps) => {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [clientId, setClientId] = useState('');
|
||||
const [clientSecret, setClientSecret] = useState('');
|
||||
const [redirectUri, setRedirectUri] = useState('');
|
||||
const [submitLoading, setSubmitLoading] = useState(false);
|
||||
const [webFlowId, setWebFlowId] = useState<string | null>(null);
|
||||
const webFlowIdRef = useRef<string | null>(null);
|
||||
const webPollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [webStatus, setWebStatus] = useState<BoxAuthStatus>('idle');
|
||||
const [webStatusMessage, setWebStatusMessage] = useState('');
|
||||
|
||||
const parsed = useMemo(() => parseBoxCredentials(value), [value]);
|
||||
const parsedRedirectUri = useMemo(() => parsed?.redirect_uri ?? '', [parsed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) {
|
||||
setClientId(parsed?.client_id ?? '');
|
||||
setClientSecret(parsed?.client_secret ?? '');
|
||||
setRedirectUri(parsed?.redirect_uri ?? '');
|
||||
}
|
||||
}, [parsed, dialogOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
webFlowIdRef.current = webFlowId;
|
||||
}, [webFlowId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (webPollTimerRef.current) {
|
||||
clearTimeout(webPollTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hasConfigured = useMemo(
|
||||
() =>
|
||||
Boolean(
|
||||
parsed?.client_id && parsed?.client_secret && parsed?.redirect_uri,
|
||||
),
|
||||
[parsed],
|
||||
);
|
||||
|
||||
const hasAuthorized = useMemo(
|
||||
() =>
|
||||
Boolean(
|
||||
parsed?.access_token ||
|
||||
parsed?.refresh_token ||
|
||||
parsed?.authorization_code,
|
||||
),
|
||||
[parsed],
|
||||
);
|
||||
|
||||
const resetWebStatus = useCallback(() => {
|
||||
setWebStatus('idle');
|
||||
setWebStatusMessage('');
|
||||
}, []);
|
||||
|
||||
const clearWebState = useCallback(() => {
|
||||
if (webPollTimerRef.current) {
|
||||
clearTimeout(webPollTimerRef.current);
|
||||
webPollTimerRef.current = null;
|
||||
}
|
||||
webFlowIdRef.current = null;
|
||||
setWebFlowId(null);
|
||||
}, []);
|
||||
|
||||
const fetchWebResult = useCallback(
|
||||
async (flowId: string) => {
|
||||
try {
|
||||
const { data } = await pollBoxWebAuthResult({ flow_id: flowId });
|
||||
if (data.code === 0 && data.data?.credentials) {
|
||||
const credentials = (data.data.credentials || {}) as Record<
|
||||
string,
|
||||
any
|
||||
>;
|
||||
const { user_id: _userId, code, ...rest } = credentials;
|
||||
|
||||
const finalValue: Record<string, any> = {
|
||||
...rest,
|
||||
// 确保客户端配置字段有值(优先后端返回,其次当前输入)
|
||||
client_id: rest.client_id ?? clientId.trim(),
|
||||
client_secret: rest.client_secret ?? clientSecret.trim(),
|
||||
};
|
||||
|
||||
const redirect =
|
||||
redirectUri.trim() || parsedRedirectUri || rest.redirect_uri;
|
||||
if (redirect) {
|
||||
finalValue.redirect_uri = redirect;
|
||||
}
|
||||
|
||||
if (code) {
|
||||
finalValue.authorization_code = code;
|
||||
}
|
||||
|
||||
// access_token / refresh_token 由后端返回,已在 ...rest 中带上,无需额外 state
|
||||
|
||||
onChange(JSON.stringify(finalValue));
|
||||
message.success('Box authorization completed.');
|
||||
clearWebState();
|
||||
resetWebStatus();
|
||||
setDialogOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.code === 106) {
|
||||
setWebStatus('waiting');
|
||||
setWebStatusMessage(
|
||||
'Authorization confirmed. Finalizing credentials...',
|
||||
);
|
||||
if (webPollTimerRef.current) {
|
||||
clearTimeout(webPollTimerRef.current);
|
||||
}
|
||||
webPollTimerRef.current = setTimeout(
|
||||
() => fetchWebResult(flowId),
|
||||
1500,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage = data.message || 'Authorization failed.';
|
||||
message.error(errorMessage);
|
||||
setWebStatus('error');
|
||||
setWebStatusMessage(errorMessage);
|
||||
clearWebState();
|
||||
} catch (_error) {
|
||||
message.error('Unable to retrieve authorization result.');
|
||||
setWebStatus('error');
|
||||
setWebStatusMessage('Unable to retrieve authorization result.');
|
||||
clearWebState();
|
||||
}
|
||||
},
|
||||
[
|
||||
clearWebState,
|
||||
clientId,
|
||||
clientSecret,
|
||||
parsedRedirectUri,
|
||||
redirectUri,
|
||||
resetWebStatus,
|
||||
onChange,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: MessageEvent) => {
|
||||
const payload = event.data;
|
||||
if (!payload || payload.type !== 'ragflow-box-oauth') {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetFlowId = payload.flowId || webFlowIdRef.current;
|
||||
if (!targetFlowId) return;
|
||||
if (webFlowIdRef.current && webFlowIdRef.current !== targetFlowId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.status === 'success') {
|
||||
setWebStatus('waiting');
|
||||
setWebStatusMessage(
|
||||
'Authorization confirmed. Finalizing credentials...',
|
||||
);
|
||||
fetchWebResult(targetFlowId);
|
||||
} else {
|
||||
const errorMessage = payload.message || 'Authorization failed.';
|
||||
message.error(errorMessage);
|
||||
setWebStatus('error');
|
||||
setWebStatusMessage(errorMessage);
|
||||
clearWebState();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handler);
|
||||
return () => window.removeEventListener('message', handler);
|
||||
}, [clearWebState, fetchWebResult]);
|
||||
|
||||
const handleOpenDialog = useCallback(() => {
|
||||
resetWebStatus();
|
||||
clearWebState();
|
||||
setDialogOpen(true);
|
||||
}, [clearWebState, resetWebStatus]);
|
||||
|
||||
const handleCloseDialog = useCallback(() => {
|
||||
setDialogOpen(false);
|
||||
clearWebState();
|
||||
resetWebStatus();
|
||||
}, [clearWebState, resetWebStatus]);
|
||||
|
||||
const handleManualWebCheck = useCallback(() => {
|
||||
if (!webFlowId) {
|
||||
message.info('Start browser authorization first.');
|
||||
return;
|
||||
}
|
||||
setWebStatus('waiting');
|
||||
setWebStatusMessage('Checking authorization status...');
|
||||
fetchWebResult(webFlowId);
|
||||
}, [fetchWebResult, webFlowId]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!clientId.trim() || !clientSecret.trim() || !redirectUri.trim()) {
|
||||
message.error(
|
||||
'Please fill in Client ID, Client Secret, and Redirect URI.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedClientId = clientId.trim();
|
||||
const trimmedClientSecret = clientSecret.trim();
|
||||
const trimmedRedirectUri = redirectUri.trim();
|
||||
|
||||
const payloadForStorage: BoxCredentials = {
|
||||
client_id: trimmedClientId,
|
||||
client_secret: trimmedClientSecret,
|
||||
redirect_uri: trimmedRedirectUri,
|
||||
};
|
||||
|
||||
setSubmitLoading(true);
|
||||
resetWebStatus();
|
||||
clearWebState();
|
||||
|
||||
try {
|
||||
const { data } = await startBoxWebAuth({
|
||||
client_id: trimmedClientId,
|
||||
client_secret: trimmedClientSecret,
|
||||
redirect_uri: trimmedRedirectUri,
|
||||
});
|
||||
|
||||
if (data.code === 0 && data.data?.authorization_url) {
|
||||
onChange(JSON.stringify(payloadForStorage));
|
||||
|
||||
const popup = window.open(
|
||||
data.data.authorization_url,
|
||||
'ragflow-box-oauth',
|
||||
'width=600,height=720',
|
||||
);
|
||||
if (!popup) {
|
||||
message.error(
|
||||
'Popup was blocked. Please allow popups for this site.',
|
||||
);
|
||||
clearWebState();
|
||||
return;
|
||||
}
|
||||
popup.focus();
|
||||
|
||||
const flowId = data.data.flow_id;
|
||||
setWebFlowId(flowId);
|
||||
webFlowIdRef.current = flowId;
|
||||
setWebStatus('waiting');
|
||||
setWebStatusMessage(
|
||||
'Complete the Box consent in the opened window and return here.',
|
||||
);
|
||||
message.info(
|
||||
'Authorization window opened. Complete the Box consent to continue.',
|
||||
);
|
||||
} else {
|
||||
message.error(data.message || 'Failed to start Box authorization.');
|
||||
}
|
||||
} catch (_error) {
|
||||
message.error('Failed to start Box authorization.');
|
||||
} finally {
|
||||
setSubmitLoading(false);
|
||||
}
|
||||
}, [
|
||||
clearWebState,
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri,
|
||||
resetWebStatus,
|
||||
onChange,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{(hasConfigured || hasAuthorized) && (
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-md border border-dashed border-muted-foreground/40 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{hasAuthorized ? (
|
||||
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-emerald-700">
|
||||
Authorized
|
||||
</span>
|
||||
) : null}
|
||||
{hasConfigured ? (
|
||||
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-blue-700">
|
||||
Configured
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="m-0">
|
||||
{hasAuthorized
|
||||
? 'Box OAuth credentials are authorized and ready to use.'
|
||||
: 'Box OAuth client information has been stored. Run the browser authorization to finalize the setup.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button variant="outline" onClick={handleOpenDialog}>
|
||||
{hasConfigured ? 'Edit Box credentials' : 'Configure Box credentials'}
|
||||
</Button>
|
||||
|
||||
<Dialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={(open) =>
|
||||
!open ? handleCloseDialog() : setDialogOpen(true)
|
||||
}
|
||||
>
|
||||
<DialogContent
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Configure Box OAuth credentials</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter your Box application's Client ID, Client Secret, and
|
||||
Redirect URI. These values will be stored in the form field and
|
||||
can be used later to start the OAuth flow.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Client ID</label>
|
||||
<Input
|
||||
value={clientId}
|
||||
placeholder="Enter Box Client ID"
|
||||
onChange={(e) => setClientId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Client Secret</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={clientSecret}
|
||||
placeholder="Enter Box Client Secret"
|
||||
onChange={(e) => setClientSecret(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Redirect URI</label>
|
||||
<Input
|
||||
value={redirectUri}
|
||||
placeholder="https://example.com/box/oauth/callback"
|
||||
onChange={(e) => setRedirectUri(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{webStatus !== 'idle' && (
|
||||
<div className="rounded-md border border-dashed border-muted-foreground/40 bg-muted/10 px-4 py-4 text-sm text-muted-foreground">
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
Browser authorization
|
||||
</div>
|
||||
<p
|
||||
className={`mt-2 text-xs ${
|
||||
webStatus === 'error'
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{webStatusMessage}
|
||||
</p>
|
||||
{webStatus === 'waiting' && webFlowId ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleManualWebCheck}
|
||||
>
|
||||
Refresh status
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="pt-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleCloseDialog}
|
||||
disabled={submitLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitLoading}>
|
||||
{submitLoading && (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
)}
|
||||
Submit & Authorize
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoxTokenField;
|
||||
@ -1,10 +1,11 @@
|
||||
import { FormFieldType } from '@/components/dynamic-form';
|
||||
import SvgIcon from '@/components/svg-icon';
|
||||
import { t } from 'i18next';
|
||||
import { ControllerRenderProps } from 'react-hook-form';
|
||||
import BoxTokenField from './component/box-token-field';
|
||||
import { ConfluenceIndexingModeField } from './component/confluence-token-field';
|
||||
import GmailTokenField from './component/gmail-token-field';
|
||||
import GoogleDriveTokenField from './component/google-drive-token-field';
|
||||
|
||||
export enum DataSourceKey {
|
||||
CONFLUENCE = 'confluence',
|
||||
S3 = 's3',
|
||||
@ -15,6 +16,7 @@ export enum DataSourceKey {
|
||||
GMAIL = 'gmail',
|
||||
JIRA = 'jira',
|
||||
WEBDAV = 'webdav',
|
||||
BOX = 'box',
|
||||
DROPBOX = 'dropbox',
|
||||
// SHAREPOINT = 'sharepoint',
|
||||
// SLACK = 'slack',
|
||||
@ -72,6 +74,11 @@ export const DataSourceInfo = {
|
||||
description: t(`setting.${DataSourceKey.DROPBOX}Description`),
|
||||
icon: <SvgIcon name={'data-source/dropbox'} width={38} />,
|
||||
},
|
||||
[DataSourceKey.BOX]: {
|
||||
name: 'Box',
|
||||
description: t(`setting.${DataSourceKey.BOX}Description`),
|
||||
icon: <SvgIcon name={'data-source/box'} width={38} />,
|
||||
},
|
||||
};
|
||||
|
||||
export const DataSourceFormBaseFields = [
|
||||
@ -234,11 +241,11 @@ export const DataSourceFormFields = {
|
||||
{
|
||||
label: 'Index Method',
|
||||
name: 'config.index_mode',
|
||||
type: FormFieldType.Text, // keep as text so RHF registers it
|
||||
type: FormFieldType.Text,
|
||||
required: false,
|
||||
horizontal: true,
|
||||
labelClassName: 'self-start pt-4',
|
||||
render: (fieldProps: ControllerRenderProps) => (
|
||||
render: (fieldProps: any) => (
|
||||
<ConfluenceIndexingModeField {...fieldProps} />
|
||||
),
|
||||
},
|
||||
@ -551,6 +558,28 @@ export const DataSourceFormFields = {
|
||||
placeholder: 'Defaults to 2',
|
||||
},
|
||||
],
|
||||
[DataSourceKey.BOX]: [
|
||||
{
|
||||
label: 'Box OAuth JSON',
|
||||
name: 'config.credentials.box_tokens',
|
||||
type: FormFieldType.Textarea,
|
||||
required: true,
|
||||
render: (fieldProps: any) => (
|
||||
<BoxTokenField
|
||||
value={fieldProps.value}
|
||||
onChange={fieldProps.onChange}
|
||||
placeholder='{ "client_id": "...", "client_secret": "...", "redirect_uri": "..." }'
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Folder ID',
|
||||
name: 'config.folder_id',
|
||||
type: FormFieldType.Text,
|
||||
required: false,
|
||||
placeholder: 'Defaults root',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const DataSourceFormDefaultValues = {
|
||||
@ -687,4 +716,15 @@ export const DataSourceFormDefaultValues = {
|
||||
},
|
||||
},
|
||||
},
|
||||
[DataSourceKey.BOX]: {
|
||||
name: '',
|
||||
source: DataSourceKey.BOX,
|
||||
config: {
|
||||
name: '',
|
||||
folder_id: '0',
|
||||
credentials: {
|
||||
box_tokens: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@ -47,4 +47,13 @@ export const startGmailWebAuth = (payload: { credentials: string }) =>
|
||||
export const pollGmailWebAuthResult = (payload: { flow_id: string }) =>
|
||||
request.post(api.googleWebAuthResult('gmail'), { data: payload });
|
||||
|
||||
export const startBoxWebAuth = (payload: {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
redirect_uri?: string;
|
||||
}) => request.post(api.boxWebAuthStart(), { data: payload });
|
||||
|
||||
export const pollBoxWebAuthResult = (payload: { flow_id: string }) =>
|
||||
request.post(api.boxWebAuthResult(), { data: payload });
|
||||
|
||||
export default dataSourceService;
|
||||
|
||||
@ -46,6 +46,8 @@ export default {
|
||||
`${api_host}/connector/google/oauth/web/start?type=${type}`,
|
||||
googleWebAuthResult: (type: 'google-drive' | 'gmail') =>
|
||||
`${api_host}/connector/google/oauth/web/result?type=${type}`,
|
||||
boxWebAuthStart: () => `${api_host}/connector/box/oauth/web/start`,
|
||||
boxWebAuthResult: () => `${api_host}/connector/box/oauth/web/result`,
|
||||
|
||||
// plugin
|
||||
llm_tools: `${api_host}/plugin/llm_tools`,
|
||||
|
||||
Reference in New Issue
Block a user