OAuth 로그인 버튼으로 provider 연결 + 제공자 연결 패널 그리드 이동
Some checks failed
API contract / OpenAPI type drift (push) Failing after 59s

claude(setup-token과 동일한 PKCE, code#state 수동 흐름)와
openrouter(헤드리스 코드 표시 모드)는 OAuth 로그인 버튼으로 연결한다.
관리자가 제공자 로그인 뒤 표시되는 인증 코드를 붙여넣으면 서버가
토큰/키로 교환해 저장·게이트웨이 push한다. state는 서버 발급·15분
단일 사용으로 CSRF를 막는다. codex·agy는 붙여넣기 방식을 유지한다.

제공자 연결 패널은 우측 사이드에서 메인 컬럼의 Provider·모델별 사용
원장 아래로 옮기고 반응형 그리드(너비에 따라 1~n열)로 배치한다.
This commit is contained in:
Yun Chan 2026-09-11 17:33:51 +09:00
parent e3a9fc717c
commit cb4d45579d
9 changed files with 752 additions and 39 deletions

View file

@ -224,6 +224,46 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/providers/{provider}/oauth/finish": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Finish Provider Oauth
* @description push한다.
*/
post: operations["finish_provider_oauth_admin_providers__provider__oauth_finish_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/providers/{provider}/oauth/start": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Start Provider Oauth
* @description OAuth URL을 .
*/
post: operations["start_provider_oauth_admin_providers__provider__oauth_start_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/providers/{provider}/verify": {
parameters: {
query?: never;
@ -3024,6 +3064,11 @@ export interface components {
last_verify_error?: string | null;
/** Last Verify Ok */
last_verify_ok?: boolean | null;
/**
* Oauth Supported
* @default false
*/
oauth_supported: boolean;
/** Provider */
provider: string;
/** Token Hint */
@ -3054,6 +3099,24 @@ export interface components {
/** Synced */
synced: boolean;
};
/** AdminProviderOAuthFinishRequest */
AdminProviderOAuthFinishRequest: {
/** Code */
code: string;
/** State */
state: string;
};
/** AdminProviderOAuthStartResponse */
AdminProviderOAuthStartResponse: {
/** Authorize Url */
authorize_url: string;
/** Expires In */
expires_in: number;
/** Provider */
provider: string;
/** State */
state: string;
};
/** AdminProviderVerifyResponse */
AdminProviderVerifyResponse: {
/** Detail */
@ -12103,6 +12166,78 @@ export interface operations {
};
};
};
finish_provider_oauth_admin_providers__provider__oauth_finish_post: {
parameters: {
query?: never;
header?: never;
path: {
provider: string;
};
cookie?: {
"__Host-vignette_sid"?: string | null;
vignette_sid?: string | null;
};
};
requestBody: {
content: {
"application/json": components["schemas"]["AdminProviderOAuthFinishRequest"];
};
};
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"];
};
};
};
};
start_provider_oauth_admin_providers__provider__oauth_start_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"]["AdminProviderOAuthStartResponse"];
};
};
/** @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;

View file

@ -812,8 +812,21 @@ export const adminProviderApi = {
`/admin/providers/${encodeURIComponent(provider)}/verify`,
{},
),
startOauth: (provider: string) =>
api.post<AdminProviderOAuthStartResponse>(
`/admin/providers/${encodeURIComponent(provider)}/oauth/start`,
{},
),
finishOauth: (provider: string, body: { code: string; state: string }) =>
apiFetch<AdminProviderCredentialResponse>(
`/admin/providers/${encodeURIComponent(provider)}/oauth/finish`,
{ method: "POST", body },
),
};
export type AdminProviderOAuthStartResponse =
ApiSchema<"AdminProviderOAuthStartResponse">;
export const adminEngineApi = {
get: () => api.get<AdminEngineConfigResponse>("/admin/engine-config"),
capabilities: (engineMode: string, force = false, engineUrl?: string) => {

View file

@ -690,10 +690,11 @@ export default function AdminAi() {
<div className="aic-empty"> .</div>
)}
</section>
<ProviderConnectionsPanel />
</div>
<div className="aic-column aic-column--side">
<ProviderConnectionsPanel />
<section className="aic-panel aic-engine">
<div className="aic-panel__head">
<div>

View file

@ -36,6 +36,9 @@ export function ProviderConnectionsPanel() {
const [verifies, setVerifies] = useState<
Record<string, { ok: boolean; detail: string }>
>({});
const [oauthStates, setOauthStates] = useState<
Record<string, { state: string; authorizing: boolean; error: string | null }>
>({});
const requestSeq = useRef(0);
const load = useCallback(async () => {
@ -143,6 +146,73 @@ export function ProviderConnectionsPanel() {
}
};
const startOauth = async (provider: AdminProviderCredentialStatus) => {
setOauthStates((current) => ({
...current,
[provider.provider]: { state: "", authorizing: true, error: null },
}));
setError(null);
try {
const result = await adminProviderApi.startOauth(provider.provider);
window.open(result.authorize_url, "_blank", "noopener");
setOauthStates((current) => ({
...current,
[provider.provider]: { state: result.state, authorizing: false, error: null },
}));
} catch (cause) {
setOauthStates((current) => ({
...current,
[provider.provider]: {
state: "",
authorizing: false,
error:
cause instanceof Error
? cause.message
: "OAuth 로그인을 시작하지 못했습니다.",
},
}));
}
};
const finishOauth = async (provider: AdminProviderCredentialStatus) => {
const oauth = oauthStates[provider.provider];
const code = (drafts[provider.provider] ?? "").trim();
if (!oauth?.state || !code) return;
setBusy(`oauth:${provider.provider}`);
setError(null);
try {
const result = await adminProviderApi.finishOauth(provider.provider, {
code,
state: oauth.state,
});
setDrafts((current) => ({ ...current, [provider.provider]: "" }));
setOauthStates((current) => ({
...current,
[provider.provider]: { state: "", authorizing: false, error: null },
}));
showFlash(
result.gateway_sync.synced
? `${provider.label} OAuth 로그인으로 연결하고 게이트웨이에 적용했습니다.`
: `${provider.label} OAuth 연결은 저장됐지만 게이트웨이 적용은 대기 중입니다.`,
);
await load();
} catch (cause) {
setOauthStates((current) => ({
...current,
[provider.provider]: {
state: oauth.state,
authorizing: false,
error:
cause instanceof Error
? cause.message
: "인증 코드를 확인하지 못했습니다.",
},
}));
} finally {
setBusy(null);
}
};
const providers = data?.providers ?? [];
return (
@ -178,6 +248,8 @@ export function ProviderConnectionsPanel() {
const saving = busy === `save:${provider.provider}`;
const verifying = busy === `verify:${provider.provider}`;
const deleting = busy === `delete:${provider.provider}`;
const oauthFinishing = busy === `oauth:${provider.provider}`;
const oauth = oauthStates[provider.provider];
const verifyResult = verifies[provider.provider];
const hasDraft = Boolean((drafts[provider.provider] ?? "").trim());
return (
@ -205,46 +277,123 @@ export function ProviderConnectionsPanel() {
</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>
{provider.oauth_supported ? (
<div className="aic-provider__oauth">
<Button
size="sm"
variant="secondary"
disabled={oauth?.authorizing || Boolean(oauth?.state)}
onClick={() => void startOauth(provider)}
>
{oauth?.authorizing ? "로그인 페이지 여는 중" : "OAuth 로그인"}
</Button>
<span>
{oauth?.state
? "제공자 로그인 뒤 표시된 인증 코드를 아래에 붙여넣으세요."
: provider.connected
? "클릭하면 제공자 로그인 화면이 열립니다. 다른 계정으로 재연결할 수 있습니다."
: "클릭하면 제공자 로그인 화면이 열립니다."}
</span>
</div>
) : null}
{oauth?.state ? (
<>
<Input
type="password"
value={drafts[provider.provider] ?? ""}
placeholder={
provider.provider === "claude"
? "인증 코드 전체를 붙여넣으세요(예: code#state)"
: "표시된 인증 코드를 붙여넣으세요"
}
aria-label={`${provider.label} OAuth 인증 코드`}
autoComplete="off"
onChange={(event) =>
setDrafts((current) => ({
...current,
[provider.provider]: event.target.value,
}))
}
/>
<div className="aic-provider__actions">
<Button
size="sm"
disabled={oauthFinishing || !hasDraft}
onClick={() => void finishOauth(provider)}
>
{oauthFinishing ? "연결 중" : "인증 코드로 연결"}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() =>
setOauthStates((current) => ({
...current,
[provider.provider]: {
state: "",
authorizing: false,
error: null,
},
}))
}
>
</Button>
</div>
{oauth.error ? (
<p className="aic-provider__verify is-fail" role="alert">
{oauth.error}
</p>
) : null}
</>
) : (
<>
<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>
</div>
</>
)}
<div className="aic-provider__actions">
<Button
size="sm"
disabled={saving || !hasDraft}
onClick={() => void saveCredential(provider)}
>
{saving ? "저장 중" : "저장"}
</Button>
<Button
size="sm"
variant="secondary"

View file

@ -832,6 +832,7 @@
/* ── 제공자 연결 (ProviderConnectionsPanel) ───────────────────── */
.aic-providers .aic-provider__list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
@ -964,3 +965,15 @@
color: var(--ok-text, #2e7d32);
font-size: var(--fs-sm);
}
.aic-provider__oauth {
display: flex;
align-items: center;
gap: 10px;
}
.aic-provider__oauth span {
color: var(--text-muted);
font-size: 12px;
line-height: 1.5;
}