diff --git a/apps/api/app/routes/admin.py b/apps/api/app/routes/admin.py index e936c07..4b636d0 100644 --- a/apps/api/app/routes/admin.py +++ b/apps/api/app/routes/admin.py @@ -39,6 +39,7 @@ from ..engine_client import engine_client from ..runtime_policy import require_runtime_fallback_allowed from ..services import evaluator, notifications, rag from ..services import provider_credentials as provider_credentials_service +from ..services import provider_oauth as provider_oauth_service from ..services.llm_pricing import ( estimate_reference_cost, provider_uses_reference_cost, @@ -350,6 +351,7 @@ class AdminProviderCredentialStatus(BaseModel): token_page_label: str guide: str allowed_auth_kinds: list[str] + oauth_supported: bool = False class AdminProvidersResponse(BaseModel): @@ -384,6 +386,18 @@ class AdminProviderVerifyResponse(BaseModel): verified_at: float | None = None +class AdminProviderOAuthStartResponse(BaseModel): + provider: str + authorize_url: str + state: str + expires_in: int + + +class AdminProviderOAuthFinishRequest(BaseModel): + code: str = Field(min_length=4, max_length=8192) + state: str = Field(min_length=8, max_length=256) + + class AdminTicketPatch(BaseModel): status: TicketStatus | None = None priority: TicketPriority | None = None @@ -2391,6 +2405,7 @@ async def list_providers(principal: AdminPrincipal) -> AdminProvidersResponse: token_page_label=meta.token_page_label, guide=meta.guide, allowed_auth_kinds=list(meta.auth_kinds), + oauth_supported=provider_oauth_service.oauth_supported(meta.code), ) ) return AdminProvidersResponse( @@ -2487,6 +2502,54 @@ async def verify_provider_credential( ) +@router.post( + "/providers/{provider}/oauth/start", + response_model=AdminProviderOAuthStartResponse, +) +async def start_provider_oauth( + provider: str, + principal: AdminPrincipal, +) -> AdminProviderOAuthStartResponse: + """OAuth 로그인 시작 — 제공자 인증 화면 URL을 발급한다.""" + try: + result = provider_oauth_service.start_oauth(provider, principal.email) + except provider_oauth_service.ProviderCredentialError as exc: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + return AdminProviderOAuthStartResponse( + provider=provider, + authorize_url=result["authorize_url"], + state=result["state"], + expires_in=result["expires_in"], + ) + + +@router.post( + "/providers/{provider}/oauth/finish", + response_model=AdminProviderCredentialResponse, +) +async def finish_provider_oauth( + provider: str, + body: AdminProviderOAuthFinishRequest, + principal: AdminPrincipal, +) -> AdminProviderCredentialResponse: + """붙여넣은 인증 코드를 토큰으로 교환해 저장하고 게이트웨이에 push한다.""" + try: + result = await provider_oauth_service.finish_oauth( + provider, body.code, body.state, principal.email + ) + except provider_oauth_service.ProviderCredentialError as exc: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + stored = result["stored"] + return AdminProviderCredentialResponse( + provider=stored.provider, + token_hint=stored.token_hint, + auth_kind=stored.auth_kind, + updated_by=stored.updated_by, + updated_at=stored.updated_at, + gateway_sync=_provider_sync_response(result["gateway_sync"]), + ) + + @router.get("/users", response_model=AdminUsersResponse) async def list_users(principal: AdminPrincipal) -> AdminUsersResponse: """Return users observed by the server-side auth/session boundary.""" diff --git a/apps/api/app/services/provider_oauth.py b/apps/api/app/services/provider_oauth.py new file mode 100644 index 0000000..6bdd774 --- /dev/null +++ b/apps/api/app/services/provider_oauth.py @@ -0,0 +1,202 @@ +"""provider OAuth 로그인(PKCE) — 로그인해서 코드를 받아 토큰으로 교환한다. + +관리자가 "OAuth 로그인" 버튼으로 제공자 인증 화면을 열고, 제공자가 보여주는 +인증 코드를 복사해 붙여넣으면 서버가 토큰으로 교환해 저장·게이트웨이에 push한다. + +- claude: ``claude setup-token``과 동일한 Authorization Code + PKCE 흐름. + 콜백 페이지가 보여주는 ``code#state`` 문자열을 그대로 붙여넣는다. + 스펙 근거: code.claude.com/docs/en/authentication 및 Claude Code OAuth 설정. +- openrouter: 헤드리스 모드(콜백 URL 없이 코드를 화면에 표시). 스펙 근거: + openrouter.ai/docs/oauth. 교환 결과는 사용자 귀속 API 키다. + +state는 서버가 발급해 15분 보관하며, 교환 시 provider·만료를 검증한다. +""" + +from __future__ import annotations + +import base64 +import hashlib +import secrets +import time +from dataclasses import dataclass +from typing import Any + +import httpx + +from .provider_credentials import ( + ProviderCredentialError, + push_credentials_to_gateway, + save_credential, +) + +OAUTH_PENDING_TTL_SECONDS = 900.0 + +# claude: setup-token과 동일한 공개 클라이언트. 콜백 페이지가 code#state를 +# 화면에 표시하는 수동 흐름이라 사전 등록·로컬 콜백 서버가 필요 없다. +_CLAUDE_OAUTH = { + "authorize_url": "https://claude.ai/oauth/authorize", + "token_url": "https://platform.claude.com/v1/oauth/token", + "redirect_uri": "https://platform.claude.com/oauth/code/callback", + "client_id": "9d1c250a-e61b-44d9-88ed-5944d1962f5e", + "scope": "user:inference", + "expires_in": 365 * 24 * 60 * 60, +} + +# openrouter: 콜백 없이 인증 코드를 화면에 표시하는 헤드리스 모드. +_OPENROUTER_OAUTH = { + "authorize_url": "https://openrouter.ai/auth", + "exchange_url": "https://openrouter.ai/api/v1/auth/keys", + "key_label": "Vignette 관리자 연결", +} + +OAUTH_PROVIDERS = ("claude", "openrouter") + + +@dataclass(frozen=True, slots=True) +class _PendingOAuth: + provider: str + code_verifier: str + created_by: str + created_at: float + + +_PENDING: dict[str, _PendingOAuth] = {} + + +def oauth_supported(provider: str) -> bool: + return provider in OAUTH_PROVIDERS + + +def _purge_expired() -> None: + now = time.time() + expired = [key for key, item in _PENDING.items() if now - item.created_at > OAUTH_PENDING_TTL_SECONDS] + for key in expired: + _PENDING.pop(key, None) + + +def _pkce_pair() -> tuple[str, str]: + verifier = secrets.token_urlsafe(48) + challenge = base64.urlsafe_b64encode( + hashlib.sha256(verifier.encode("ascii")).digest() + ).decode("ascii").rstrip("=") + return verifier, challenge + + +def start_oauth(provider: str, admin_email: str) -> dict[str, Any]: + """OAuth 시작. 제공자 인증 화면 URL과 서버가 보관하는 state를 돌려준다.""" + if provider not in OAUTH_PROVIDERS: + raise ProviderCredentialError(f"{provider}는 OAuth 로그인을 지원하지 않습니다.") + _purge_expired() + + state = secrets.token_urlsafe(24) + code_verifier, code_challenge = _pkce_pair() + _PENDING[state] = _PendingOAuth( + provider=provider, + code_verifier=code_verifier, + created_by=admin_email, + created_at=time.time(), + ) + + if provider == "claude": + authorize_url = ( + f"{_CLAUDE_OAUTH['authorize_url']}" + "?code=true" + "&response_type=code" + f"&client_id={_CLAUDE_OAUTH['client_id']}" + f"&redirect_uri={_CLAUDE_OAUTH['redirect_uri']}" + f"&scope={_CLAUDE_OAUTH['scope'].replace(':', '%3A')}" + f"&code_challenge={code_challenge}" + "&code_challenge_method=S256" + f"&state={state}" + ) + else: + authorize_url = ( + f"{_OPENROUTER_OAUTH['authorize_url']}" + f"?code_challenge={code_challenge}" + "&code_challenge_method=S256" + f"&key_label={_OPENROUTER_OAUTH['key_label']}" + ) + return { + "authorize_url": authorize_url, + "state": state, + "expires_in": int(OAUTH_PENDING_TTL_SECONDS), + } + + +def _pop_pending(provider: str, state: str) -> _PendingOAuth: + _purge_expired() + attempt = _PENDING.get(state) + if attempt is None: + raise ProviderCredentialError("OAuth 요청이 만료되었거나 존재하지 않습니다. 다시 로그인해 주세요.") + _PENDING.pop(state, None) + if attempt.provider != provider: + raise ProviderCredentialError("OAuth 요청의 provider가 일치하지 않습니다.") + return attempt + + +async def finish_oauth(provider: str, code: str, state: str, admin_email: str) -> dict[str, Any]: + """붙여넣은 인증 코드를 토큰/키로 교환해 저장하고 게이트웨이에 push한다.""" + attempt = _pop_pending(provider, state) + pasted = code.strip() + + if provider == "claude": + # 콜백 페이지 표시 형식: authorizationCode#state + parts = pasted.split("#") + if len(parts) != 2 or parts[1].strip() != state: + raise ProviderCredentialError( + "인증 코드 형식이 올바르지 않습니다. 콜백 화면의 전체 코드(code#state)를 붙여넣으세요." + ) + auth_code = parts[0].strip() + payload: dict[str, Any] = { + "grant_type": "authorization_code", + "code": auth_code, + "redirect_uri": _CLAUDE_OAUTH["redirect_uri"], + "client_id": _CLAUDE_OAUTH["client_id"], + "code_verifier": attempt.code_verifier, + "state": state, + "expires_in": _CLAUDE_OAUTH["expires_in"], + } + try: + async with httpx.AsyncClient(timeout=30) as client: + response = await client.post( + _CLAUDE_OAUTH["token_url"], + json=payload, + headers={"Content-Type": "application/json"}, + ) + response.raise_for_status() + body = response.json() + except (httpx.HTTPError, ValueError) as exc: + raise ProviderCredentialError(f"Claude 토큰 교환 실패: {exc}") from exc + token = str(body.get("access_token") or "").strip() + if not token: + raise ProviderCredentialError("Claude가 액세스 토큰을 반환하지 않았습니다.") + auth_kind = "oauth_token" + else: + try: + async with httpx.AsyncClient(timeout=30) as client: + response = await client.post( + _OPENROUTER_OAUTH["exchange_url"], + json={ + "code": pasted, + "code_verifier": attempt.code_verifier, + "code_challenge_method": "S256", + }, + headers={"Content-Type": "application/json"}, + ) + response.raise_for_status() + body = response.json() + except (httpx.HTTPError, ValueError) as exc: + raise ProviderCredentialError(f"OpenRouter 키 교환 실패: {exc}") from exc + token = str(body.get("key") or "").strip() + if not token: + raise ProviderCredentialError("OpenRouter가 API 키를 반환하지 않았습니다.") + auth_kind = "api_key" + + stored = await save_credential( + provider=provider, + token=token, + auth_kind=auth_kind, + updated_by=admin_email, + ) + sync = await push_credentials_to_gateway() + return {"stored": stored, "gateway_sync": sync} diff --git a/apps/api/app/test_provider_oauth.py b/apps/api/app/test_provider_oauth.py new file mode 100644 index 0000000..4b945f0 --- /dev/null +++ b/apps/api/app/test_provider_oauth.py @@ -0,0 +1,136 @@ +"""provider OAuth(PKCE) 서비스 단위 테스트 — 실제 네트워크 호출 없음.""" + +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from app.services import provider_oauth as svc + + +class _FakeResponse: + def __init__(self, payload, status_code=200): + self._payload = payload + self.status_code = status_code + + def raise_for_status(self): + if self.status_code >= 400: + import httpx + + raise httpx.HTTPStatusError("err", request=None, response=None) + + def json(self): + return self._payload + + +class _FakeAsyncClient: + def __init__(self, payload): + self._payload = payload + self.captured = {} + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return None + + async def post(self, url, **kwargs): + self.captured = {"url": url, **kwargs} + return _FakeResponse(self._payload) + + +class StartOAuthTest(unittest.TestCase): + def setUp(self): + svc._PENDING.clear() + + def tearDown(self): + svc._PENDING.clear() + + def test_unsupported_provider_rejected(self): + with self.assertRaises(svc.ProviderCredentialError): + svc.start_oauth("codex", "admin@example.com") + + def test_claude_authorize_url_contains_pkce_and_state(self): + result = svc.start_oauth("claude", "admin@example.com") + self.assertIn("https://claude.ai/oauth/authorize", result["authorize_url"]) + self.assertIn("code_challenge_method=S256", result["authorize_url"]) + self.assertIn(f"&state={result['state']}", result["authorize_url"]) + self.assertIn("user%3Ainference", result["authorize_url"]) + self.assertIn(result["state"], svc._PENDING) + + def test_openrouter_authorize_url_is_headless(self): + result = svc.start_oauth("openrouter", "admin@example.com") + self.assertIn("https://openrouter.ai/auth?", result["authorize_url"]) + self.assertIn("code_challenge_method=S256", result["authorize_url"]) + self.assertNotIn("callback_url", result["authorize_url"]) + + def test_pending_expires(self): + import time + + result = svc.start_oauth("claude", "admin@example.com") + attempt = svc._PENDING[result["state"]] + stale = svc._PendingOAuth( + provider=attempt.provider, + code_verifier=attempt.code_verifier, + created_by=attempt.created_by, + created_at=time.time() - svc.OAUTH_PENDING_TTL_SECONDS - 1, + ) + svc._PENDING[result["state"]] = stale + with self.assertRaises(svc.ProviderCredentialError): + svc._pop_pending("claude", result["state"]) + + +class FinishOAuthTest(unittest.IsolatedAsyncioTestCase): + def setUp(self): + svc._PENDING.clear() + + def tearDown(self): + svc._PENDING.clear() + + async def test_claude_code_state_exchange_and_save(self): + start = svc.start_oauth("claude", "admin@example.com") + fake = _FakeAsyncClient({"access_token": "sk-ant-oat-token-abc", "expires_in": 31536000}) + with patch.object(svc.httpx, "AsyncClient", return_value=fake), patch.object( + svc, "save_credential" + ) as save, patch.object(svc, "push_credentials_to_gateway") as push: + save.return_value = SimpleNamespace( + provider="claude", token_hint="…oat", auth_kind="oauth_token", + updated_by="admin@example.com", updated_at=1.0, + ) + push.return_value = {"synced": True} + result = await svc.finish_oauth( + "claude", f"auth-code-123#{start['state']}", start["state"], "admin@example.com" + ) + self.assertEqual(result["stored"].auth_kind, "oauth_token") + sent = fake.captured["json"] + self.assertEqual(sent["grant_type"], "authorization_code") + self.assertEqual(sent["code"], "auth-code-123") + self.assertIn("code_verifier", sent) + self.assertEqual(fake.captured["url"], svc._CLAUDE_OAUTH["token_url"]) + + async def test_state_mismatch_rejected(self): + start = svc.start_oauth("claude", "admin@example.com") + with self.assertRaises(svc.ProviderCredentialError): + await svc.finish_oauth("claude", "code#other-state", start["state"], "a@b.com") + + async def test_openrouter_code_exchange_returns_api_key(self): + start = svc.start_oauth("openrouter", "admin@example.com") + fake = _FakeAsyncClient({"key": "sk-or-v1-oauth-key"}) + with patch.object(svc.httpx, "AsyncClient", return_value=fake), patch.object( + svc, "save_credential" + ) as save, patch.object(svc, "push_credentials_to_gateway") as push: + save.return_value = SimpleNamespace( + provider="openrouter", token_hint="…key", auth_kind="api_key", + updated_by="admin@example.com", updated_at=1.0, + ) + push.return_value = {"synced": True} + result = await svc.finish_oauth( + "openrouter", "or-code-xyz", start["state"], "admin@example.com" + ) + self.assertEqual(result["stored"].auth_kind, "api_key") + self.assertEqual(fake.captured["url"], svc._OPENROUTER_OAUTH["exchange_url"]) + self.assertEqual(fake.captured["json"]["code"], "or-code-xyz") + self.assertEqual(fake.captured["json"]["code_challenge_method"], "S256") + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/web/src/lib/api.gen.ts b/apps/web/src/lib/api.gen.ts index c6955ab..f591782 100644 --- a/apps/web/src/lib/api.gen.ts +++ b/apps/web/src/lib/api.gen.ts @@ -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; diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 9f13aa8..ed9cff7 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -812,8 +812,21 @@ export const adminProviderApi = { `/admin/providers/${encodeURIComponent(provider)}/verify`, {}, ), + startOauth: (provider: string) => + api.post( + `/admin/providers/${encodeURIComponent(provider)}/oauth/start`, + {}, + ), + finishOauth: (provider: string, body: { code: string; state: string }) => + apiFetch( + `/admin/providers/${encodeURIComponent(provider)}/oauth/finish`, + { method: "POST", body }, + ), }; +export type AdminProviderOAuthStartResponse = + ApiSchema<"AdminProviderOAuthStartResponse">; + export const adminEngineApi = { get: () => api.get("/admin/engine-config"), capabilities: (engineMode: string, force = false, engineUrl?: string) => { diff --git a/apps/web/src/pages/AdminAi.tsx b/apps/web/src/pages/AdminAi.tsx index 14dae1c..754b1ed 100644 --- a/apps/web/src/pages/AdminAi.tsx +++ b/apps/web/src/pages/AdminAi.tsx @@ -690,10 +690,11 @@ export default function AdminAi() {
모델별 계량 행이 아직 없습니다.
)} + +
-
diff --git a/apps/web/src/pages/admin/ProviderConnectionsPanel.tsx b/apps/web/src/pages/admin/ProviderConnectionsPanel.tsx index e72e30b..2a25ecc 100644 --- a/apps/web/src/pages/admin/ProviderConnectionsPanel.tsx +++ b/apps/web/src/pages/admin/ProviderConnectionsPanel.tsx @@ -36,6 +36,9 @@ export function ProviderConnectionsPanel() { const [verifies, setVerifies] = useState< Record >({}); + const [oauthStates, setOauthStates] = useState< + Record + >({}); 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() { ) : null}
- - setDrafts((current) => ({ - ...current, - [provider.provider]: event.target.value, - })) - } - /> - {provider.allowed_auth_kinds.length > 1 ? ( - + {provider.oauth_supported ? ( +
+ + + {oauth?.state + ? "제공자 로그인 뒤 표시된 인증 코드를 아래에 붙여넣으세요." + : provider.connected + ? "클릭하면 제공자 로그인 화면이 열립니다. 다른 계정으로 재연결할 수 있습니다." + : "클릭하면 제공자 로그인 화면이 열립니다."} + +
) : null} + {oauth?.state ? ( + <> + + setDrafts((current) => ({ + ...current, + [provider.provider]: event.target.value, + })) + } + /> +
+ + +
+ {oauth.error ? ( +

+ {oauth.error} +

+ ) : null} + + ) : ( + <> + + setDrafts((current) => ({ + ...current, + [provider.provider]: event.target.value, + })) + } + /> + {provider.allowed_auth_kinds.length > 1 ? ( + + ) : null} +
+ +
+ + )}
-