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:
Magicbook1108
2025-12-12 10:23:40 +08:00
committed by GitHub
parent a6bd765a02
commit 7db9045b74
19 changed files with 1019 additions and 131 deletions

View 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

View File

@ -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&apos;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;

View File

@ -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: '',
},
},
},
};

View File

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

View File

@ -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`,