feat: support OAuth & API key for Qwen/MiniMax providers (#177)
This commit is contained in:
committed by
GitHub
Unverified
parent
e1ae68ce7e
commit
7b16b6af14
@@ -19,6 +19,7 @@ import {
|
||||
XCircle,
|
||||
ExternalLink,
|
||||
BookOpen,
|
||||
Copy,
|
||||
} from 'lucide-react';
|
||||
import { TitleBar } from '@/components/layout/TitleBar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -715,6 +716,84 @@ function ProviderContent({
|
||||
const [providerMenuOpen, setProviderMenuOpen] = useState(false);
|
||||
const providerMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [authMode, setAuthMode] = useState<'oauth' | 'apikey'>('oauth');
|
||||
|
||||
// OAuth Flow State
|
||||
const [oauthFlowing, setOauthFlowing] = useState(false);
|
||||
const [oauthData, setOauthData] = useState<{
|
||||
verificationUri: string;
|
||||
userCode: string;
|
||||
expiresIn: number;
|
||||
} | null>(null);
|
||||
const [oauthError, setOauthError] = useState<string | null>(null);
|
||||
|
||||
// Manage OAuth events
|
||||
useEffect(() => {
|
||||
const handleCode = (data: unknown) => {
|
||||
setOauthData(data as { verificationUri: string; userCode: string; expiresIn: number });
|
||||
setOauthError(null);
|
||||
};
|
||||
|
||||
const handleSuccess = async () => {
|
||||
setOauthFlowing(false);
|
||||
setOauthData(null);
|
||||
setKeyValid(true);
|
||||
|
||||
if (selectedProvider) {
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('provider:setDefault', selectedProvider);
|
||||
} catch (error) {
|
||||
console.error('Failed to set default provider:', error);
|
||||
}
|
||||
}
|
||||
|
||||
onConfiguredChange(true);
|
||||
toast.success(t('provider.valid'));
|
||||
};
|
||||
|
||||
const handleError = (data: unknown) => {
|
||||
setOauthError((data as { message: string }).message);
|
||||
setOauthData(null);
|
||||
};
|
||||
|
||||
window.electron.ipcRenderer.on('oauth:code', handleCode);
|
||||
window.electron.ipcRenderer.on('oauth:success', handleSuccess);
|
||||
window.electron.ipcRenderer.on('oauth:error', handleError);
|
||||
|
||||
return () => {
|
||||
// Clean up manually if the API provides removeListener, though `on` in preloads might not return an unsub.
|
||||
// Easiest is to just let it be, or if they have `off`:
|
||||
if (typeof window.electron.ipcRenderer.off === 'function') {
|
||||
window.electron.ipcRenderer.off('oauth:code', handleCode);
|
||||
window.electron.ipcRenderer.off('oauth:success', handleSuccess);
|
||||
window.electron.ipcRenderer.off('oauth:error', handleError);
|
||||
}
|
||||
};
|
||||
}, [onConfiguredChange, t, selectedProvider]);
|
||||
|
||||
const handleStartOAuth = async () => {
|
||||
if (!selectedProvider) return;
|
||||
setOauthFlowing(true);
|
||||
setOauthData(null);
|
||||
setOauthError(null);
|
||||
|
||||
// Default to global region for MiniMax in setup
|
||||
const region = 'global';
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('provider:requestOAuth', selectedProvider, region);
|
||||
} catch (e) {
|
||||
setOauthError(String(e));
|
||||
setOauthFlowing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelOAuth = async () => {
|
||||
setOauthFlowing(false);
|
||||
setOauthData(null);
|
||||
setOauthError(null);
|
||||
await window.electron.ipcRenderer.invoke('provider:cancelOAuth');
|
||||
};
|
||||
|
||||
// On mount, try to restore previously configured provider
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -819,6 +898,9 @@ function ProviderContent({
|
||||
const showBaseUrlField = selectedProviderData?.showBaseUrl ?? false;
|
||||
const showModelIdField = selectedProviderData?.showModelId ?? false;
|
||||
const requiresKey = selectedProviderData?.requiresApiKey ?? false;
|
||||
const isOAuth = selectedProviderData?.isOAuth ?? false;
|
||||
const supportsApiKey = selectedProviderData?.supportsApiKey ?? false;
|
||||
const useOAuthFlow = isOAuth && (!supportsApiKey || authMode === 'oauth');
|
||||
|
||||
const handleValidateAndSave = async () => {
|
||||
if (!selectedProvider) return;
|
||||
@@ -904,7 +986,8 @@ function ProviderContent({
|
||||
const canSubmit =
|
||||
selectedProvider
|
||||
&& (requiresKey ? apiKey.length > 0 : true)
|
||||
&& (showModelIdField ? modelId.trim().length > 0 : true);
|
||||
&& (showModelIdField ? modelId.trim().length > 0 : true)
|
||||
&& !useOAuthFlow;
|
||||
|
||||
const handleSelectProvider = (providerId: string) => {
|
||||
onSelectProvider(providerId);
|
||||
@@ -913,6 +996,7 @@ function ProviderContent({
|
||||
onApiKeyChange('');
|
||||
setKeyValid(null);
|
||||
setProviderMenuOpen(false);
|
||||
setAuthMode('oauth');
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -1047,8 +1131,32 @@ function ProviderContent({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Auth mode toggle for providers supporting both */}
|
||||
{isOAuth && supportsApiKey && (
|
||||
<div className="flex rounded-lg border overflow-hidden text-sm">
|
||||
<button
|
||||
onClick={() => setAuthMode('oauth')}
|
||||
className={cn(
|
||||
'flex-1 py-2 px-3 transition-colors',
|
||||
authMode === 'oauth' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{t('settings:aiProviders.oauth.loginMode')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAuthMode('apikey')}
|
||||
className={cn(
|
||||
'flex-1 py-2 px-3 transition-colors',
|
||||
authMode === 'apikey' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{t('settings:aiProviders.oauth.apikeyMode')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key field (hidden for ollama) */}
|
||||
{requiresKey && (
|
||||
{(!isOAuth || (supportsApiKey && authMode === 'apikey')) && requiresKey && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apiKey">{t('provider.apiKey')}</Label>
|
||||
<div className="relative">
|
||||
@@ -1076,11 +1184,104 @@ function ProviderContent({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Device OAuth Trigger */}
|
||||
{useOAuthFlow && (
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="rounded-lg bg-blue-500/10 border border-blue-500/20 p-4 text-center">
|
||||
<p className="text-sm text-blue-200 mb-3 block">
|
||||
This provider requires signing in via your browser.
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleStartOAuth}
|
||||
disabled={oauthFlowing}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
{oauthFlowing ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Waiting...</>
|
||||
) : (
|
||||
'Login with Browser'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* OAuth Active State Modal / Inline View */}
|
||||
{oauthFlowing && (
|
||||
<div className="mt-4 p-4 border rounded-xl bg-card relative overflow-hidden">
|
||||
{/* Background pulse effect */}
|
||||
<div className="absolute inset-0 bg-primary/5 animate-pulse" />
|
||||
|
||||
<div className="relative z-10 flex flex-col items-center justify-center text-center space-y-4">
|
||||
{oauthError ? (
|
||||
<div className="text-red-400 space-y-2">
|
||||
<XCircle className="h-8 w-8 mx-auto" />
|
||||
<p className="font-medium">Authentication Failed</p>
|
||||
<p className="text-sm opacity-80">{oauthError}</p>
|
||||
<Button variant="outline" size="sm" onClick={handleCancelOAuth} className="mt-2">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
) : !oauthData ? (
|
||||
<div className="space-y-3 py-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto" />
|
||||
<p className="text-sm text-muted-foreground animate-pulse">Requesting secure login code...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 w-full">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-lg">Approve Login</h3>
|
||||
<div className="text-sm text-muted-foreground text-left mt-2 space-y-1">
|
||||
<p>1. Copy the authorization code below.</p>
|
||||
<p>2. Open the login page in your browser.</p>
|
||||
<p>3. Paste the code to approve access.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-2 p-3 bg-background border rounded-lg">
|
||||
<code className="text-2xl font-mono tracking-widest font-bold text-primary">
|
||||
{oauthData.userCode}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(oauthData.userCode);
|
||||
toast.success('Code copied to clipboard');
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={() => window.electron.ipcRenderer.invoke('shell:openExternal', oauthData.verificationUri)}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
Open Login Page
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground pt-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>Waiting for approval in browser...</span>
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" size="sm" className="w-full mt-2" onClick={handleCancelOAuth}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validate & Save */}
|
||||
<Button
|
||||
onClick={handleValidateAndSave}
|
||||
disabled={!canSubmit || validating}
|
||||
className="w-full"
|
||||
className={cn("w-full", useOAuthFlow && "hidden")}
|
||||
>
|
||||
{validating ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
|
||||
Reference in New Issue
Block a user