mirror of
https://github.com/infiniflow/ragflow.git
synced 2025-12-08 20:42:30 +08:00
Feat: Google drive supports web-based credentials (#11173)
### What problem does this PR solve? Google drive supports web-based credentials. <img width="1204" height="612" alt="image" src="https://github.com/user-attachments/assets/70291c63-a2dd-4a80-ae20-807fe034cdbc" /> ### Type of change - [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
@ -1,64 +1,383 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { FileUploader } from '@/components/file-uploader';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import message from '@/components/ui/message';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { FileMimeType } from '@/constants/common';
|
||||
import {
|
||||
pollGoogleDriveWebAuthResult,
|
||||
startGoogleDriveWebAuth,
|
||||
} from '@/services/data-source-service';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
type GoogleDriveTokenFieldProps = {
|
||||
value?: string;
|
||||
onChange: (value: any) => void;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
const credentialHasRefreshToken = (content: string) => {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
return Boolean(parsed?.refresh_token);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const describeCredentials = (content?: string) => {
|
||||
if (!content) return '';
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
if (parsed?.refresh_token) {
|
||||
return 'Uploaded OAuth tokens with a refresh token.';
|
||||
}
|
||||
if (parsed?.installed || parsed?.web) {
|
||||
return 'Client credentials detected. Complete verification to mint long-lived tokens.';
|
||||
}
|
||||
return 'Stored Google credential JSON.';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const GoogleDriveTokenField = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: GoogleDriveTokenFieldProps) => {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [pendingCredentials, setPendingCredentials] = useState<string>('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [webAuthLoading, setWebAuthLoading] = useState(false);
|
||||
const [webFlowId, setWebFlowId] = useState<string | null>(null);
|
||||
const [webStatus, setWebStatus] = useState<
|
||||
'idle' | 'waiting' | 'success' | 'error'
|
||||
>('idle');
|
||||
const [webStatusMessage, setWebStatusMessage] = useState('');
|
||||
const webFlowIdRef = useRef<string | null>(null);
|
||||
const webPollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleValueChange = useMemo(
|
||||
() => (nextFiles: File[]) => {
|
||||
const clearWebState = useCallback(() => {
|
||||
if (webPollTimerRef.current) {
|
||||
clearTimeout(webPollTimerRef.current);
|
||||
webPollTimerRef.current = null;
|
||||
}
|
||||
webFlowIdRef.current = null;
|
||||
setWebFlowId(null);
|
||||
setWebStatus('idle');
|
||||
setWebStatusMessage('');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (webPollTimerRef.current) {
|
||||
clearTimeout(webPollTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
webFlowIdRef.current = webFlowId;
|
||||
}, [webFlowId]);
|
||||
|
||||
const credentialSummary = useMemo(() => describeCredentials(value), [value]);
|
||||
const hasVerifiedTokens = useMemo(
|
||||
() => Boolean(value && credentialHasRefreshToken(value)),
|
||||
[value],
|
||||
);
|
||||
const hasUploadedButUnverified = useMemo(
|
||||
() => Boolean(value && !hasVerifiedTokens),
|
||||
[hasVerifiedTokens, value],
|
||||
);
|
||||
|
||||
const resetDialog = useCallback(
|
||||
(shouldResetState: boolean) => {
|
||||
setDialogOpen(false);
|
||||
clearWebState();
|
||||
if (shouldResetState) {
|
||||
setPendingCredentials('');
|
||||
setFiles([]);
|
||||
}
|
||||
},
|
||||
[clearWebState],
|
||||
);
|
||||
|
||||
const fetchWebResult = useCallback(
|
||||
async (flowId: string) => {
|
||||
try {
|
||||
const { data } = await pollGoogleDriveWebAuthResult({
|
||||
flow_id: flowId,
|
||||
});
|
||||
if (data.code === 0 && data.data?.credentials) {
|
||||
onChange(data.data.credentials);
|
||||
setPendingCredentials('');
|
||||
message.success('Google Drive credentials verified.');
|
||||
resetDialog(false);
|
||||
return;
|
||||
}
|
||||
if (data.code === 106) {
|
||||
setWebStatus('waiting');
|
||||
setWebStatusMessage('Authorization confirmed. Finalizing tokens...');
|
||||
if (webPollTimerRef.current) {
|
||||
clearTimeout(webPollTimerRef.current);
|
||||
}
|
||||
webPollTimerRef.current = setTimeout(
|
||||
() => fetchWebResult(flowId),
|
||||
1500,
|
||||
);
|
||||
return;
|
||||
}
|
||||
message.error(data.message || 'Authorization failed.');
|
||||
clearWebState();
|
||||
} catch (err) {
|
||||
message.error('Unable to retrieve authorization result.');
|
||||
clearWebState();
|
||||
}
|
||||
},
|
||||
[clearWebState, onChange, resetDialog],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: MessageEvent) => {
|
||||
const payload = event.data;
|
||||
if (!payload || payload.type !== 'ragflow-google-drive-oauth') {
|
||||
return;
|
||||
}
|
||||
if (!payload.flowId) {
|
||||
return;
|
||||
}
|
||||
if (webFlowIdRef.current && webFlowIdRef.current !== payload.flowId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.status === 'success') {
|
||||
setWebStatus('waiting');
|
||||
setWebStatusMessage('Authorization confirmed. Finalizing tokens...');
|
||||
fetchWebResult(payload.flowId);
|
||||
} else {
|
||||
message.error(
|
||||
payload.message || 'Authorization window reported an error.',
|
||||
);
|
||||
clearWebState();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handler);
|
||||
return () => window.removeEventListener('message', handler);
|
||||
}, [clearWebState, fetchWebResult]);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(nextFiles: File[]) => {
|
||||
if (!nextFiles.length) {
|
||||
setFiles([]);
|
||||
onChange('');
|
||||
setPendingCredentials('');
|
||||
clearWebState();
|
||||
return;
|
||||
}
|
||||
const file = nextFiles[nextFiles.length - 1];
|
||||
file
|
||||
.text()
|
||||
.then((text) => {
|
||||
JSON.parse(text);
|
||||
onChange(text);
|
||||
try {
|
||||
JSON.parse(text);
|
||||
} catch {
|
||||
message.error('Invalid JSON file.');
|
||||
setFiles([]);
|
||||
clearWebState();
|
||||
return;
|
||||
}
|
||||
setFiles([file]);
|
||||
message.success('JSON uploaded');
|
||||
clearWebState();
|
||||
if (credentialHasRefreshToken(text)) {
|
||||
onChange(text);
|
||||
setPendingCredentials('');
|
||||
message.success('OAuth credentials uploaded.');
|
||||
return;
|
||||
}
|
||||
setPendingCredentials(text);
|
||||
setDialogOpen(true);
|
||||
message.info(
|
||||
'Client configuration uploaded. Verification is required to finish setup.',
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
message.error('Invalid JSON file.');
|
||||
message.error('Unable to read the uploaded file.');
|
||||
setFiles([]);
|
||||
});
|
||||
},
|
||||
[onChange],
|
||||
[clearWebState, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Textarea
|
||||
value={value || ''}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={
|
||||
placeholder ||
|
||||
'{ "token": "...", "refresh_token": "...", "client_id": "...", ... }'
|
||||
const handleStartWebAuthorization = useCallback(async () => {
|
||||
if (!pendingCredentials) {
|
||||
message.error('No Google credential file detected.');
|
||||
return;
|
||||
}
|
||||
setWebAuthLoading(true);
|
||||
clearWebState();
|
||||
try {
|
||||
const { data } = await startGoogleDriveWebAuth({
|
||||
credentials: pendingCredentials,
|
||||
});
|
||||
if (data.code === 0 && data.data?.authorization_url) {
|
||||
const flowId = data.data.flow_id;
|
||||
const popup = window.open(
|
||||
data.data.authorization_url,
|
||||
'ragflow-google-drive-oauth',
|
||||
'width=600,height=720',
|
||||
);
|
||||
if (!popup) {
|
||||
message.error(
|
||||
'Popup was blocked. Please allow popups for this site.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
className="min-h-[120px] max-h-60 overflow-y-auto"
|
||||
/>
|
||||
popup.focus();
|
||||
webFlowIdRef.current = flowId;
|
||||
setWebFlowId(flowId);
|
||||
setWebStatus('waiting');
|
||||
setWebStatusMessage('Complete the Google consent in the popup window.');
|
||||
} else {
|
||||
message.error(data.message || 'Failed to start browser authorization.');
|
||||
}
|
||||
} catch (err) {
|
||||
message.error('Failed to start browser authorization.');
|
||||
} finally {
|
||||
setWebAuthLoading(false);
|
||||
}
|
||||
}, [clearWebState, pendingCredentials]);
|
||||
|
||||
const handleManualWebCheck = useCallback(() => {
|
||||
if (!webFlowId) {
|
||||
message.info('Start browser authorization first.');
|
||||
return;
|
||||
}
|
||||
setWebStatus('waiting');
|
||||
setWebStatusMessage('Checking authorization status...');
|
||||
fetchWebResult(webFlowId);
|
||||
}, [fetchWebResult, webFlowId]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
message.warning(
|
||||
'Verification canceled. Upload the credential again to restart.',
|
||||
);
|
||||
resetDialog(true);
|
||||
}, [resetDialog]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{(credentialSummary ||
|
||||
hasVerifiedTokens ||
|
||||
hasUploadedButUnverified ||
|
||||
pendingCredentials) && (
|
||||
<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">
|
||||
{hasVerifiedTokens ? (
|
||||
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-emerald-700">
|
||||
Verified
|
||||
</span>
|
||||
) : null}
|
||||
{hasUploadedButUnverified ? (
|
||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-amber-700">
|
||||
Needs authorization
|
||||
</span>
|
||||
) : null}
|
||||
{pendingCredentials && !hasVerifiedTokens ? (
|
||||
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-blue-700">
|
||||
Uploaded (pending)
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{credentialSummary ? (
|
||||
<p className="m-0">{credentialSummary}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<FileUploader
|
||||
className="py-4"
|
||||
value={files}
|
||||
onValueChange={handleValueChange}
|
||||
accept={{ '*.json': [FileMimeType.Json] }}
|
||||
maxFileCount={1}
|
||||
description="Upload your Google OAuth JSON file."
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
handleCancel();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Complete Google verification</DialogTitle>
|
||||
<DialogDescription>
|
||||
The uploaded client credentials do not contain a refresh token.
|
||||
Run the verification flow once to mint reusable tokens.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<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">
|
||||
Authorize in browser
|
||||
</div>
|
||||
<p className="mt-2">
|
||||
We will open Google's consent page in a new window. Sign in
|
||||
with the admin account, grant access, and return here. Your
|
||||
credentials will update automatically.
|
||||
</p>
|
||||
{webStatus !== 'idle' && (
|
||||
<p
|
||||
className={`mt-2 text-xs ${
|
||||
webStatus === 'error'
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{webStatusMessage}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={handleStartWebAuthorization}
|
||||
disabled={webAuthLoading}
|
||||
>
|
||||
{webAuthLoading && (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
)}
|
||||
Authorize with Google
|
||||
</Button>
|
||||
{webFlowId ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleManualWebCheck}
|
||||
disabled={webStatus === 'success'}
|
||||
>
|
||||
Refresh status
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="pt-2">
|
||||
<Button variant="ghost" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -270,6 +270,101 @@ export const DataSourceFormFields = {
|
||||
defaultValue: 'uploaded',
|
||||
},
|
||||
],
|
||||
[DataSourceKey.GOOGLE_DRIVE]: [
|
||||
{
|
||||
label: 'Primary Admin Email',
|
||||
name: 'config.credentials.google_primary_admin',
|
||||
type: FormFieldType.Text,
|
||||
required: true,
|
||||
placeholder: 'admin@example.com',
|
||||
tooltip: t('setting.google_drivePrimaryAdminTip'),
|
||||
},
|
||||
{
|
||||
label: 'OAuth Token JSON',
|
||||
name: 'config.credentials.google_tokens',
|
||||
type: FormFieldType.Textarea,
|
||||
required: true,
|
||||
render: (fieldProps) => (
|
||||
<GoogleDriveTokenField
|
||||
value={fieldProps.value}
|
||||
onChange={fieldProps.onChange}
|
||||
placeholder='{ "token": "...", "refresh_token": "...", ... }'
|
||||
/>
|
||||
),
|
||||
tooltip: t('setting.google_driveTokenTip'),
|
||||
},
|
||||
{
|
||||
label: 'My Drive Emails',
|
||||
name: 'config.my_drive_emails',
|
||||
type: FormFieldType.Text,
|
||||
required: true,
|
||||
placeholder: 'user1@example.com,user2@example.com',
|
||||
tooltip: t('setting.google_driveMyDriveEmailsTip'),
|
||||
},
|
||||
{
|
||||
label: 'Shared Folder URLs',
|
||||
name: 'config.shared_folder_urls',
|
||||
type: FormFieldType.Textarea,
|
||||
required: true,
|
||||
placeholder:
|
||||
'https://drive.google.com/drive/folders/XXXXX,https://drive.google.com/drive/folders/YYYYY',
|
||||
tooltip: t('setting.google_driveSharedFoldersTip'),
|
||||
},
|
||||
// The fields below are intentionally disabled for now. Uncomment them when we
|
||||
// reintroduce shared drive controls or advanced impersonation options.
|
||||
// {
|
||||
// label: 'Shared Drive URLs',
|
||||
// name: 'config.shared_drive_urls',
|
||||
// type: FormFieldType.Text,
|
||||
// required: false,
|
||||
// placeholder:
|
||||
// 'Optional: comma-separated shared drive links if you want to include them.',
|
||||
// },
|
||||
// {
|
||||
// label: 'Specific User Emails',
|
||||
// name: 'config.specific_user_emails',
|
||||
// type: FormFieldType.Text,
|
||||
// required: false,
|
||||
// placeholder:
|
||||
// 'Optional: comma-separated list of users to impersonate (overrides defaults).',
|
||||
// },
|
||||
// {
|
||||
// label: 'Include My Drive',
|
||||
// name: 'config.include_my_drives',
|
||||
// type: FormFieldType.Checkbox,
|
||||
// required: false,
|
||||
// defaultValue: true,
|
||||
// },
|
||||
// {
|
||||
// label: 'Include Shared Drives',
|
||||
// name: 'config.include_shared_drives',
|
||||
// type: FormFieldType.Checkbox,
|
||||
// required: false,
|
||||
// defaultValue: false,
|
||||
// },
|
||||
// {
|
||||
// label: 'Include “Shared with me”',
|
||||
// name: 'config.include_files_shared_with_me',
|
||||
// type: FormFieldType.Checkbox,
|
||||
// required: false,
|
||||
// defaultValue: false,
|
||||
// },
|
||||
// {
|
||||
// label: 'Allow Images',
|
||||
// name: 'config.allow_images',
|
||||
// type: FormFieldType.Checkbox,
|
||||
// required: false,
|
||||
// defaultValue: false,
|
||||
// },
|
||||
{
|
||||
label: '',
|
||||
name: 'config.credentials.authentication_method',
|
||||
type: FormFieldType.Text,
|
||||
required: false,
|
||||
hidden: true,
|
||||
defaultValue: 'uploaded',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const DataSourceFormDefaultValues = {
|
||||
|
||||
@ -33,4 +33,10 @@ export const getDataSourceLogs = (id: string, params?: any) =>
|
||||
export const featchDataSourceDetail = (id: string) =>
|
||||
request.get(api.dataSourceDetail(id));
|
||||
|
||||
export const startGoogleDriveWebAuth = (payload: { credentials: string }) =>
|
||||
request.post(api.googleDriveWebAuthStart, { data: payload });
|
||||
|
||||
export const pollGoogleDriveWebAuthResult = (payload: { flow_id: string }) =>
|
||||
request.post(api.googleDriveWebAuthResult, { data: payload });
|
||||
|
||||
export default dataSourceService;
|
||||
|
||||
@ -42,6 +42,8 @@ export default {
|
||||
dataSourceRebuild: (id: string) => `${api_host}/connector/${id}/rebuild`,
|
||||
dataSourceLogs: (id: string) => `${api_host}/connector/${id}/logs`,
|
||||
dataSourceDetail: (id: string) => `${api_host}/connector/${id}`,
|
||||
googleDriveWebAuthStart: `${api_host}/connector/google-drive/oauth/web/start`,
|
||||
googleDriveWebAuthResult: `${api_host}/connector/google-drive/oauth/web/result`,
|
||||
|
||||
// plugin
|
||||
llm_tools: `${api_host}/plugin/llm_tools`,
|
||||
|
||||
Reference in New Issue
Block a user