Feat: optimize gmail/google-drive (#13230)

### What problem does this PR solve?

Feat: optimize gmail/google-drive

Now:
<img width="700" alt="image"
src="https://github.com/user-attachments/assets/0c4b6044-7209-4c4f-ac0c-32070b79daf7"
/>
<img width="700" alt="image"
src="https://github.com/user-attachments/assets/406f93d8-9b0f-4f5a-b8bb-3936990f558c"
/>


### Type of change

- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
Magicbook1108
2026-02-26 19:19:40 +08:00
committed by GitHub
parent 22c4d72891
commit c03c537bf8
4 changed files with 176 additions and 18 deletions

View File

@ -193,20 +193,25 @@ async def start_google_web_oauth():
if source not in ("google-drive", "gmail"):
return get_json_result(code=RetCode.ARGUMENT_ERROR, message="Invalid Google OAuth type.")
req = await get_request_json()
if source == "gmail":
redirect_uri = GMAIL_WEB_OAUTH_REDIRECT_URI
default_redirect_uri = GMAIL_WEB_OAUTH_REDIRECT_URI
scopes = GOOGLE_SCOPES[DocumentSource.GMAIL]
else:
redirect_uri = GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI
default_redirect_uri = GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI
scopes = GOOGLE_SCOPES[DocumentSource.GOOGLE_DRIVE]
redirect_uri = req.get("redirect_uri", default_redirect_uri)
if isinstance(redirect_uri, str):
redirect_uri = redirect_uri.strip()
if not redirect_uri:
return get_json_result(
code=RetCode.SERVER_ERROR,
message="Google OAuth redirect URI is not configured on the server.",
)
req = await get_request_json()
raw_credentials = req.get("credentials", "")
try:
@ -246,6 +251,7 @@ async def start_google_web_oauth():
cache_payload = {
"user_id": current_user.id,
"client_config": client_config,
"redirect_uri": redirect_uri,
"created_at": int(time.time()),
}
REDIS_CONN.set_obj(_web_state_cache_key(flow_id, source), cache_payload, WEB_FLOW_TTL_SECS)
@ -276,6 +282,7 @@ async def google_gmail_web_oauth_callback():
state_obj = json.loads(state_cache)
client_config = state_obj.get("client_config")
redirect_uri = state_obj.get("redirect_uri", GMAIL_WEB_OAUTH_REDIRECT_URI)
if not client_config:
REDIS_CONN.delete(_web_state_cache_key(state_id, source))
return await _render_web_oauth_popup(state_id, False, "Authorization session was invalid. Please retry.", source)
@ -291,7 +298,7 @@ async def google_gmail_web_oauth_callback():
try:
# TODO(google-oauth): branch scopes/redirect_uri based on source_type (drive vs gmail)
flow = Flow.from_client_config(client_config, scopes=GOOGLE_SCOPES[DocumentSource.GMAIL])
flow.redirect_uri = GMAIL_WEB_OAUTH_REDIRECT_URI
flow.redirect_uri = redirect_uri
flow.fetch_token(code=code)
except Exception as exc: # pragma: no cover - defensive
logging.exception("Failed to exchange Google OAuth code: %s", exc)
@ -326,6 +333,7 @@ async def google_drive_web_oauth_callback():
state_obj = json.loads(state_cache)
client_config = state_obj.get("client_config")
redirect_uri = state_obj.get("redirect_uri", GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI)
if not client_config:
REDIS_CONN.delete(_web_state_cache_key(state_id, source))
return await _render_web_oauth_popup(state_id, False, "Authorization session was invalid. Please retry.", source)
@ -341,7 +349,7 @@ async def google_drive_web_oauth_callback():
try:
# TODO(google-oauth): branch scopes/redirect_uri based on source_type (drive vs gmail)
flow = Flow.from_client_config(client_config, scopes=GOOGLE_SCOPES[DocumentSource.GOOGLE_DRIVE])
flow.redirect_uri = GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI
flow.redirect_uri = redirect_uri
flow.fetch_token(code=code)
except Exception as exc: # pragma: no cover - defensive
logging.exception("Failed to exchange Google OAuth code: %s", exc)
@ -480,4 +488,4 @@ async def poll_box_web_result():
REDIS_CONN.delete(_web_result_cache_key(flow_id, "box"))
return get_json_result(data={"credentials": cache_raw})
return get_json_result(data={"credentials": cache_raw})

View File

@ -10,6 +10,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import message from '@/components/ui/message';
import { FileMimeType } from '@/constants/common';
import {
@ -49,6 +50,51 @@ const describeCredentials = (content?: string) => {
}
};
const parseJsonObject = (content?: string): Record<string, any> | null => {
if (!content) return null;
try {
const parsed = JSON.parse(content);
return typeof parsed === 'object' && parsed !== null ? parsed : null;
} catch {
return null;
}
};
const extractRedirectUri = (content?: string): string => {
const parsed = parseJsonObject(content);
if (!parsed) return '';
if (typeof parsed.redirect_uri === 'string' && parsed.redirect_uri.trim()) {
return parsed.redirect_uri.trim();
}
const redirectUris =
parsed.web?.redirect_uris ?? parsed.installed?.redirect_uris;
if (Array.isArray(redirectUris)) {
const firstValidRedirect = redirectUris.find(
(item) => typeof item === 'string' && item.trim(),
);
if (firstValidRedirect) {
return firstValidRedirect.trim();
}
}
return '';
};
const withRedirectUri = (credentials: string, redirectUri: string): string => {
const trimmedRedirectUri = redirectUri.trim();
if (!trimmedRedirectUri) return credentials;
const parsed = parseJsonObject(credentials);
if (!parsed) return credentials;
return JSON.stringify({
...parsed,
redirect_uri: trimmedRedirectUri,
});
};
const GmailTokenField = ({
value,
onChange,
@ -56,6 +102,7 @@ const GmailTokenField = ({
}: GmailTokenFieldProps) => {
const [files, setFiles] = useState<File[]>([]);
const [pendingCredentials, setPendingCredentials] = useState<string>('');
const [redirectUri, setRedirectUri] = useState('');
const [dialogOpen, setDialogOpen] = useState(false);
const [webAuthLoading, setWebAuthLoading] = useState(false);
const [webFlowId, setWebFlowId] = useState<string | null>(null);
@ -89,6 +136,12 @@ const GmailTokenField = ({
webFlowIdRef.current = webFlowId;
}, [webFlowId]);
useEffect(() => {
if (!dialogOpen) {
setRedirectUri(extractRedirectUri(value));
}
}, [dialogOpen, value]);
const credentialSummary = useMemo(() => describeCredentials(value), [value]);
const hasVerifiedTokens = useMemo(
() => Boolean(value && credentialHasRefreshToken(value)),
@ -118,7 +171,11 @@ const GmailTokenField = ({
flow_id: flowId,
});
if (data.code === 0 && data.data?.credentials) {
onChange(data.data.credentials);
const rawCredentials =
typeof data.data.credentials === 'string'
? data.data.credentials
: JSON.stringify(data.data.credentials);
onChange(withRedirectUri(rawCredentials, redirectUri));
setPendingCredentials('');
message.success('Gmail credentials verified.');
resetDialog(false);
@ -143,7 +200,7 @@ const GmailTokenField = ({
clearWebState();
}
},
[clearWebState, onChange, resetDialog],
[clearWebState, onChange, redirectUri, resetDialog],
);
useEffect(() => {
@ -198,8 +255,10 @@ const GmailTokenField = ({
}
setFiles([file]);
clearWebState();
const extractedRedirectUri = extractRedirectUri(text);
setRedirectUri(extractedRedirectUri);
if (credentialHasRefreshToken(text)) {
onChange(text);
onChange(withRedirectUri(text, extractedRedirectUri));
setPendingCredentials('');
message.success('Gmail OAuth credentials uploaded.');
return;
@ -223,11 +282,17 @@ const GmailTokenField = ({
message.error('No Google credential file detected.');
return;
}
if (!redirectUri.trim()) {
message.error('Please fill in Redirect URI.');
return;
}
const trimmedRedirectUri = redirectUri.trim();
setWebAuthLoading(true);
clearWebState();
try {
const { data } = await startGmailWebAuth({
credentials: pendingCredentials,
redirect_uri: trimmedRedirectUri,
});
if (data.code === 0 && data.data?.authorization_url) {
const flowId = data.data.flow_id;
@ -255,7 +320,7 @@ const GmailTokenField = ({
} finally {
setWebAuthLoading(false);
}
}, [clearWebState, pendingCredentials]);
}, [clearWebState, pendingCredentials, redirectUri]);
const handleManualWebCheck = useCallback(() => {
if (!webFlowId) {
@ -334,6 +399,14 @@ const GmailTokenField = ({
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-medium">Redirect URI</label>
<Input
value={redirectUri}
placeholder="https://example.com/gmail/oauth/callback"
onChange={(e) => setRedirectUri(e.target.value)}
/>
</div>
<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

View File

@ -8,6 +8,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import message from '@/components/ui/message';
import { FileMimeType } from '@/constants/common';
import {
@ -47,12 +48,58 @@ const describeCredentials = (content?: string) => {
}
};
const parseJsonObject = (content?: string): Record<string, any> | null => {
if (!content) return null;
try {
const parsed = JSON.parse(content);
return typeof parsed === 'object' && parsed !== null ? parsed : null;
} catch {
return null;
}
};
const extractRedirectUri = (content?: string): string => {
const parsed = parseJsonObject(content);
if (!parsed) return '';
if (typeof parsed.redirect_uri === 'string' && parsed.redirect_uri.trim()) {
return parsed.redirect_uri.trim();
}
const redirectUris =
parsed.web?.redirect_uris ?? parsed.installed?.redirect_uris;
if (Array.isArray(redirectUris)) {
const firstValidRedirect = redirectUris.find(
(item) => typeof item === 'string' && item.trim(),
);
if (firstValidRedirect) {
return firstValidRedirect.trim();
}
}
return '';
};
const withRedirectUri = (credentials: string, redirectUri: string): string => {
const trimmedRedirectUri = redirectUri.trim();
if (!trimmedRedirectUri) return credentials;
const parsed = parseJsonObject(credentials);
if (!parsed) return credentials;
return JSON.stringify({
...parsed,
redirect_uri: trimmedRedirectUri,
});
};
const GoogleDriveTokenField = ({
value,
onChange,
}: GoogleDriveTokenFieldProps) => {
const [files, setFiles] = useState<File[]>([]);
const [pendingCredentials, setPendingCredentials] = useState<string>('');
const [redirectUri, setRedirectUri] = useState('');
const [dialogOpen, setDialogOpen] = useState(false);
const [webAuthLoading, setWebAuthLoading] = useState(false);
const [webFlowId, setWebFlowId] = useState<string | null>(null);
@ -86,6 +133,12 @@ const GoogleDriveTokenField = ({
webFlowIdRef.current = webFlowId;
}, [webFlowId]);
useEffect(() => {
if (!dialogOpen) {
setRedirectUri(extractRedirectUri(value));
}
}, [dialogOpen, value]);
const credentialSummary = useMemo(() => describeCredentials(value), [value]);
const hasVerifiedTokens = useMemo(
() => Boolean(value && credentialHasRefreshToken(value)),
@ -115,7 +168,11 @@ const GoogleDriveTokenField = ({
flow_id: flowId,
});
if (data.code === 0 && data.data?.credentials) {
onChange(data.data.credentials);
const rawCredentials =
typeof data.data.credentials === 'string'
? data.data.credentials
: JSON.stringify(data.data.credentials);
onChange(withRedirectUri(rawCredentials, redirectUri));
setPendingCredentials('');
message.success('Google Drive credentials verified.');
resetDialog(false);
@ -140,7 +197,7 @@ const GoogleDriveTokenField = ({
clearWebState();
}
},
[clearWebState, onChange, resetDialog],
[clearWebState, onChange, redirectUri, resetDialog],
);
useEffect(() => {
@ -195,8 +252,10 @@ const GoogleDriveTokenField = ({
}
setFiles([file]);
clearWebState();
const extractedRedirectUri = extractRedirectUri(text);
setRedirectUri(extractedRedirectUri);
if (credentialHasRefreshToken(text)) {
onChange(text);
onChange(withRedirectUri(text, extractedRedirectUri));
setPendingCredentials('');
message.success('OAuth credentials uploaded.');
return;
@ -220,11 +279,17 @@ const GoogleDriveTokenField = ({
message.error('No Google credential file detected.');
return;
}
if (!redirectUri.trim()) {
message.error('Please fill in Redirect URI.');
return;
}
const trimmedRedirectUri = redirectUri.trim();
setWebAuthLoading(true);
clearWebState();
try {
const { data } = await startGoogleDriveWebAuth({
credentials: pendingCredentials,
redirect_uri: trimmedRedirectUri,
});
if (data.code === 0 && data.data?.authorization_url) {
const flowId = data.data.flow_id;
@ -252,7 +317,7 @@ const GoogleDriveTokenField = ({
} finally {
setWebAuthLoading(false);
}
}, [clearWebState, pendingCredentials]);
}, [clearWebState, pendingCredentials, redirectUri]);
const handleManualWebCheck = useCallback(() => {
if (!webFlowId) {
@ -330,6 +395,14 @@ const GoogleDriveTokenField = ({
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-medium">Redirect URI</label>
<Input
value={redirectUri}
placeholder="https://example.com/google-drive/oauth/callback"
onChange={(e) => setRedirectUri(e.target.value)}
/>
</div>
<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

View File

@ -33,16 +33,20 @@ 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.googleWebAuthStart('google-drive'), { data: payload });
export const startGoogleDriveWebAuth = (payload: {
credentials: string;
redirect_uri?: string;
}) => request.post(api.googleWebAuthStart('google-drive'), { data: payload });
export const pollGoogleDriveWebAuthResult = (payload: { flow_id: string }) =>
request.post(api.googleWebAuthResult('google-drive'), { data: payload });
// Gmail web auth follows the same pattern as Google Drive, but uses
// Gmail-specific endpoints and is consumed by the GmailTokenField UI.
export const startGmailWebAuth = (payload: { credentials: string }) =>
request.post(api.googleWebAuthStart('gmail'), { data: payload });
export const startGmailWebAuth = (payload: {
credentials: string;
redirect_uri?: string;
}) => request.post(api.googleWebAuthStart('gmail'), { data: payload });
export const pollGmailWebAuthResult = (payload: { flow_id: string }) =>
request.post(api.googleWebAuthResult('gmail'), { data: payload });