feat: add local provider account connector
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, ChatModelConnection, SettingsActor } from '../../lib/api';
|
||||
import { api, ChatModelConnection, ProviderAuthStatus, SettingsActor } from '../../lib/api';
|
||||
|
||||
interface ModelConnectionPanelProps {
|
||||
actor: SettingsActor;
|
||||
@@ -47,6 +47,13 @@ export function ModelConnectionPanel({ actor, open, onClose, onSelect }: ModelCo
|
||||
enabled: open,
|
||||
retry: false,
|
||||
});
|
||||
const accountQuery = useQuery({
|
||||
queryKey: ['provider-auth', actor],
|
||||
queryFn: () => api.providerAuthStatus(actor),
|
||||
enabled: open,
|
||||
retry: false,
|
||||
refetchInterval: (state) => state.state.data?.providers.some((provider) => provider.authenticating) ? 1500 : false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.data?.connections) return;
|
||||
@@ -94,7 +101,16 @@ export function ModelConnectionPanel({ actor, open, onClose, onSelect }: ModelCo
|
||||
onSuccess: async (result) => finish(`${result.connection.label} đã được ngắt kết nối; secret đã bị xóa.`),
|
||||
onError: (reason) => { setNotice(null); setError(messageOf(reason)); },
|
||||
});
|
||||
const busy = connect.isPending || refresh.isPending || setDefault.isPending || disconnect.isPending;
|
||||
const login = useMutation({
|
||||
mutationFn: (provider: ProviderAuthStatus) => api.startProviderLogin(actor, provider.id),
|
||||
onSuccess: async (result) => {
|
||||
setError(null);
|
||||
setNotice(result.reason === 'already_logged_in' ? `${result.provider.label} đã đăng nhập.` : `Đã mở browser login cho ${result.provider.label}. CASAN sẽ tự cập nhật khi callback hoàn tất.`);
|
||||
await accountQuery.refetch();
|
||||
},
|
||||
onError: (reason) => { setNotice(null); setError(messageOf(reason)); },
|
||||
});
|
||||
const busy = connect.isPending || refresh.isPending || setDefault.isPending || disconnect.isPending || login.isPending;
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
@@ -114,6 +130,22 @@ export function ModelConnectionPanel({ actor, open, onClose, onSelect }: ModelCo
|
||||
{notice && <div className="mb-5 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">{notice}</div>}
|
||||
{error && <div role="alert" className="mb-5 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{error}</div>}
|
||||
{query.isLoading && <div className="rounded-2xl border border-dashed border-slate-300 bg-white p-8 text-center text-sm text-slate-500">Đang đọc kho kết nối đã mã hóa…</div>}
|
||||
<section className="mb-5 rounded-2xl border border-indigo-200 bg-gradient-to-br from-indigo-950 to-slate-900 p-5 text-white shadow-lg shadow-indigo-950/10">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div><div className="text-[10px] font-bold uppercase tracking-[0.18em] text-indigo-300">Local account connector</div><h3 className="mt-1 text-base font-semibold">Đăng nhập bằng tài khoản Codex hoặc Claude</h3><p className="mt-1 max-w-2xl text-xs leading-5 text-indigo-100/70">Browser login do CLI chính thức trên máy Mac xử lý. Credential nằm trong Keychain/CLI store và không đi vào Docker hoặc trình duyệt CASAN.</p></div>
|
||||
<span className="rounded-full border border-white/15 bg-white/10 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide text-indigo-100">Host bridge</span>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
{(accountQuery.data?.providers ?? []).map((provider) => (
|
||||
<div key={provider.id} className="rounded-xl border border-white/10 bg-white/5 p-3.5">
|
||||
<div className="flex items-center justify-between gap-2"><div className="text-sm font-semibold">{provider.label}</div><span className={`rounded-full px-2 py-1 text-[9px] font-bold uppercase ${provider.loggedIn ? 'bg-emerald-300/20 text-emerald-200' : provider.authenticating ? 'bg-amber-300/20 text-amber-200' : 'bg-white/10 text-slate-300'}`}>{provider.loggedIn ? 'Logged in' : provider.authenticating ? 'Waiting callback' : provider.available ? 'Signed out' : 'CLI missing'}</span></div>
|
||||
<p className="mt-1 text-[11px] text-indigo-100/60">{provider.loggedIn ? `Auth: ${provider.authMethod}` : provider.available ? 'Login opens in your default browser.' : 'Install the official CLI on the Mac host first.'}</p>
|
||||
<button type="button" onClick={() => login.mutate(provider)} disabled={busy || !provider.available || provider.loggedIn || !['project-admin', 'org-admin'].includes(actor.role)} className="mt-3 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-slate-900 transition hover:bg-indigo-50 disabled:cursor-not-allowed disabled:bg-white/20 disabled:text-white/50">{provider.loggedIn ? 'Connected' : provider.authenticating ? 'Waiting…' : `Login ${provider.id === 'codex' ? 'with ChatGPT' : 'with Claude'}`}</button>
|
||||
</div>
|
||||
))}
|
||||
{accountQuery.isError && <div className="rounded-xl border border-rose-300/20 bg-rose-300/10 p-3 text-xs text-rose-100 sm:col-span-2">Host auth bridge chưa sẵn sàng. Chạy lại local-full để bật connector.</div>}
|
||||
</div>
|
||||
</section>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{(query.data?.connections ?? []).map((connection) => {
|
||||
const form = forms[connection.id] ?? { endpoint: connection.endpoint || DEFAULT_ENDPOINTS[connection.id], apiKey: '' };
|
||||
|
||||
@@ -208,6 +208,15 @@ export interface ChatModelConnection {
|
||||
requiresKey: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderAuthStatus {
|
||||
id: 'codex' | 'claude';
|
||||
label: string;
|
||||
available: boolean;
|
||||
loggedIn: boolean;
|
||||
authenticating: boolean;
|
||||
authMethod: string;
|
||||
}
|
||||
|
||||
export interface ChatReplay {
|
||||
ok: boolean;
|
||||
decision: 'MATCH' | 'DRIFT' | 'BREAK' | string;
|
||||
@@ -347,6 +356,10 @@ export const api = {
|
||||
post<{ success: boolean; connection: ChatModelConnection }>('chat/connections/default', { provider, model }, actorHeaders(actor)),
|
||||
disconnectChatProvider: (actor: SettingsActor, provider: string) =>
|
||||
post<{ success: boolean; connection: ChatModelConnection }>('chat/connections/disconnect', { provider }, actorHeaders(actor)),
|
||||
providerAuthStatus: (actor: SettingsActor) =>
|
||||
getWithHeaders<{ success: boolean; providers: ProviderAuthStatus[] }>('provider-auth', actorHeaders(actor)),
|
||||
startProviderLogin: (actor: SettingsActor, provider: ProviderAuthStatus['id']) =>
|
||||
post<{ success: boolean; reason: string; provider: ProviderAuthStatus }>(`provider-auth/${provider}/login`, {}, actorHeaders(actor)),
|
||||
};
|
||||
|
||||
// Health is raw (not enveloped) + carries HTTP status.
|
||||
|
||||
Reference in New Issue
Block a user