관리자 AI 제공자 연결 기능 추가 (claude·codex·agy·openrouter 토큰 붙여넣기 연결)
Some checks failed
API contract / OpenAPI type drift (push) Failing after 12m57s
Some checks failed
API contract / OpenAPI type drift (push) Failing after 12m57s
관리자 /admin/ai 화면에서 provider 토큰(OAuth 액세스 토큰 또는 API 키)을 발급받아 붙여넣으면 DB에 암호화 저장되고, shared-secret으로 보호되는 게이트웨이 내부 엔드포인트로 push되어 실행 중인 엔진 컨테이너에 즉시 적용된다. NAS처럼 게이트웨이가 컨테이너로 도는 환경에서 CLI·로컬 PC 의존 없이 claude_api·openai·openrouter를 연결할 수 있다. - 게이트웨이: openrouter 어댑터 신설(chat/completions), OAuth 토큰이면 Anthropic Bearer 헤더, /internal/provider-credentials GET/POST와 boot_id 기반 재동기화 - API: app.admin_provider_credential 테이블(idempotent DDL), stdlib HMAC-CTR+MAC 암호화 서비스(PROVIDER_CREDENTIAL_SECRET, 폴백 SESSION_SECRET), /admin/providers CRUD·검증 라우트 - 웹: 제공자 연결 패널(저장·검증·해제, 토큰 힌트만 표시), 엔진 선택에 OpenRouter 추가, api.gen.ts 재생성 - compose: api 서비스에 PROVIDER_CREDENTIAL_SECRET 전달(로컬·NAS)
This commit is contained in:
parent
6831a79ffb
commit
4b45d33142
22 changed files with 2088 additions and 13 deletions
|
|
@ -180,6 +180,70 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/providers": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* List Providers
|
||||
* @description 연결 가능한 AI provider 목록과 자격증명 상태를 반환한다.
|
||||
*/
|
||||
get: operations["list_providers_admin_providers_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/providers/{provider}/credential": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/**
|
||||
* Put Provider Credential
|
||||
* @description 사용자가 발급받아 붙여넣은 provider 토큰을 암호화 저장하고 게이트웨이에 주입한다.
|
||||
*/
|
||||
put: operations["put_provider_credential_admin_providers__provider__credential_put"];
|
||||
post?: never;
|
||||
/**
|
||||
* Delete Provider Credential
|
||||
* @description 저장된 provider 자격증명을 삭제하고 게이트웨이 주입 상태를 재동기화한다.
|
||||
*/
|
||||
delete: operations["delete_provider_credential_admin_providers__provider__credential_delete"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/providers/{provider}/verify": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Verify Provider Credential
|
||||
* @description 저장된 토큰으로 provider에 실제 요청을 보내 연결을 확인한다.
|
||||
*/
|
||||
post: operations["verify_provider_credential_admin_providers__provider__verify_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/tickets": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -2694,7 +2758,7 @@ export interface components {
|
|||
* Engine Mode
|
||||
* @enum {string}
|
||||
*/
|
||||
engine_mode: "claude_cli" | "claude_api" | "codex_cli" | "agy_cli" | "openai" | "solar";
|
||||
engine_mode: "claude_cli" | "claude_api" | "codex_cli" | "agy_cli" | "openai" | "openrouter" | "solar";
|
||||
/** Engine Url */
|
||||
engine_url: string;
|
||||
/** Model */
|
||||
|
|
@ -2915,6 +2979,98 @@ export interface components {
|
|||
/** Version */
|
||||
version: number;
|
||||
};
|
||||
/** AdminProviderCredentialRequest */
|
||||
AdminProviderCredentialRequest: {
|
||||
/**
|
||||
* Auth Kind
|
||||
* @default api_key
|
||||
* @enum {string}
|
||||
*/
|
||||
auth_kind: "api_key" | "oauth_token";
|
||||
/** Token */
|
||||
token: string;
|
||||
};
|
||||
/** AdminProviderCredentialResponse */
|
||||
AdminProviderCredentialResponse: {
|
||||
/** Auth Kind */
|
||||
auth_kind: string;
|
||||
gateway_sync: components["schemas"]["AdminProviderGatewaySync"];
|
||||
/** Provider */
|
||||
provider: string;
|
||||
/** Token Hint */
|
||||
token_hint: string;
|
||||
/** Updated At */
|
||||
updated_at?: number | null;
|
||||
/** Updated By */
|
||||
updated_by?: string | null;
|
||||
};
|
||||
/** AdminProviderCredentialStatus */
|
||||
AdminProviderCredentialStatus: {
|
||||
/** Allowed Auth Kinds */
|
||||
allowed_auth_kinds: string[];
|
||||
/** Auth Kind */
|
||||
auth_kind?: string | null;
|
||||
/** Connected */
|
||||
connected: boolean;
|
||||
/** Description */
|
||||
description: string;
|
||||
/** Guide */
|
||||
guide: string;
|
||||
/** Label */
|
||||
label: string;
|
||||
/** Last Verified At */
|
||||
last_verified_at?: number | null;
|
||||
/** Last Verify Error */
|
||||
last_verify_error?: string | null;
|
||||
/** Last Verify Ok */
|
||||
last_verify_ok?: boolean | null;
|
||||
/** Provider */
|
||||
provider: string;
|
||||
/** Token Hint */
|
||||
token_hint?: string | null;
|
||||
/** Token Page Label */
|
||||
token_page_label: string;
|
||||
/** Token Page Url */
|
||||
token_page_url: string;
|
||||
/** Updated At */
|
||||
updated_at?: number | null;
|
||||
/** Updated By */
|
||||
updated_by?: string | null;
|
||||
};
|
||||
/** AdminProviderDeleteResponse */
|
||||
AdminProviderDeleteResponse: {
|
||||
gateway_sync: components["schemas"]["AdminProviderGatewaySync"];
|
||||
/** Ok */
|
||||
ok: boolean;
|
||||
/** Provider */
|
||||
provider: string;
|
||||
};
|
||||
/** AdminProviderGatewaySync */
|
||||
AdminProviderGatewaySync: {
|
||||
/** Boot Id */
|
||||
boot_id?: string | null;
|
||||
/** Detail */
|
||||
detail?: string | null;
|
||||
/** Synced */
|
||||
synced: boolean;
|
||||
};
|
||||
/** AdminProviderVerifyResponse */
|
||||
AdminProviderVerifyResponse: {
|
||||
/** Detail */
|
||||
detail: string;
|
||||
/** Ok */
|
||||
ok: boolean;
|
||||
/** Provider */
|
||||
provider: string;
|
||||
/** Verified At */
|
||||
verified_at?: number | null;
|
||||
};
|
||||
/** AdminProvidersResponse */
|
||||
AdminProvidersResponse: {
|
||||
gateway_sync: components["schemas"]["AdminProviderGatewaySync"];
|
||||
/** Providers */
|
||||
providers: components["schemas"]["AdminProviderCredentialStatus"][];
|
||||
};
|
||||
/** AdminServiceHealth */
|
||||
AdminServiceHealth: {
|
||||
/** Detail */
|
||||
|
|
@ -5093,7 +5249,7 @@ export interface components {
|
|||
* Provider
|
||||
* @enum {string}
|
||||
*/
|
||||
provider: "claude_cli" | "claude_api" | "codex_cli" | "agy_cli" | "openai" | "solar";
|
||||
provider: "claude_cli" | "claude_api" | "codex_cli" | "agy_cli" | "openai" | "openrouter" | "solar";
|
||||
/**
|
||||
* Source
|
||||
* @enum {string}
|
||||
|
|
@ -11843,6 +11999,144 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
list_providers_admin_providers_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: {
|
||||
"__Host-vignette_sid"?: string | null;
|
||||
vignette_sid?: string | null;
|
||||
};
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AdminProvidersResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put_provider_credential_admin_providers__provider__credential_put: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
provider: string;
|
||||
};
|
||||
cookie?: {
|
||||
"__Host-vignette_sid"?: string | null;
|
||||
vignette_sid?: string | null;
|
||||
};
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["AdminProviderCredentialRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AdminProviderCredentialResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete_provider_credential_admin_providers__provider__credential_delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
provider: string;
|
||||
};
|
||||
cookie?: {
|
||||
"__Host-vignette_sid"?: string | null;
|
||||
vignette_sid?: string | null;
|
||||
};
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AdminProviderDeleteResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
verify_provider_credential_admin_providers__provider__verify_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
provider: string;
|
||||
};
|
||||
cookie?: {
|
||||
"__Host-vignette_sid"?: string | null;
|
||||
vignette_sid?: string | null;
|
||||
};
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AdminProviderVerifyResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
list_tickets_admin_tickets_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
|
|
|||
|
|
@ -786,6 +786,34 @@ export type AdminEngineConfigResponse = ApiSchema<"AdminEngineConfigResponse">;
|
|||
export type AdminEngineConfigPatchRequest = ApiSchema<"AdminEngineConfigPatch">;
|
||||
export type EngineCapabilitiesResponse = ApiSchema<"EngineCapabilitiesResponse">;
|
||||
|
||||
export type AdminProviderCredentialStatus =
|
||||
ApiSchema<"AdminProviderCredentialStatus">;
|
||||
export type AdminProvidersResponse = ApiSchema<"AdminProvidersResponse">;
|
||||
export type AdminProviderGatewaySync = ApiSchema<"AdminProviderGatewaySync">;
|
||||
export type AdminProviderCredentialResponse =
|
||||
ApiSchema<"AdminProviderCredentialResponse">;
|
||||
export type AdminProviderDeleteResponse = ApiSchema<"AdminProviderDeleteResponse">;
|
||||
export type AdminProviderVerifyResponse = ApiSchema<"AdminProviderVerifyResponse">;
|
||||
|
||||
export const adminProviderApi = {
|
||||
list: () => api.get<AdminProvidersResponse>("/admin/providers"),
|
||||
saveCredential: (provider: string, body: { token: string; auth_kind: "api_key" | "oauth_token" }) =>
|
||||
apiFetch<AdminProviderCredentialResponse>(
|
||||
`/admin/providers/${encodeURIComponent(provider)}/credential`,
|
||||
{ method: "PUT", body },
|
||||
),
|
||||
deleteCredential: (provider: string) =>
|
||||
apiFetch<AdminProviderDeleteResponse>(
|
||||
`/admin/providers/${encodeURIComponent(provider)}/credential`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
verify: (provider: string) =>
|
||||
api.post<AdminProviderVerifyResponse>(
|
||||
`/admin/providers/${encodeURIComponent(provider)}/verify`,
|
||||
{},
|
||||
),
|
||||
};
|
||||
|
||||
export const adminEngineApi = {
|
||||
get: () => api.get<AdminEngineConfigResponse>("/admin/engine-config"),
|
||||
capabilities: (engineMode: string, force = false, engineUrl?: string) => {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
type EngineCapabilitiesResponse,
|
||||
} from "../lib/api";
|
||||
import "./admin/admin-ai.css";
|
||||
import { ProviderConnectionsPanel } from "./admin/ProviderConnectionsPanel";
|
||||
|
||||
const WINDOW_OPTIONS = [7, 30, 90] as const;
|
||||
const ENGINE_MODES = [
|
||||
|
|
@ -18,6 +19,7 @@ const ENGINE_MODES = [
|
|||
"codex_cli",
|
||||
"agy_cli",
|
||||
"openai",
|
||||
"openrouter",
|
||||
"solar",
|
||||
] as const;
|
||||
|
||||
|
|
@ -28,6 +30,7 @@ const ENGINE_MODE_LABEL: Record<string, string> = {
|
|||
codex_cli: "Codex CLI 게이트웨이",
|
||||
agy_cli: "Agy CLI 게이트웨이",
|
||||
openai: "OpenAI 호환",
|
||||
openrouter: "OpenRouter",
|
||||
solar: "Solar",
|
||||
};
|
||||
|
||||
|
|
@ -690,6 +693,7 @@ export default function AdminAi() {
|
|||
</div>
|
||||
|
||||
<div className="aic-column aic-column--side">
|
||||
<ProviderConnectionsPanel />
|
||||
<section className="aic-panel aic-engine">
|
||||
<div className="aic-panel__head">
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ const ENGINE_MODE_LABEL: Record<string, string> = {
|
|||
codex_cli: "Codex CLI 게이트웨이",
|
||||
agy_cli: "Agy CLI 게이트웨이",
|
||||
openai: "OpenAI 호환",
|
||||
openrouter: "OpenRouter",
|
||||
solar: "Solar",
|
||||
};
|
||||
const ENGINE_MODES = [
|
||||
|
|
@ -54,6 +55,7 @@ const ENGINE_MODES = [
|
|||
"codex_cli",
|
||||
"agy_cli",
|
||||
"openai",
|
||||
"openrouter",
|
||||
"solar",
|
||||
] as const;
|
||||
|
||||
|
|
|
|||
310
apps/web/src/pages/admin/ProviderConnectionsPanel.tsx
Normal file
310
apps/web/src/pages/admin/ProviderConnectionsPanel.tsx
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button, Icon, Input } from "../../components/ui";
|
||||
import {
|
||||
adminProviderApi,
|
||||
type AdminProviderCredentialStatus,
|
||||
type AdminProvidersResponse,
|
||||
} from "../../lib/api";
|
||||
|
||||
function updatedAtLabel(value?: number | null): string {
|
||||
if (!value || !Number.isFinite(value)) return "기록 없음";
|
||||
return new Intl.DateTimeFormat("ko-KR", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(value * 1000));
|
||||
}
|
||||
|
||||
function authKindLabel(value: string | null | undefined): string {
|
||||
if (value === "oauth_token") return "OAuth 토큰";
|
||||
if (value === "api_key") return "API 키";
|
||||
return "미지정";
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 제공자 연결 패널 — OAuth/API 토큰을 발급받아 붙여넣는 방식으로 provider를 연결한다.
|
||||
* 토큰 원문은 저장 후 서버로만 전송되고 화면에는 마지막 4글자 힌트만 남는다.
|
||||
*/
|
||||
export function ProviderConnectionsPanel() {
|
||||
const [data, setData] = useState<AdminProvidersResponse | null>(null);
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||
const [authKinds, setAuthKinds] = useState<Record<string, string>>({});
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [flash, setFlash] = useState<string | null>(null);
|
||||
const [verifies, setVerifies] = useState<
|
||||
Record<string, { ok: boolean; detail: string }>
|
||||
>({});
|
||||
const requestSeq = useRef(0);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const requestId = ++requestSeq.current;
|
||||
setError(null);
|
||||
try {
|
||||
const next = await adminProviderApi.list();
|
||||
if (requestId !== requestSeq.current) return;
|
||||
setData(next);
|
||||
setAuthKinds((current) => {
|
||||
const merged = { ...current };
|
||||
for (const provider of next.providers) {
|
||||
if (!merged[provider.provider]) {
|
||||
merged[provider.provider] = provider.allowed_auth_kinds[0] ?? "api_key";
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
} catch (cause) {
|
||||
if (requestId !== requestSeq.current) return;
|
||||
setError(
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: "제공자 연결 상태를 불러오지 못했습니다.",
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const showFlash = (message: string) => {
|
||||
setFlash(message);
|
||||
window.setTimeout(() => setFlash(null), 2600);
|
||||
};
|
||||
|
||||
const saveCredential = async (provider: AdminProviderCredentialStatus) => {
|
||||
const token = (drafts[provider.provider] ?? "").trim();
|
||||
if (!token) return;
|
||||
setBusy(`save:${provider.provider}`);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await adminProviderApi.saveCredential(provider.provider, {
|
||||
token,
|
||||
auth_kind: (authKinds[provider.provider] ?? "api_key") as
|
||||
| "api_key"
|
||||
| "oauth_token",
|
||||
});
|
||||
setDrafts((current) => ({ ...current, [provider.provider]: "" }));
|
||||
showFlash(
|
||||
result.gateway_sync.synced
|
||||
? `${provider.label} 자격증명을 저장하고 게이트웨이에 적용했습니다.`
|
||||
: `${provider.label} 자격증명은 저장됐지만 게이트웨이 적용은 대기 중입니다.`,
|
||||
);
|
||||
await load();
|
||||
} catch (cause) {
|
||||
setError(
|
||||
cause instanceof Error ? cause.message : "자격증명을 저장하지 못했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteCredential = async (provider: AdminProviderCredentialStatus) => {
|
||||
setBusy(`delete:${provider.provider}`);
|
||||
setError(null);
|
||||
try {
|
||||
await adminProviderApi.deleteCredential(provider.provider);
|
||||
showFlash(`${provider.label} 연결을 해제했습니다.`);
|
||||
await load();
|
||||
} catch (cause) {
|
||||
setError(
|
||||
cause instanceof Error ? cause.message : "연결 해제에 실패했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const verifyCredential = async (provider: AdminProviderCredentialStatus) => {
|
||||
setBusy(`verify:${provider.provider}`);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await adminProviderApi.verify(provider.provider);
|
||||
setVerifies((current) => ({
|
||||
...current,
|
||||
[provider.provider]: {
|
||||
ok: result.ok,
|
||||
detail: result.detail || (result.ok ? "연결을 확인했습니다." : "확인 실패"),
|
||||
},
|
||||
}));
|
||||
await load();
|
||||
} catch (cause) {
|
||||
setVerifies((current) => ({
|
||||
...current,
|
||||
[provider.provider]: {
|
||||
ok: false,
|
||||
detail: cause instanceof Error ? cause.message : "검증 요청이 실패했습니다.",
|
||||
},
|
||||
}));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const providers = data?.providers ?? [];
|
||||
|
||||
return (
|
||||
<section className="aic-panel aic-providers" data-testid="provider-connections-panel">
|
||||
<div className="aic-panel__head">
|
||||
<div>
|
||||
<span className="aic-eyebrow">공급자 연결</span>
|
||||
<h2>AI 제공자 연결</h2>
|
||||
</div>
|
||||
<span
|
||||
className={`aic-runtime ${data?.gateway_sync.synced ? "aic-runtime--ok" : ""}`}
|
||||
title={data?.gateway_sync.detail ?? undefined}
|
||||
>
|
||||
{data === null ? "확인 중" : data.gateway_sync.synced ? "게이트웨이 적용됨" : "적용 대기"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="aic-alert" role="alert">
|
||||
<Icon name="alert" size={16} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{flash ? (
|
||||
<div className="aic-provider__flash" role="status">
|
||||
<Icon name="check" size={15} />
|
||||
<span>{flash}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="aic-provider__list">
|
||||
{providers.map((provider) => {
|
||||
const saving = busy === `save:${provider.provider}`;
|
||||
const verifying = busy === `verify:${provider.provider}`;
|
||||
const deleting = busy === `delete:${provider.provider}`;
|
||||
const verifyResult = verifies[provider.provider];
|
||||
const hasDraft = Boolean((drafts[provider.provider] ?? "").trim());
|
||||
return (
|
||||
<article
|
||||
key={provider.provider}
|
||||
className={`aic-provider ${provider.connected ? "is-connected" : ""}`}
|
||||
>
|
||||
<header className="aic-provider__head">
|
||||
<b>{provider.label}</b>
|
||||
<span className={`aic-provider__badge ${provider.connected ? "is-on" : ""}`}>
|
||||
{provider.connected ? "연결됨" : "미연결"}
|
||||
</span>
|
||||
</header>
|
||||
<p className="aic-provider__desc">{provider.description}</p>
|
||||
<p className="aic-provider__guide">{provider.guide}</p>
|
||||
{provider.token_page_url ? (
|
||||
<a
|
||||
className="aic-provider__link"
|
||||
href={provider.token_page_url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{provider.token_page_label} 열기
|
||||
<Icon name="arrow-flat" size={13} />
|
||||
</a>
|
||||
) : null}
|
||||
<div className="aic-provider__controls">
|
||||
<Input
|
||||
type="password"
|
||||
value={drafts[provider.provider] ?? ""}
|
||||
placeholder={provider.connected ? "새 토큰으로 교체" : "발급받은 토큰을 붙여넣으세요"}
|
||||
aria-label={`${provider.label} 토큰`}
|
||||
autoComplete="off"
|
||||
onChange={(event) =>
|
||||
setDrafts((current) => ({
|
||||
...current,
|
||||
[provider.provider]: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
{provider.allowed_auth_kinds.length > 1 ? (
|
||||
<select
|
||||
className="vg-input aic-select aic-provider__kind"
|
||||
value={authKinds[provider.provider] ?? provider.allowed_auth_kinds[0]}
|
||||
aria-label={`${provider.label} 인증 형식`}
|
||||
onChange={(event) =>
|
||||
setAuthKinds((current) => ({
|
||||
...current,
|
||||
[provider.provider]: event.target.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{provider.allowed_auth_kinds.map((kind) => (
|
||||
<option key={kind} value={kind}>
|
||||
{authKindLabel(kind)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
<div className="aic-provider__actions">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={saving || !hasDraft}
|
||||
onClick={() => void saveCredential(provider)}
|
||||
>
|
||||
{saving ? "저장 중" : "저장"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={verifying || !provider.connected}
|
||||
onClick={() => void verifyCredential(provider)}
|
||||
>
|
||||
{verifying ? "확인 중" : "연결 검증"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={deleting || !provider.connected}
|
||||
onClick={() => void deleteCredential(provider)}
|
||||
>
|
||||
{deleting ? "해제 중" : "해제"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{provider.connected ? (
|
||||
<dl className="aic-provider__meta">
|
||||
<div>
|
||||
<dt>토큰</dt>
|
||||
<dd>
|
||||
{provider.token_hint ?? "저장됨"} · {authKindLabel(provider.auth_kind)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>변경</dt>
|
||||
<dd>
|
||||
{updatedAtLabel(provider.updated_at)}
|
||||
{provider.updated_by ? ` · ${provider.updated_by}` : ""}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>검증</dt>
|
||||
<dd>
|
||||
{provider.last_verify_ok === null || provider.last_verify_ok === undefined
|
||||
? "미검증"
|
||||
: provider.last_verify_ok
|
||||
? `통과 · ${updatedAtLabel(provider.last_verified_at)}`
|
||||
: `실패 · ${provider.last_verify_error || "원인 미상"}`}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : null}
|
||||
{verifyResult ? (
|
||||
<p
|
||||
className={`aic-provider__verify ${verifyResult.ok ? "is-ok" : "is-fail"}`}
|
||||
role={verifyResult.ok ? "status" : "alert"}
|
||||
>
|
||||
{verifyResult.ok ? "연결 검증 통과" : "연결 검증 실패"} · {verifyResult.detail}
|
||||
</p>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
{data !== null && providers.length === 0 ? (
|
||||
<div className="aic-empty">연결 가능한 제공자가 정의되지 않았습니다.</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -828,3 +828,139 @@
|
|||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 제공자 연결 (ProviderConnectionsPanel) ───────────────────── */
|
||||
.aic-providers .aic-provider__list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.aic-provider {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-surface-2);
|
||||
}
|
||||
|
||||
.aic-provider.is-connected {
|
||||
border-color: color-mix(in srgb, var(--ok-solid, #2e7d32) 38%, var(--border-subtle));
|
||||
}
|
||||
|
||||
.aic-provider__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.aic-provider__head b {
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.aic-provider__badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.aic-provider__badge.is-on {
|
||||
border-color: transparent;
|
||||
background: color-mix(in srgb, var(--ok-solid, #2e7d32) 16%, transparent);
|
||||
color: var(--ok-text, #2e7d32);
|
||||
}
|
||||
|
||||
.aic-provider__desc,
|
||||
.aic-provider__guide {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.aic-provider__guide {
|
||||
color: var(--text-body);
|
||||
}
|
||||
|
||||
.aic-provider__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--text-strong);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.aic-provider__controls {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.aic-provider__kind {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.aic-provider__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.aic-provider__meta {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed var(--border-subtle);
|
||||
}
|
||||
|
||||
.aic-provider__meta div {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.aic-provider__meta dt {
|
||||
flex: 0 0 32px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.aic-provider__meta dd {
|
||||
margin: 0;
|
||||
color: var(--text-body);
|
||||
font-size: 11px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.aic-provider__verify {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.aic-provider__verify.is-ok {
|
||||
color: var(--ok-text, #2e7d32);
|
||||
}
|
||||
|
||||
.aic-provider__verify.is-fail {
|
||||
color: var(--crit-text, #b3261e);
|
||||
}
|
||||
|
||||
.aic-provider__flash {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--ok-solid, #2e7d32) 34%, var(--border-subtle));
|
||||
border-radius: var(--radius);
|
||||
color: var(--ok-text, #2e7d32);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue