feat: add local provider account connector

This commit is contained in:
thanhnv
2026-07-10 23:05:24 +09:00
parent adce7717f7
commit 7cb592a32f
9 changed files with 380 additions and 3 deletions
@@ -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.