diff --git a/devlog/_plan/260727_login_url_copy_parity/000_inventory.md b/devlog/_plan/260727_login_url_copy_parity/000_inventory.md new file mode 100644 index 0000000000..e5587b4b39 --- /dev/null +++ b/devlog/_plan/260727_login_url_copy_parity/000_inventory.md @@ -0,0 +1,87 @@ +# 000 — 로그인 URL 복사 어포던스 인벤토리 (조사) + +세 개의 OAuth 로그인 대기 표면이 서로 다른 수준의 어포던스를 들고 있다. +5919779d가 둘을 고쳤고 하나는 그대로 남았다. 아래는 조사 시점(dev @ f327db1e)의 +실측이다. + +## 표면 A — Provider Workspace 설정 패널 + +`gui/src/components/provider-workspace/ProviderAuthPanel.tsx:150-172` + +- URL 전문: `{hintForThis.url}` — 있음 +- 복사 버튼: 3-상태(`idle` / `copied` / `unavailable`), `copyTextToClipboard` 사용 +- 타이머: `linkCopyTimer` ref + 언마운트 `clearTimeout` +- 외부 링크: `prov.didntOpen` 있음 +- a11y: 라벨 span에 `aria-live="polite"` +- 기기 코드 복사(`prov.copyCode`)는 `navigator.clipboard.writeText` 직접 호출 — + 같은 파일 안에서 규약이 갈린다(복사 실패 시 라벨 변화 없음). + +## 표면 B — 프로바이더 추가 모달 + +`gui/src/components/add-provider-oauth-pane.tsx:38-93` + +표면 A와 동일한 블록을 **복제**해서 들고 있다. 상태 이름(`linkCopyState`), +타이머 ref, 렌더 트리, 클래스명까지 같다. 즉 이미 2벌 중복이다. + +## 표면 C — Codex/ChatGPT 계정 추가·재인증 모달 (구멍) + +`gui/src/components/add-codex-account-waiting-step.tsx:41-43` +`gui/src/components/use-add-codex-account-oauth.ts:253-273` + +- URL 전문: **없음**. `ui.authUrl`은 상태에 있지만 화면에 렌더되지 않는다. + 복사 버튼이 듣지 않는 환경(비보안 컨텍스트)에서 URL을 얻을 방법이 0이다. +- 복사 피드백: 2-상태(`copied` boolean)뿐. 실패는 + `dispatch({type:"set-error"})`로 **에러 notice**에 섞인다 + (`codexAuth.loginLinkCopyFailed`). 복사 실패와 로그인 실패가 같은 자리에 + 같은 톤으로 뜬다. +- 복사 구현: `navigator.clipboard?.writeText` → 실패 시 `document.execCommand("copy")` + 수동 textarea 폴백. 저장소 공용 래퍼(`oauth-health-display.ts:126` + `copyTextToClipboard`)를 쓰지 않는 유일한 경로다. +- 타이머: `setTimeout` 2.5초, ref 없음. 연속 클릭 시 앞 타이머가 뒤 라벨을 + 지운다 — 5919779d 감사에서 이미 잡혔던 회귀가 이 파일에만 남았다. +- 외부 링크(`prov.didntOpen`): **없음**. + +`AddCodexAccountModal`은 계정 추가와 재인증 두 진입점을 모두 이 대기 화면으로 +보낸다(`AddCodexAccountModal.tsx:66-84`, `CodexAccountPool.tsx` `openReauth`). +즉 ChatGPT 계정을 추가하는 모든 사용자가 구식 표면을 만난다. + +## 데이터 경로 + +| 표면 | URL 출처 | 상태 보관 | +|------|----------|-----------| +| A | `POST /api/oauth/login` → `loginInfo.url` (`use-providers-oauth.ts:84`) | `Providers.tsx` useState | +| B | 같은 엔드포인트 → `oauthUrl` + `oauthUrlProvider` | `add-provider-modal-reducer.ts` | +| C | `POST /api/codex-auth/login` → `data.url` (`use-add-codex-account-oauth.ts:157`) | `add-codex-account-reducer.ts` `authUrl` | + +서버는 세 경로 모두 URL을 이미 내려주고, 브라우저 열기까지 서버가 이미 시도한다 +(`src/server/management/oauth-account-routes.ts:100-106`, +`src/codex/auth-api.ts:759-761`). 서버 변경은 필요 없다. 이 사실이 블록의 +성격을 결정한다 — 이 블록은 기본 경로가 아니라 **자동 열기 실패 뒤의 복구 경로**다. + +## i18n 현황 + +`prov.copyLink` / `prov.linkCopied` / `prov.linkCopyUnavailable` / +`prov.didntOpen`은 6개 로케일(en/ko/ja/zh/de/ru)에 모두 있다. +`codexAuth.copyLoginLink` / `codexAuth.loginLinkCopied` / +`codexAuth.loginLinkCopyFailed`도 6개 로케일에 있으나 표면 C 전용이다. + +## CSS 현황 + +`gui/src/styles/provider-workspace-settings.css:36-39`에 +`.pwi-auth-open-link` / `.pwi-auth-url-wrap` / `.pwi-auth-url` / +`.pwi-auth-url-actions`가 이미 정의돼 있고 표면 A·B가 공유한다. +접두사 `pwi-`(provider workspace item)가 모달에서도 쓰이는 상태라 +이름과 소속이 어긋난다. + +## 문제 정의 + +1. 표면 C에 URL 전문·외부 링크가 없어 "브라우저가 안 열림" 상황에서 막힌다. +2. 표면 C의 복사 실패가 에러로 오분류되고 3-상태 피드백이 없다. +3. 표면 C의 타이머가 ref로 보호되지 않는다. +4. 표면 A·B가 같은 블록을 2벌 복제해 다음 회귀가 다시 갈라질 준비를 하고 있다. + +## 결론 — 작업 분해 + +- wp1: 공용 컴포넌트 신설(3벌 중복의 단일 소유자) + 단위 테스트. +- wp2: 표면 C 채택 + 복사 규약 통일 + 타이머 하드닝 + 회귀 테스트. +- wp3: 표면 A·B 이관, i18n 정합, 전체 검증. diff --git a/devlog/_plan/260727_login_url_copy_parity/010_shared_block.md b/devlog/_plan/260727_login_url_copy_parity/010_shared_block.md new file mode 100644 index 0000000000..c5c099800d --- /dev/null +++ b/devlog/_plan/260727_login_url_copy_parity/010_shared_block.md @@ -0,0 +1,136 @@ +# 010 — 공용 LoginUrlBlock 컴포넌트 (wp1) + +## 목표 + +로그인 URL 노출 + 복사 + 외부 열기를 한 파일이 소유한다. 현재 표면 A·B가 +같은 JSX를 2벌 들고 있고 표면 C는 아예 없다. 소유자가 하나여야 다음 회귀가 +세 갈래로 갈리지 않는다. + +## Design Read (mini DESIGN.md) + +```yaml +name: login-url-block +role: recovery affordance inside an OAuth waiting state +``` + +읽기: 로컬 프록시 관리 도구(D5~D6 밀도)의 **에러 복구 보조 표면**이다. +사용자는 이미 "브라우저가 안 열렸다"는 실패 상태에 있고, 목표는 URL을 다른 +기기/브라우저로 옮기는 것 하나뿐이다. + +- DESIGN_VARIANCE: 2 +- MOTION_INTENSITY: 1 +- 밀도 프로필: D5 (개발자·운영 도구) +- 근거: 실패 복구 경로에 시각적 변주를 넣는 것은 domain-wrong이다. 이 블록의 + 성공 기준은 "URL을 얻어간다" 하나이며, 기존 대기 패널의 시각 언어를 벗어나면 + 오히려 사용자가 새 UI를 읽는 비용을 문다. + +Do: 기존 `.pwi-auth-url*` 시각 언어를 그대로 승계한다. 복사 결과를 말로 알린다. +Don't: 새 색/모션/아이콘 체계 도입 금지. 토스트 도입 금지(대기 패널 안에서 끝낸다). + +### Lazy-User Gate (UX-LAZY-01) + +이 블록의 결정 지점은 "복사" 하나다. 나머지는 결정이 아니라 정보다. + +- do nothing: 서버가 이미 브라우저를 열어봤다(`openUrl`). 실패했을 때만 이 블록이 의미를 갖는다 → 렌더 조건은 `url`이 있을 때로 유지. +- delete: "URL 전문 표시"를 지울 수 있나? 못 지운다. 클립보드가 막힌 컨텍스트에서 유일한 탈출구다(`user-select: all`). +- absorb: 복사 실패를 시스템이 흡수한다 → 사용자에게 "실패했으니 알아서 하라"가 아니라 URL 전문을 이미 눈앞에 두어 수동 선택이 가능한 상태로 만든다. +- demote: 없음. 이 블록 자체가 이미 실패 상태에서만 의미를 갖는 종속 정보다. + +### UX-STATE-01 — 에러/복구 상태 계약 + +복사 결과는 3-상태다. 실패를 조용히 삼키지 않고, 로그인 에러와 섞지도 않는다. + +| 상태 | 라벨 키 | 의미 | +|------|---------|------| +| idle | `prov.copyLink` | 아직 누르지 않음 | +| copied | `prov.linkCopied` | 클립보드에 들어감 | +| unavailable | `prov.linkCopyUnavailable` | 클립보드 API 부재/거부 — URL 전문을 수동 선택하라는 신호 | + +`unavailable`은 dead-end가 아니다. 바로 위에 URL 전문이 `user-select: all`로 +있으므로 복구 경로가 화면에 남아 있다. + +## 신규 파일 — `gui/src/components/login-url-block.tsx` + +```tsx +import { useEffect, useRef, useState } from "react"; +import { IconExternal, IconLink } from "../icons"; +import { useT } from "../i18n/shared"; +import { copyTextToClipboard } from "../oauth-health-display"; + +export type LoginUrlCopyState = "idle" | "copied" | "unavailable"; + +export function LoginUrlBlock({ url, className }: { url: string; className?: string }) { ... } +``` + +- props는 `url` 하나(+ 선택적 `className`). 상태·타이머·i18n을 컴포넌트가 소유한다. + 호출부가 복사 상태를 들고 있을 이유가 없다 — 표면 A·B의 `linkCopyState`, + 표면 C의 `ui.copied`가 전부 사라진다. +- `url`이 빈 문자열이면 `null`을 반환한다. 호출부 조건문을 단순화한다. +- 타이머는 `useRef | null>`. 클릭할 때마다 + `clearTimeout` 후 재설정, 언마운트 시 정리. 이유: 같은 outcome을 연속으로 + 누르면 functional-update 가드로는 앞 타이머가 뒤 라벨을 지운다(5919779d 감사 결론). +- **`url`이 바뀌면 복사 상태를 `idle`로 되돌리고 대기 중인 타이머를 취소한다** + (`useEffect(..., [url])`). A 감사 blocker #1: 표면 A는 + `Providers.tsx:210`의 `key={item.name}`로 리마운트되어 우연히 안전하지만, + 표면 B(`AddProviderModal.tsx:253`)에는 그런 key가 없다. 지금은 복사 상태가 + pane 지역 상태라 무해하지만, 블록이 상태를 소유하는 순간 + "Claude 복사 → Back → Gemini 로그인" 경로에서 **Gemini URL 위에 '복사됨'이 + 남는다** — 클립보드에는 Claude URL이 든 채로. 거짓 성공이다. + 호출부 `key={url}`이 아니라 컴포넌트 내부 effect로 해결한다. 상태를 소유한 + 쪽이 그 상태의 무효화도 소유해야 한다(이 문서의 단일 소유자 논지 그대로). +- 라벨 span에 `aria-live="polite"`. 상태 전환을 스크린리더에 고지한다. +- 외부 링크 `prov.didntOpen`은 블록 내부에 포함한다. 표면 A·B가 지금 이 링크를 + 블록 밖에 두고 있으나, "URL을 얻는다"라는 하나의 사용자 의도에 속하므로 + 블록이 함께 소유하는 것이 옳다. + +렌더 구조(기존 마크업/클래스 승계): + +```tsx +
+ {url} +
+ + + +
+
+``` + +클래스명은 이번 단계에서 바꾸지 않는다. `pwi-` 접두사가 모달에서도 쓰이는 +어긋남은 사실이나, 리네임은 CSS·테스트·세 표면을 동시에 건드리므로 이 루프의 +목표(기능 동등성)와 섞으면 회귀 원인을 분리할 수 없다. 별도 항목으로 남긴다. + +## 신규 테스트 — `gui/tests/login-url-block.test.tsx` + +`gui/tests/provider-auth-login-copy-link.test.tsx`의 happy-dom + act 하네스를 재사용. + +1. `url`이 주어지면 URL 전문이 DOM에 렌더된다. +2. 복사 클릭 시 `navigator.clipboard.writeText`가 그 URL로 호출되고 라벨이 + `prov.linkCopied`로 바뀐다. +3. 클립보드 API 부재 시 라벨이 `prov.linkCopyUnavailable`로 바뀐다(조용한 실패 금지). +4. 2.5초 창 안에서 같은 outcome으로 재클릭해도 뒤 클릭 피드백이 제 수명을 다한다 + (타이머 ref 회귀 가드). +5. `prov.didntOpen` 외부 링크가 `href={url}`로 존재한다. +6. `url=""`이면 아무것도 렌더하지 않는다. +7. **url A로 복사한 뒤 url B로 리렌더하면 라벨이 `prov.copyLink`로 돌아온다** + (blocker #1 가드). 이 테스트는 effect를 지우면 실패해야 한다. + +버튼은 라벨 텍스트가 아니라 `.pwi-auth-url-actions button` 구조로 조회한다. + +## 범위 밖 (wp1) + +- 표면 A·B·C의 호출부 변경 — wp2/wp3. +- CSS 클래스 리네임. +- 서버/`src/` 변경. + +## 완료 기준 + +- `cd gui && bun x tsc -b` exit 0 — 단 이는 `src`만 덮는다. + `gui/tsconfig.app.json`의 `include`는 `src`뿐이므로 **신규 테스트 파일의 타입은 + 이 명령이 증명하지 않는다**(A 감사 #8). 테스트 타입 오류는 `bun test`에서만 드러난다. +- `cd gui && bun test tests/login-url-block.test.tsx` 전건 통과 +- 컴포넌트를 되돌리면 신규 테스트가 실패한다(가드 실효 확인) diff --git a/devlog/_plan/260727_login_url_copy_parity/020_codex_modal.md b/devlog/_plan/260727_login_url_copy_parity/020_codex_modal.md new file mode 100644 index 0000000000..3025122d29 --- /dev/null +++ b/devlog/_plan/260727_login_url_copy_parity/020_codex_modal.md @@ -0,0 +1,106 @@ +# 020 — Codex 계정 추가/재인증 모달 채택 (wp2) + +이 루프의 본체다. 사용자가 보고한 증상("계정 추가할 때 복사 버튼 관련")이 +바로 이 표면이다. + +## 변경 1 — `gui/src/components/add-codex-account-waiting-step.tsx` + +현재: + +```tsx + +``` + +변경 후: 위 버튼을 `` 하나로 교체한다. + +**빈 URL 구간의 거동 변화(A 감사 #5).** 지금은 `disabled={!authUrl}` 버튼이 +회색으로 자리를 지킨다. 블록은 `url=""`이면 `null`을 반환하므로 그 자리가 빈다. +재인증 진입은 `initialAddCodexAccountUiState`가 곧바로 `step: "oauth-waiting"`을 +주므로(`AddCodexAccountModal.tsx:20`) `startOAuth` 응답 전 첫 렌더가 실제로 그 상태다. +받아들인다 — 대기 화면에는 이미 스피너와 `codexAuth.oauthWaiting` 문구가 있어 +"준비 중"이라는 신호가 중복으로 존재하고, 눌리지 않는 버튼보다 아무것도 없는 쪽이 +거짓 어포던스를 만들지 않는다. `cancelLogin`이 `authUrl`을 비우면 블록이 사라지는 +것도 같은 이유로 옳다. + +- `copied` prop 제거. `onCopyLoginLink` prop 제거. +- import에서 `IconLink` 제거(더 이상 이 파일이 아이콘을 직접 쓰지 않는다). +- 배치: `

` 바로 아래, 수동 코드 입력 블록 위. + 대기 화면의 읽기 순서는 "기다리는 중이다 → URL은 여기 있다 → 안 되면 코드를 + 붙여넣어라"가 되어야 한다. 지금은 복사 버튼이 full-width로 떠 있어 위계가 없다. +- 모달 폭이 440px(`AddCodexAccountModal.tsx`)이고 OAuth URL은 길다. + `.pwi-auth-url`이 `overflow-wrap: anywhere`를 이미 갖고 있어 그대로 통한다. + +## 변경 2 — `gui/src/components/use-add-codex-account-oauth.ts` + +`copyLoginLink`(253-273, 닫는 `};` 포함) 전체를 삭제한다. + +- `document.execCommand("copy")` 수동 textarea 폴백 삭제. 저장소 공용 래퍼 + `copyTextToClipboard`가 비보안 컨텍스트에서 `false`를 돌려주는 계약이고, + 그 실패를 3-상태 라벨로 보여주는 것이 이제 컴포넌트 책임이다. +- 복사 실패 시 `set-error` 디스패치 제거. 로그인 에러 채널을 복사 실패가 + 오염하던 문제가 여기서 사라진다. +- 반환 객체에서 `copyLoginLink` 제거(`:311`). +- 훅의 `ui` 파라미터 타입(`:20-26`)에는 `copied`가 없다. 시그니처 변경은 없다. + +## 변경 3 — `gui/src/components/add-codex-account-reducer.ts` + +- `AddCodexAccountUiState`에서 `copied: boolean` 필드 제거. +- `initialAddCodexAccountUiState`에서 `copied: false` 제거. +- 액션 유니온에서 `{ type: "set-copied"; copied: boolean }` 제거. +- reducer의 `case "set-copied"` 제거. + +이 상태는 이제 컴포넌트 지역 상태다. 모달 reducer가 복사 피드백 같은 +순간적 UI 상태를 들고 있을 이유가 없다. + +## 변경 4 — `gui/src/components/AddCodexAccountModal.tsx` + +- `oauth` 구조분해에서 `copyLoginLink` 제거. +- ``에서 `copied={ui.copied}` / + `onCopyLoginLink={...}` prop 제거. + +## i18n + +새 키는 필요 없다. 표면 C가 `prov.copyLink` / `prov.linkCopied` / +`prov.linkCopyUnavailable` / `prov.didntOpen`을 쓰게 된다. + +`codexAuth.copyLoginLink` / `codexAuth.loginLinkCopied` / +`codexAuth.loginLinkCopyFailed` 3개 키는 소비처가 0이 된다. 6개 로케일에서 +삭제한다 — 죽은 키를 남기면 다음 사람이 이 표면에 별도 규약이 있다고 오해한다. +(5919779d 조사에서 "i18n 키는 살아 있고 소비처만 0건"이 오히려 혼란의 근거였다.) + +**반드시 6개 파일을 같은 커밋에서 함께 지운다(A 감사 blocker #2).** +삭제 위치: `en.ts:1006-1008`, `ko.ts:707-709`, `zh.ts:707-709`, +`ja.ts:960-962`, `de.ts:690-692`, `ru.ts:1005-1007`. +정합 게이트는 `lint:i18n`이 **아니다** — `gui/eslint.config.js:12`가 +`src/i18n/**`를 globalIgnores에 넣어 로케일 키에 대해 아무 의견이 없다. +실제 게이트는 두 개다: `gui/tests/claude-desktop-locale.test.ts`의 +"locale key sets stay identical to the English source"(텍스트 파싱 집합 비교)와, +`en.ts:1361`의 `TKey = keyof typeof en` 때문에 발생하는 `tsc` 오류. + +## 신규 테스트 — `gui/tests/add-codex-account-login-url.test.tsx` + +기존 `gui/tests/add-codex-account-oauth.test.tsx`의 fetch 스텁 하네스를 따른다. +`/api/codex-auth/login`이 `{ url, flowId }`를 반환하도록 하고 모달을 마운트한다. + +1. 대기 단계에서 인증 URL 전문이 DOM에 렌더된다(현재는 렌더되지 않음 → 실패해야 함). +2. `prov.didntOpen` 외부 링크가 그 URL을 가리킨다. +3. 복사 클릭 시 클립보드에 URL이 들어가고 라벨이 `prov.linkCopied`로 바뀐다. +4. 클립보드 부재 시 라벨이 `prov.linkCopyUnavailable`이 되고, + **에러 notice(`.notice-err`)는 뜨지 않는다** — 복사 실패의 에러 채널 오염 금지. +5. 재인증 진입(`reauthAccountId` 지정)에서도 같은 블록이 렌더된다. + **`startOAuth` 응답을 명시적으로 `act`로 기다린 뒤 단언한다** — 첫 렌더는 + `authUrl: ""`이라 기다리지 않으면 flaky다(A 감사 #5). + +## 완료 기준 + +- `cd gui && bun x tsc -b` exit 0 +- `cd gui && bun test tests` 전건 통과(기존 `add-codex-account-oauth.test.tsx`, + `claude-desktop-locale.test.ts` 포함) +- `bun test tests/codex-auth-modal-status.test.ts` (루트) 통과 — 이 테스트가 + `add-codex-account-waiting-step.tsx`의 `aria-live="polite"`와 수동 코드 + disabled 조건을 소스 텍스트로 고정하고 있으므로 대기 화면 편집 시 함께 본다. +- 변경을 되돌리면 신규 테스트가 실패한다 +- `rg "copyLoginLink|set-copied|loginLinkCopyFailed|loginLinkCopied" gui/src` → 0건 + (`loginLinkCopied`를 빼면 로케일 잔존을 놓친다 — A 감사 #9) diff --git a/devlog/_plan/260727_login_url_copy_parity/030_migrate_and_verify.md b/devlog/_plan/260727_login_url_copy_parity/030_migrate_and_verify.md new file mode 100644 index 0000000000..e6608541d7 --- /dev/null +++ b/devlog/_plan/260727_login_url_copy_parity/030_migrate_and_verify.md @@ -0,0 +1,84 @@ +# 030 — 표면 A·B 이관 + i18n 정합 + 전체 검증 (wp3) + +## 변경 1 — `gui/src/components/provider-workspace/ProviderAuthPanel.tsx` + +- `linkCopyState` / `linkCopyTimer` / `copyLoginUrl` / 관련 `useEffect` 정리 훅 삭제. +- `hintForThis.url &&` 블록을 ``로 교체. + 기존 `prov.didntOpen` 링크는 블록이 소유하므로 별도 렌더를 제거한다. +- import 정리(A 감사 #4로 실측 확정): `IconLink`, `IconExternal`, `useEffect`, + `useRef`가 미사용이 되어 제거 대상이다. **`useState`는 남긴다** — + `addingKey`/`newKey`/`keyBusy`/`deviceCodeCopied`(`:49-52`)와 + `copiedDoctorFor`(`:68`)가 계속 쓴다. + `gui/tsconfig.app.json`이 `noUnusedLocals: true`라 틀리면 `tsc`가 즉시 잡는다. +- `copyTextToClipboard`는 doctor 복사에서 계속 쓰므로 import 유지. + +### 보류 — 기기 코드 복사 (A 감사 #10으로 범위에서 제외) + +같은 파일 147-152의 `prov.copyCode` 버튼이 `navigator.clipboard.writeText`를 +직접 부르고 실패를 `.catch(() => {})`로 삼킨다. 실제 결함이다. 그러나 이 루프의 +인과 사슬(로그인 URL 어포던스) 밖이고, 테스트가 없는 경로이며, 이 계획이 스스로 +내건 "되돌리면 테스트가 실패한다" 보증을 흐린다. **별도 항목으로 남긴다.** + +## 변경 2 — `gui/src/components/add-provider-oauth-pane.tsx` + +- `linkCopyState` / `linkCopyTimer` / `copyAuthUrl` / 정리 `useEffect` 삭제. +- `oauthBusy && oauthUrl` 블록을 ``로 교체 + (렌더 조건 `oauthBusy &&`는 유지 — 스테일 URL 노출 방지 계약). +- import에서 `IconExternal` / `IconLink` / `copyTextToClipboard` / + `useEffect` / `useRef` / `useState` 중 미사용분 제거. +- **`oauthUrlProvider` 필터와 reducer의 `set-oauth-url` 가드는 건드리지 않는다.** + A/B 로그인 경쟁 회귀(`add-provider-oauth-url-leak.test.tsx`)를 지키는 계약이다. + +## 변경 3 — i18n 6개 로케일 + +- `codexAuth.copyLoginLink` / `codexAuth.loginLinkCopied` / + `codexAuth.loginLinkCopyFailed` 삭제(en/ko/ja/zh/de/ru). +- 나머지 키 변경 없음. +- 정합 게이트는 `lint:i18n`이 아니라 + `cd gui && bun test tests/claude-desktop-locale.test.ts`다 + (`gui/eslint.config.js:12`가 `src/i18n/**`를 무시한다 — A 감사 blocker #2). + `lint:i18n`은 UI 파일의 하드코딩 문자열 규칙용이므로 별도로 계속 돌린다. + +## 변경 4 — 기존 테스트 무수정 통과 확인 + +`gui/tests/provider-auth-login-copy-link.test.tsx`와 +`gui/tests/add-provider-oauth-url-leak.test.tsx`는 라벨 텍스트와 구조 +(`.pwi-auth-url-actions button`)로 조회하므로 이관 후에도 그대로 통과해야 한다. +**테스트를 고쳐서 통과시키지 않는다.** 통과하지 않으면 이관이 동등하지 않다는 뜻이다. + +## 검증 (wp3 종료 조건) + +| 명령 | 기대 | +|------|------| +| `bun run typecheck` | exit 0 | +| `cd gui && bun x tsc -b` | exit 0 | +| `cd gui && bun test tests` | 전건 통과 | +| `bun run lint:gui` | exit 0 | +| `cd gui && bun run lint:i18n` | exit 0 (로케일 키 정합은 이 명령이 보지 않는다) | +| `cd gui && bun test tests/claude-desktop-locale.test.ts` | 로케일 키 집합 동일 | +| `bun test tests/codex-auth-modal-status.test.ts` | 대기 화면 소스 계약 유지 | +| `bun run privacy:scan` | exit 0 | +| `bun run test` (루트) | 기준선 대비 신규 실패 0 | + +루트 스위트는 착수 전 기준선을 먼저 측정해 비교한다(과거 이 저장소에서 +`tests/management-provider-validation.test.ts`가 병렬 포트 경합으로 flake를 +낸 전례가 있다 — 기준선 없이 "5건 실패"를 신규로 오판하면 안 된다). + +## 소비처 확인 + +`rg -n "LoginUrlBlock" gui/src` → 표면 A·B·C 3건 + 컴포넌트 정의 1건. +`rg -n "pwi-auth-url-wrap" gui/src --glob '*.tsx'` → 컴포넌트 1건만. + +## docs-site + +`rg`로 확인한 결과 `docs-site/`에 copy-login-link 어포던스를 언급하는 문장이 +0건이다. 표면 C가 URL과 외부 링크를 얻는 것은 사용자에게 보이는 변화지만, +문서가 이 화면의 구성 요소를 열거한 적이 없으므로 모순되는 서술이 생기지 않는다. +**문서 변경 없음 — 근거를 남기고 넘어간다**(A 감사 #11). + +## 범위 밖 + +- `pwi-` 클래스 접두사 리네임(별도 항목). +- `prov.copyCode` 기기 코드 복사 규약 통일(별도 항목). +- 계정 풀/자동 전환 로직. +- push, 배포. diff --git a/devlog/_plan/260727_login_url_copy_parity/040_outcome.md b/devlog/_plan/260727_login_url_copy_parity/040_outcome.md new file mode 100644 index 0000000000..0bd1ee3457 --- /dev/null +++ b/devlog/_plan/260727_login_url_copy_parity/040_outcome.md @@ -0,0 +1,83 @@ +# 040 — 실행 결과 (wp1~wp4 종료) + +## 무엇이 바뀌었나 + +세 로그인 표면이 하나의 `LoginUrlBlock`을 쓴다. 사용자가 지적한 표면 +(ChatGPT 계정 추가·재인증 모달)은 이제 인증 URL 전문, 수동 열기 링크, +3-상태 복사 피드백을 갖는다. + +| | 이전 | 이후 | +|---|---|---| +| Workspace 패널 | URL + 3-상태 복사 (복제본 1) | 공용 블록 | +| 프로바이더 추가 모달 | URL + 3-상태 복사 (복제본 2) | 공용 블록 | +| Codex 계정 모달 | 복사 버튼만, 실패는 에러 notice | 공용 블록 | +| 비보안 컨텍스트 복사 | 표면별로 제각각 | `copyTextToClipboard` 폴백 통일 | + +## 커밋 (dev, 전부 미푸시) + +| 커밋 | 내용 | +|------|------| +| `ef656be2` | wp0 문서 4건 | +| `c136ecec` | `LoginUrlBlock` 신설 + 테스트 6건 | +| `555c3d63` | 복사 피드백을 effect 리셋에서 파생 상태로 (lint 규칙) | +| `3f05fa00` | Codex 계정 모달 채택 + 죽은 i18n 키 3개 제거 | +| `853c4088` | 표면 A·B 이관, 중복 2벌 제거 | +| `a19ce5dd` | 비보안 컨텍스트 클립보드 폴백 복원 | + +## 계획에서 벗어난 지점 + +**1. url 변경 리셋을 effect가 아니라 파생 상태로 구현했다.** +010은 `useEffect(() => setState("idle"), [url])`를 적었지만 +`react-hooks/set-state-in-effect`가 막았다. 규칙이 옳다 — effect로 상태를 +되돌리면 캐스케이딩 렌더가 된다. 피드백이 자기 url을 함께 들고 다니게 해 +렌더 시점 불일치로 읽히게 바꿨다. 계약은 동일하고 effect만 사라졌다. + +**2. wp4가 계획에 없었다.** +C 단계 독립 검증에서 나온 인접 결함이다. `execCommand` 폴백이 Codex 모달의 +옛 코드에만 있었고 공용 래퍼로 옮기며 사라졌다. `hostname: 0.0.0.0`으로 LAN에 +노출하면 평문 HTTP라 보안 컨텍스트가 아니고 `navigator.clipboard`가 없다. +즉 그 배포에서 복사가 영구히 죽는다. 조기 종료 대신 work-phase를 붙여 +공용 래퍼에서 한 번에 고쳤다 — 세 표면이 동시에 수혜를 본다. + +**3. 기기 코드 복사(`prov.copyCode`) 통일은 계획에서 뺐다.** +A 감사가 범위 이탈로 지목했고 동의했다. 별도 항목. + +## 검증 (실측) + +| 명령 | 결과 | +|------|------| +| `bun run typecheck` | exit 0 | +| `cd gui && bun x tsc -b` | exit 0 | +| `cd gui && bun test tests` | 319 pass / 0 fail | +| `bun run test` (루트) | 4985 pass / 0 fail | +| `bun run lint:gui` | exit 0 | +| `cd gui && bun run lint:i18n` | exit 0 | +| `bun run privacy:scan` | passed | +| `bun test tests/claude-desktop-locale.test.ts` | 6개 로케일 키 1324개로 동일 | + +가드 실효(수정을 되돌리면 실패): + +- url 스코프 피드백 제거 → 1 fail +- 재클릭 `clearTimer` 제거 → 1 fail +- `LoginUrlBlock` 렌더 제거(Codex 모달) → 3 fail +- 빈 url 가드 제거 → 2 fail +- `execCommand` 폴백 제거 → 2 fail + +이관 동등성의 증거는 기존 테스트 두 파일 +(`provider-auth-login-copy-link.test.tsx`, `add-provider-oauth-url-leak.test.tsx`)이 +한 줄도 수정되지 않고 9건 전부 통과한다는 사실이다. + +## 독립 검증 + +두 차례 독립 감사를 받았다. + +- wp0 계획 감사: `GO-WITH-FIXES (blockers=2)`. 지적 11건 전부 문서에 반영. + blocker는 (a) url 변경 시 stale 복사 상태, (b) 로케일 정합 게이트 오인 + (`lint:i18n`은 `src/i18n/**`를 globalIgnores로 제외한다). +- wp3 종료 후 최종 검증: `PASS (blockers=0)`. 지적 [M] 1건이 wp4가 됐다. + +## 남은 항목 + +- `pwi-` 클래스 접두사 리네임 (모달에서도 쓰이는데 이름이 provider workspace를 가리킨다) +- `prov.copyCode` 기기 코드 복사 규약 통일 +- push 및 배포는 사용자 승인 대기 diff --git a/devlog/_plan/260727_login_url_copy_parity/050_device_code_copy.md b/devlog/_plan/260727_login_url_copy_parity/050_device_code_copy.md new file mode 100644 index 0000000000..a07c1f3767 --- /dev/null +++ b/devlog/_plan/260727_login_url_copy_parity/050_device_code_copy.md @@ -0,0 +1,162 @@ +# 050 — 기기 코드 복사를 공용 복사 규약으로 (wp1) + +040의 "남은 항목" 두 건 중 첫째. wp3 감사에서 범위 이탈로 뺐던 항목을 +독립 사이클로 되가져온다. + +## 문제 + +`gui/src/components/provider-workspace/ProviderAuthPanel.tsx:133-138` + +```tsx + +``` + +같은 대기 패널 안에서 규약이 둘로 갈린다. + +| | 기기 코드 복사 | 로그인 URL 복사 | +|---|---|---| +| 구현 | `navigator.clipboard.writeText` 직접 | `copyTextToClipboard` | +| 비보안 컨텍스트 | **TypeError로 즉시 죽음** | execCommand 폴백 | +| 실패 처리 | `.catch(() => {})` 침묵 | `unavailable` 라벨 | +| 타이머 | ref 없는 `setTimeout` | ref + clearTimeout | + +비보안 컨텍스트 문제가 특히 나쁘다. `navigator.clipboard`가 `undefined`면 +`.writeText`를 읽는 순간 TypeError가 나고, `.catch()`는 **Promise 거부만** +잡으므로 이 동기 예외를 못 잡는다. LAN 바인딩(`hostname: 0.0.0.0`, 평문 HTTP)에서 +기기 코드 복사 버튼을 누르면 아무 일도 일어나지 않고 콘솔에만 예외가 남는다. +a19ce5dd가 URL 복사에 대해 고친 바로 그 문제가 여기 그대로 있다. + +## A 감사가 바꾼 방향 — 훅으로 규약을 실제로 공유한다 + +초안은 기기 코드에 3-상태를 손으로 한 벌 더 짜는 계획이었다. 감사가 +정확히 반박했다: "공유하는 것은 복사 규약"이라 써놓고 규약을 공유하지 않으면 +같은 프로토콜의 손수 구현이 **셋**이 된다. + +| 표면 | 상태 모양 | 라벨 | 타이머 | +|------|-----------|------|--------| +| `LoginUrlBlock` | `{url, outcome} \| null` | 인라인 삼항 | `timerRef` | +| 기기 코드 (초안대로면) | `"idle"\|"copied"\|"unavailable"` | 인라인 삼항 | 또 다른 ref | +| doctor 복사 | `DoctorCopyFeedback{accountId, outcome}` | `doctorCopyButtonLabel` | ref 없는 `setTimeout` | + +게다가 doctor 복사(`ProviderAuthPanel.tsx:182`)에는 **URL 복사가 이미 겪은 +타이머 회귀가 그대로 남아 있다**. functional update로 `accountId`+`outcome`을 +비교해 완화했지만, 같은 계정을 같은 결과로 연속 클릭하면 앞 타이머가 뒤 +피드백을 지운다. 5919779d 감사가 "functional update 가드는 불충분하다"고 +결론 낸 바로 그 형태다. + +세 번째 손수 구현을 추가하는 대신 훅을 뽑는다. 셋 다 이미 스코프 키를 갖고 +있다 — URL, (없음), accountId. 하나의 추상이 자연스럽게 맞는다. + +### 신규 `gui/src/components/use-copy-feedback.ts` + +```ts +export type CopyOutcome = "copied" | "unavailable"; + +export function useCopyFeedback(): { + outcomeFor: (scope: K) => CopyOutcome | null; + copy: (text: string, scope: K) => void; +}; +``` + +- 피드백을 `{ scope, outcome }`로 들고, 읽을 때 스코프가 일치할 때만 결과를 + 돌려준다. `LoginUrlBlock`이 url 변경 시 자동으로 idle이 되는 그 방식과 동일하다 + (`react-hooks/set-state-in-effect`가 effect 리셋을 금지하므로 파생이 유일한 길). +- 타이머는 훅이 ref로 소유한다. 재클릭 시 `clearTimeout`, 언마운트 시 정리. +- `copyTextToClipboard`를 부른다 — execCommand 폴백이 세 표면 모두에 붙는다. +- 스코프가 없는 표면은 `K = void`로 쓴다. + +### 세 소비처 이관 + +- `LoginUrlBlock`: 지역 `useState`/`timerRef`/`copy`를 훅 호출로 교체. 스코프는 `url`. +- 기기 코드: 훅 호출. 스코프 없음. 라벨은 아래 표. +- doctor 복사: `copiedDoctorFor`/`setTimeout`을 훅으로 교체. 스코프는 `accountId`. + `doctorCopyButtonLabel`은 `DoctorCopyFeedback` 대신 `CopyOutcome | null`을 받도록 + 좁힌다 — 스코프 비교는 이제 훅이 한다. + `CodexAccountPool.tsx`와 `codex-account-pool-cards.tsx`도 같은 시그니처로 따라간다. + +## 변경 — `ProviderAuthPanel.tsx` + +기기 코드 복사도 URL 복사와 같은 3-상태를 갖는다. 코드 전문이 ``에 +이미 보이므로 `unavailable`은 dead-end가 아니다 — `.pwi-device-code`가 +`user-select: all`이라 수동 선택 경로가 화면에 남아 있다(css:33). + +- `deviceCodeCopied: boolean` 상태와 인라인 `setTimeout` 제거 → `useCopyFeedback()`. +- 타이머·폴백·스코프는 전부 훅이 소유한다. +- 라벨: `idle` → `prov.copyCode`, `copied` → `prov.codeCopied`, + `unavailable` → `prov.linkCopyUnavailable` 재사용. + 새 i18n 키를 만들지 않는다. "클립보드를 사용할 수 없음"은 복사 대상이 + 링크든 코드든 같은 사실을 말하고, 6개 로케일 문안도 대상 중립적이다 + (en "Clipboard unavailable", ko "클립보드를 사용할 수 없음"). +- 라벨 span에 `aria-live="polite"`. URL 복사와 같은 고지 규약. + +`LoginUrlBlock`으로 흡수하지 않는다. 그 컴포넌트는 URL이라는 하나의 대상에 +묶인 URL 전문 + 외부 열기 링크를 함께 소유한다. 기기 코드는 외부 링크가 없고 +표시 형태(`.pwi-device-code`, 큰 자간 강조)도 다르다. 억지로 합치면 props가 +분기되어 소유의 이점이 사라진다. 공유하는 것은 **복사 규약**이고, 그 규약은 +위 훅이 소유한다 — 렌더가 아니라 동작을 공유하는 것이 이 분리의 요점이다. + +## i18n — ja 미번역 3건 (A 감사 #2) + +`gui/src/i18n/ja.ts:1310-1312`의 `prov.deviceCode` / `prov.copyCode` / +`prov.codeCopied`가 영어 그대로다. `sync-locale-keys.mjs`가 키를 채울 때 +영문을 붙여두고 파일 맨 아래에 남긴 것으로, `prov.*` 블록에서도 떨어져 있다. +이 사이클이 기기 코드 복사 라벨을 건드리므로 함께 번역한다. + +- `"prov.deviceCode": "デバイスコード"` +- `"prov.copyCode": "コードをコピー"` +- `"prov.codeCopied": "コードをコピーしました"` + +위치도 `prov.*` 인접 블록(`ja.ts:248` 근처)으로 옮겨 다른 로케일과 같은 배치로 +맞춘다. 어떤 게이트도 이걸 잡지 못한다 — `lint:i18n`은 `src/i18n/**`를 +globalIgnores로 빼고, `claude-desktop-locale.test.ts`는 키 **집합**만 보지 +번역 여부는 보지 않는다. 사람이 보지 않으면 영원히 영어로 남는다. + +`prov.linkCopyUnavailable`은 6개 로케일 전부 대상 중립적이라 +(en "Clipboard unavailable", ja "クリップボードを使用できません", +de "Zwischenablage nicht verfügbar") 코드 복사에 재사용해도 어색하지 않다. +감사가 6개 전부 확인했다. + +## 회귀 테스트 — `gui/tests/provider-auth-device-code-copy.test.tsx` + +`provider-auth-login-copy-link.test.tsx`의 하네스를 그대로 쓴다 +(`loginHint`에 `deviceCode`를 넣으면 같은 대기 패널이 렌더된다). + +마운트 조건(감사가 실측): `busy`가 참이고 `loginHint.provider === item.name`, +`item.authMode === "oauth"`. `url`은 선택이므로 `deviceCode`만 주면 기기 코드 +블록만 렌더된다. + +**하네스를 그대로 복사하면 안 된다(A 감사 blocker #4).** happy-dom에는 +`document.execCommand`가 없다(`typeof`가 `undefined`). 클립보드 스텁만 있는 +기존 하네스를 복사하면 폴백 케이스가 조용히 unavailable 경로를 타고 **틀린 +이유로 통과한다**. `gui/tests/clipboard-fallback.test.ts:20`처럼 +`Object.defineProperty(win.document, "execCommand", ...)` 헬퍼를 함께 둔다. + +1. 기기 코드 전문이 렌더되고 복사 버튼이 그 코드를 클립보드에 넣는다. +2. 클립보드 API 부재 + execCommand 스텁 → 폴백으로 복사되고 라벨이 + `prov.codeCopied`가 된다. +3. 둘 다 부재 → 라벨이 `prov.linkCopyUnavailable`이 된다. + **`expect(() => click()).not.toThrow()`로 쓰지 않는다** — React 19가 동기 + 예외를 dev 에러 경로로 흘려보내 happy-dom과 섞이면 단언이 불안정하다. + "라벨이 unavailable이 된다"는 긍정 단언이 오늘 도달 불가능하므로 그것으로 + 충분한 가드다. +4. 2.5초 창 안에서 재클릭해도 뒤 클릭 피드백이 제 수명을 다한다(훅 타이머). +5. doctor 복사도 같은 재클릭 회귀를 갖지 않는다 — 같은 계정·같은 결과로 + 연속 클릭해도 뒤 피드백이 살아남는다(훅 이관의 실질 이득). + +버튼은 `.pwi-device-code-wrap button`으로 구조 조회한다. + +## 검증 + +- `bun run typecheck` exit 0 (루트 — AGENTS.md 요구) +- `cd gui && bun x tsc -b` exit 0, `bun run lint:gui` exit 0 +- `cd gui && bun test tests` 전건 통과 +- `bun run test` (루트) 신규 실패 0 — `tests/provider-workspace-auth.test.ts:160`은 + `pwi-device-code` 부분 문자열 매치라 이 사이클이 바꾸는 것을 제약하지 않는다 + (감사 #5: 게이트가 아니라 무영향) +- `bun run privacy:scan` 통과 +- 변경을 되돌리면 신규 테스트 3·4·5번이 실패한다 diff --git a/devlog/_plan/260727_login_url_copy_parity/060_style_ownership.md b/devlog/_plan/260727_login_url_copy_parity/060_style_ownership.md new file mode 100644 index 0000000000..95280fbd81 --- /dev/null +++ b/devlog/_plan/260727_login_url_copy_parity/060_style_ownership.md @@ -0,0 +1,115 @@ +# 060 — 로그인 URL 블록 스타일의 소속 정리 (wp2) + +040의 "남은 항목" 둘째. + +## 문제 + +`LoginUrlBlock`은 세 표면이 공유하는 컴포넌트인데, 그 스타일은 이름도 파일도 +한 표면의 소유로 남아 있다. + +- 클래스: `.pwi-auth-url-wrap` / `.pwi-auth-url` / `.pwi-auth-url-actions` / + `.pwi-auth-open-link`. `pwi`는 provider **w**orkspace **i**tem이다. +- 파일: `gui/src/styles/provider-workspace-settings.css:36-39`. + 헤더 주석이 "WP091 — provider workspace settings / auth / JSON editor"이고 + `─ ProviderAuthPanel ─` 섹션 아래 있다. + +실제 소비처는 셋이다: 워크스페이스 패널, 프로바이더 추가 모달, Codex 계정 모달. +뒤 둘은 워크스페이스가 아니다. 지금은 `styles.css`가 모든 부분 스타일시트를 +전역 import(`styles.css:13`)해서 동작하지만, 이름이 소속을 속이고 있어 +다음 사람이 워크스페이스 CSS를 정리할 때 모달을 깨뜨릴 수 있다. + +## 컨벤션 근거 + +이 저장소는 이미 컴포넌트/기능 단위 스타일 파일 + 그 이름을 딴 접두사를 쓴다. +`gui/src/styles/provider-catalog.css`가 `.provider-catalog*`를 소유하는 식이다. +새 규칙을 만드는 게 아니라 기존 관행을 따른다. + +## 변경 1 — 신규 `gui/src/styles/login-url-block.css` + +`provider-workspace-settings.css`의 네 규칙(`.pwi-auth-open-link`가 `:36`, +나머지 셋이 `:37-39`)을 옮기고 재명명한다. + +| 이전 | 이후 | +|------|------| +| `.pwi-auth-url-wrap` | `.login-url-block` | +| `.pwi-auth-url` | `.login-url-block-text` | +| `.pwi-auth-url-actions` | `.login-url-block-actions` | +| `.pwi-auth-open-link` | `.login-url-block-open` | + +접두사를 컴포넌트/파일 이름과 정확히 일치시킨다(A 감사 #7). 초안의 `.login-url`은 +저장소 관행(`.provider-catalog*`, `.quota-*` 같은 기능 스코프 접두사)에 비해 너무 +일반적이라 나중에 충돌 여지를 남긴다. 지금 붙여두면 비용이 0이다. +현재 `gui/src`의 9개 CSS 파일 어디에도 `.login*` 셀렉터가 없음을 감사가 확인했다. + +속성값은 한 글자도 바꾸지 않는다. 이번 사이클은 소속 정리이지 시각 변경이 +아니다. 렌더 결과가 달라지면 그건 실패다. + +`.pwi-auth-open-link`는 `LoginUrlBlock` 밖에 소비처가 없다(rg로 확인). +따라서 함께 옮긴다. + +## 변경 2 — `gui/src/styles.css` + +`@import "./styles/login-url-block.css";`를 추가한다. 삽입 위치는 +`provider-workspace-settings.css` 다음 줄 — 기존 import 블록의 인접성을 유지한다. + +**import 순서는 결과에 영향이 없다(A 감사 #8).** 네 규칙은 전부 단일 클래스 +선택자(명시도 0-1-0)이고, `provider-workspace-settings.css`에 `@media`도 +`!important`도 없으며, 어떤 GUI 스타일시트도 같은 요소를 같은 명시도로 겨루지 +않는다. 경쟁 선택자가 없으므로 소스 순서가 캐스케이드를 바꿀 수 없다. +인접 배치는 가독성 때문이지 정확성 때문이 아니다. + +## 변경 3 — `gui/src/components/login-url-block.tsx` + +네 개의 `className`을 새 이름으로 교체. 구조·속성 변경 없음. + +## 변경 4 — 테스트 셀렉터 4파일 + +| 파일 | 셀렉터 | +|------|--------| +| `gui/tests/login-url-block.test.tsx:76,152` | `.pwi-auth-url-actions button`, `.pwi-auth-url-wrap` | +| `gui/tests/provider-auth-login-copy-link.test.tsx:104` | `.pwi-auth-url-actions button` | +| `gui/tests/add-codex-account-login-url.test.tsx:101` | `.pwi-auth-url-actions button` | +| `gui/tests/add-provider-oauth-url-leak.test.tsx:122,138,167` | `.pwi-auth-url` | + +**셀렉터만 바꾸고 단언은 한 글자도 바꾸지 않는다.** 이것이 이 사이클의 +안전 조건이다. 단언이 함께 바뀌면 "리네임이 렌더를 보존했다"는 증거가 사라진다. +커밋 diff에서 테스트 변경이 전부 셀렉터 문자열 한 줄짜리인지 눈으로 확인한다. + +여기에 단언 하나를 **추가**한다(A 감사 #9). `login-url-block.test.tsx`에 +렌더된 래퍼의 클래스가 `login-url-block`임을 단언한다. 리네임 자체에는 동작 +변화가 없어 테스트가 없으면 "소속을 정리했다"는 주장이 검증 불가능한 신념으로 +남는다. 이 한 줄이 그 주장을 집행 가능하게 만든다. + +## 건드리지 않는 것 + +- `.pwi-device-code*`: `ProviderAuthPanel` 전용이고 다른 표면에 없다. + 이름과 소속이 일치하므로 그대로 둔다. +- `.pwi-auth-wait` / `.pwi-auth-wait-copy` / `.pwi-auth-section` 등 나머지 + `pwi-` 계열 전부. 워크스페이스 패널 전용이다. +- `tests/provider-workspace-auth.test.ts:160`의 `pwi-device-code` 단언. + +즉 이 사이클은 `pwi-` 접두사를 일괄 개명하는 작업이 **아니다**. 소속이 +어긋난 네 개만 옮긴다. + +## 검증 + +- `bun run typecheck` exit 0 (루트) +- `cd gui && bun x tsc -b` exit 0, `bun run lint:gui` exit 0 +- `cd gui && bun test tests` 전건 통과 — 특히 위 4파일이 셀렉터 갱신만으로 통과 +- `rg "pwi-auth-url|pwi-auth-open-link" gui/` → 0건 +- `rg 'className="login-url' gui/src` → 컴포넌트 4건 (파일명 매치가 섞이는 + `rg "login-url-"`은 게이트로 쓰지 않는다 — 이미 import 3건이 걸린다, A 감사 #10) +- `rg "login-url-block" gui/src/styles/login-url-block.css` → CSS 4건 +- `bun run build:gui` 성공(CSS import 경로 확인) +- `bun run test` (루트) 신규 실패 0, `bun run privacy:scan` 통과 + +## 사이클 배치 (A 감사 #9) + +감사는 060을 050의 꼬리 커밋으로 접으라고 권했다 — 동작 변화가 없는 커밋 +하나만으로 별도 리뷰 라운드를 도는 건 값이 안 맞는다는 것이다. 절반만 받는다. + +**커밋은 분리하되 work-phase는 050과 같은 사이클에 두지 않는다.** 리네임은 +7개 파일의 셀렉터를 동시에 건드리므로, 기기 코드 복사 변경과 한 커밋에 섞이면 +gui 테스트가 깨졌을 때 원인이 훅 이관인지 셀렉터 교체인지 분리할 수 없다. +별도 work-phase(wp2) + 별도 커밋으로 두되, 리뷰는 wp3의 최종 검증에서 +한 번에 받는다. 감사의 "리뷰 라운드 하나" 취지는 지키고 이등분 가능성은 살린다. diff --git a/devlog/_plan/260727_login_url_copy_parity/070_review_p2_fixes.md b/devlog/_plan/260727_login_url_copy_parity/070_review_p2_fixes.md new file mode 100644 index 0000000000..36ea7c19cf --- /dev/null +++ b/devlog/_plan/260727_login_url_copy_parity/070_review_p2_fixes.md @@ -0,0 +1,114 @@ +# 070 — PR #544 Codex 리뷰 P2 2건 (wp1) + +`chatgpt-codex-connector`가 `171885ec29`에 남긴 인라인 지적 둘. 둘 다 +`useCopyFeedback` 도입으로 **새로 생긴** 결함이라 머지 전에 닫는다. + +## P2-1 — 기기 코드 복사가 스코프를 안 쓴다 + +`gui/src/components/provider-workspace/ProviderAuthPanel.tsx` + +```tsx +const deviceCodeCopy = useCopyFeedback(); // K = void +const deviceCodeOutcome = deviceCodeCopy.outcomeFor(undefined); +onClick={() => deviceCodeCopy.copy(hintForThis.deviceCode ?? "", undefined)} +``` + +훅은 "피드백이 자기 스코프를 들고 다녀 스코프가 바뀌면 idle로 읽힌다"는 +계약으로 만들었는데, 세 소비처 중 여기만 그 계약을 쓰지 않는다. URL은 +`url`을, doctor는 `account.id`를 스코프로 넘긴다. 기기 코드만 `undefined`다. + +### 재현 + +1. 기기 코드 A가 뜬 상태에서 복사 → 라벨이 "코드 복사됨". +2. 2.5초 안에 취소를 누른다. `cancelLoginOAuth`가 + `setLoginInfo(current => current?.provider === provider ? null : current)` + 로 힌트만 비운다(`use-providers-oauth.ts:53`). **패널은 언마운트되지 않는다** — + `busy && hintForThis` 블록만 사라진다. +3. 다시 로그인하면 `loginOAuth`가 새 코드 B로 `setLoginInfo`한다(`:84`). + 같은 컴포넌트 인스턴스, 같은 `undefined` 스코프. +4. 코드 B 위에 A의 "복사됨"이 남는다. 클립보드에는 A가 들어 있다. + +`Providers.tsx:210`의 `key={item.name}`은 프로바이더가 바뀔 때만 리마운트하므로 +같은 프로바이더 재로그인에서는 보호가 없다. 이건 `LoginUrlBlock`이 이미 +막아둔 것과 **동일한 거짓 성공**이다. + +### 수정 + +```tsx +const deviceCodeCopy = useCopyFeedback(); +const deviceCode = hintForThis?.deviceCode ?? ""; +const deviceCodeOutcome = deviceCodeCopy.outcomeFor(deviceCode); +onClick={() => deviceCodeCopy.copy(deviceCode, deviceCode)} +``` + +복사 대상 문자열이 곧 스코프다 — URL 복사와 같은 형태(`copy(url, url)`). + +## P2-2 — 겹친 복사에서 오래된 완료가 최신 결과를 덮어쓴다 + +`gui/src/components/use-copy-feedback.ts:38-47` + +```ts +void copyTextToClipboard(text).then((ok) => { + clearTimer(); + setFeedback({ scope, outcome: ok ? "copied" : "unavailable" }); + timerRef.current = setTimeout(...); +}); +``` + +`copyTextToClipboard`는 async다. `navigator.clipboard.writeText`가 권한 +프롬프트나 포커스 대기로 지연되면 첫 시도가 둘째보다 늦게 resolve될 수 있다. +그때 첫 시도의 `.then`이 무조건 상태와 타이머를 덮어쓴다. + +- 스코프가 다르면(예: 코드 A→B) 현재 버튼이 남의 스코프 피드백을 받아 idle이 된다. +- 스코프가 같으면 오래된 결과를 최신인 양 보고한다(A 실패→B 성공이면 "사용 불가"). +- 타이머도 늦은 쪽이 다시 걸어 피드백 수명이 어긋난다. + +`clearTimer`는 이 경합을 못 막는다. 순서 문제이지 타이머 문제가 아니다. + +### 수정 + +요청 세대 카운터를 둔다. + +```ts +const generationRef = useRef(0); + +const copy = useCallback((text: string, scope: Scope) => { + const generation = ++generationRef.current; + void copyTextToClipboard(text).then((ok) => { + if (generationRef.current !== generation) return; // 스테일 완료는 버린다 + clearTimer(); + setFeedback({ scope, outcome: ok ? "copied" : "unavailable" }); + timerRef.current = setTimeout(() => { ... }, FEEDBACK_MS); + }); +}, [clearTimer]); +``` + +만료 타이머도 자기 세대를 확인하게 해, 늦게 도착한 클릭이 앞 타이머의 +만료로 지워지지 않도록 한다. 이 저장소의 다른 경합 가드와 같은 방식이다 +(`use-providers-oauth.ts`의 `oauthLoginGenerationRef`, +`use-add-codex-account-oauth.ts`의 `pollSession`). + +## 회귀 테스트 + +### `gui/tests/use-copy-feedback-race.test.tsx` (신규) + +클립보드 `writeText`를 테스트가 붙잡았다 놓는 스텁으로 순서를 고정한다. + +1. **늦게 끝난 오래된 시도는 무시된다.** A를 클릭(보류) → B를 클릭(즉시 성공) + → A를 성공으로 해제. 라벨은 B의 결과를 유지한다. +2. **결과가 갈려도 마찬가지.** A는 실패, B는 성공으로 두고 A를 나중에 해제해도 + "복사됨"이 남는다. +3. **정상 순서는 그대로 동작한다.** 겹치지 않으면 마지막 클릭 결과가 뜬다. + +### `gui/tests/provider-auth-device-code-copy.test.tsx` (기존에 추가) + +1. **기기 코드가 바뀌면 라벨이 초기화된다.** 코드 A 복사 → 같은 패널에 + 코드 B로 리렌더 → 라벨이 `prov.copyCode`로 돌아온다. + +## 검증 + +- `bun run typecheck` exit 0, `cd gui && bun x tsc -b` exit 0 +- `cd gui && bun run lint` / `lint:i18n` exit 0 +- `cd gui && bun test tests` 전건 통과 +- `bun run test` (루트) 신규 실패 0, `bun run privacy:scan` 통과 +- 스코프를 `undefined`로 되돌리면 기기 코드 테스트가, 세대 가드를 지우면 경합 테스트가 실패한다 diff --git a/gui/src/components/AddCodexAccountModal.tsx b/gui/src/components/AddCodexAccountModal.tsx index 969ad82e65..0217dd3af0 100644 --- a/gui/src/components/AddCodexAccountModal.tsx +++ b/gui/src/components/AddCodexAccountModal.tsx @@ -22,7 +22,7 @@ export default function AddCodexAccountModal({ const dialogRef = useRef(null); const oauth = useAddCodexAccountOAuth({ apiBase, reauthAccountId, ui, dispatch, t }); - const { manualCodeBusy, manualCodeWaiting, bindCallbacks, closeModal, startOAuth, copyLoginLink, submitManualCode } = oauth; + const { manualCodeBusy, manualCodeWaiting, bindCallbacks, closeModal, startOAuth, submitManualCode } = oauth; useEffect(() => { bindCallbacks(onAdded, onClose); @@ -69,7 +69,6 @@ export default function AddCodexAccountModal({ { void copyLoginLink(); }} onManualCodeChange={value => dispatch({ type: "set-manual-code", manualCode: value })} onSubmitManualCode={() => { void submitManualCode(); }} onClose={closeModal} diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index 9e0027e36c..9031cb139f 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -15,7 +15,8 @@ import { CodexAccountResetModal } from "./codex-account-reset-modal"; import { CodexAccountPoolLoadStates, CodexAccountPoolMainCard, CodexAccountPoolPageHead } from "./codex-account-pool-main-card"; import { redeemResetCredit } from "./codex-account-pool-handlers"; import type { CodexAccountEntry } from "./codex-account-pool-types"; -import { accountNeedsReauth, copyTextToClipboard, type DoctorCopyFeedback } from "../oauth-health-display"; +import { accountNeedsReauth } from "../oauth-health-display"; +import { useCopyFeedback } from "./use-copy-feedback"; // Single definition lives with the controller that owns this data (WP3). export type { CodexAccountEntry } from "../hooks/useCodexAccountPool"; @@ -65,20 +66,11 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const [redeeming, setRedeeming] = useState(false); const [creditDetails, setCreditDetails] = useState<{ granted_at: string; expires_at: string }[] | null>(null); const [creditDetailsLoading, setCreditDetailsLoading] = useState(false); - const [copiedDoctorFor, setCopiedDoctorFor] = useState(null); + const doctorCopy = useCopyFeedback(); const copyDoctor = useCallback((accountId: string) => { - void copyTextToClipboard(DOCTOR_CMD).then((ok) => { - const feedback: DoctorCopyFeedback = { - accountId, - outcome: ok ? "copied" : "unavailable", - }; - setCopiedDoctorFor(feedback); - setTimeout(() => setCopiedDoctorFor(current => ( - current?.accountId === accountId && current.outcome === feedback.outcome ? null : current - )), 2500); - }); - }, []); + doctorCopy.copy(DOCTOR_CMD, accountId); + }, [doctorCopy]); // The controller owns loading and polling. This surface only feeds the auto-switch // threshold observer and leases a pause while an OAuth modal is open. @@ -256,7 +248,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban onSwitch={setConfirm} onOpenReset={openResetPopup} onCopyDoctor={copyDoctor} - copiedDoctorFor={copiedDoctorFor} + doctorCopyOutcomeFor={doctorCopy.outcomeFor} />

@@ -285,7 +277,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban onEditAlias={editAlias} onRemove={remove} onCopyDoctor={copyDoctor} - copiedDoctorFor={copiedDoctorFor} + doctorCopyOutcomeFor={doctorCopy.outcomeFor} /> void; onManualCodeChange: (value: string) => void; onSubmitManualCode: () => void; onClose: () => void; @@ -39,9 +35,7 @@ export function AddCodexAccountWaitingStep({ <>

{reauthAccountId ? t("codexAuth.reauthenticate") : t("codexAuth.oauthLogin")}

{t("codexAuth.oauthWaiting")}

- +
{t("prov.pasteRedirectHint")}
diff --git a/gui/src/components/add-provider-oauth-pane.tsx b/gui/src/components/add-provider-oauth-pane.tsx index 7f849ff015..4003ca0177 100644 --- a/gui/src/components/add-provider-oauth-pane.tsx +++ b/gui/src/components/add-provider-oauth-pane.tsx @@ -1,7 +1,6 @@ -import { useEffect, useRef, useState } from "react"; -import { IconExternal, IconLink, IconLock } from "../icons"; +import { IconLock } from "../icons"; import { useT } from "../i18n/shared"; -import { copyTextToClipboard } from "../oauth-health-display"; +import { LoginUrlBlock } from "./login-url-block"; import type { CatalogPreset } from "./provider-catalog/provider-presets"; export function AddProviderOAuthPane({ @@ -38,21 +37,6 @@ export function AddProviderOAuthPane({ onBack: () => void; }) { const t = useT(); - const [linkCopyState, setLinkCopyState] = useState<"idle" | "copied" | "unavailable">("idle"); - const linkCopyTimer = useRef | null>(null); - - useEffect(() => () => { if (linkCopyTimer.current) clearTimeout(linkCopyTimer.current); }, []); - - const copyAuthUrl = () => { - void copyTextToClipboard(oauthUrl).then((ok) => { - setLinkCopyState(ok ? "copied" : "unavailable"); - if (linkCopyTimer.current) clearTimeout(linkCopyTimer.current); - linkCopyTimer.current = setTimeout(() => { - linkCopyTimer.current = null; - setLinkCopyState("idle"); - }, 2500); - }); - }; return (
@@ -72,26 +56,7 @@ export function AddProviderOAuthPane({ {oauthMsg}
)} - {oauthBusy && oauthUrl && ( -
- {oauthUrl} -
- - - -
-
- )} + {oauthBusy && } {oauthBusy && (
diff --git a/gui/src/components/codex-account-pool-cards.tsx b/gui/src/components/codex-account-pool-cards.tsx index 0bd2ae258f..478b0e8a21 100644 --- a/gui/src/components/codex-account-pool-cards.tsx +++ b/gui/src/components/codex-account-pool-cards.tsx @@ -13,7 +13,6 @@ import { oauthHealthIsCooldown, oauthHealthShowsDoctor, oauthHealthShowsReauth, - type DoctorCopyFeedback, } from "../oauth-health-display"; export function CodexAccountPoolCards({ @@ -28,7 +27,7 @@ export function CodexAccountPoolCards({ onEditAlias, onRemove, onCopyDoctor, - copiedDoctorFor, + doctorCopyOutcomeFor, }: { pool: CodexAccountEntry[]; activeId: string | null; @@ -41,7 +40,7 @@ export function CodexAccountPoolCards({ onEditAlias: (account: CodexAccountEntry) => void; onRemove: (id: string) => void; onCopyDoctor?: (accountId: string) => void; - copiedDoctorFor?: DoctorCopyFeedback | null; + doctorCopyOutcomeFor?: (accountId: string) => "copied" | "unavailable" | null; }) { const t = useT(); const isNext = (id: string) => activeId === id; @@ -84,7 +83,7 @@ export function CodexAccountPoolCards({ )} {onCopyDoctor && oauthHealthShowsDoctor(healthStatus) && ( )} )} {t("codexAuth.appLogin")} diff --git a/gui/src/components/login-url-block.tsx b/gui/src/components/login-url-block.tsx new file mode 100644 index 0000000000..db21896302 --- /dev/null +++ b/gui/src/components/login-url-block.tsx @@ -0,0 +1,40 @@ +import { IconExternal, IconLink } from "../icons"; +import { useT } from "../i18n/shared"; +import { useCopyFeedback } from "./use-copy-feedback"; + +/** + * Recovery affordance for an OAuth waiting state: the proxy already tried to + * open the browser server-side, so this block only matters once that failed. + * It exposes the authorization URL as selectable text, copies it, and offers a + * manual open — the single owner for all three login surfaces (workspace panel, + * add-provider modal, Codex account modal). + */ +export function LoginUrlBlock({ url }: { url: string }) { + const t = useT(); + const { outcomeFor, copy } = useCopyFeedback(); + + if (!url) return null; + + const outcome = outcomeFor(url); + + const label = outcome === "copied" + ? t("prov.linkCopied") + : outcome === "unavailable" + ? t("prov.linkCopyUnavailable") + : t("prov.copyLink"); + + return ( +
+ {url} +
+ + + +
+
+ ); +} diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index 3c24f95254..efa61b33e0 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -3,14 +3,13 @@ * embedding for the workspace Settings tab (WP091). Consumes WP040+WP060 * handlers via props-down; no internal auth machinery. */ -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; import { useT } from "../../i18n/shared"; -import { IconLock, IconExternal, IconLink, IconTrash } from "../../icons"; +import { IconLock, IconTrash } from "../../icons"; import type { WorkspaceItem } from "../../provider-workspace/catalog"; import { oauthAccountDisplayLabel, providerAuthSurface } from "../../provider-workspace/auth"; import { displayAccountId } from "../../lib/privacy"; import { - copyTextToClipboard, doctorCopyButtonLabel, formatOAuthHealthLabel, formatOAuthHealthSummary, @@ -18,9 +17,10 @@ import { oauthHealthIsCooldown, oauthHealthShowsDoctor, oauthHealthShowsReauth, - type DoctorCopyFeedback, } from "../../oauth-health-display"; import CodexAccountPool from "../CodexAccountPool"; +import { LoginUrlBlock } from "../login-url-block"; +import { useCopyFeedback } from "../use-copy-feedback"; import type { CodexAccountPoolController } from "../../hooks/useCodexAccountPool"; import type { AccountLoadState, OAuthAccountRow, ApiKeyRow, LoginHint, ProviderAuthHandlers } from "./types"; @@ -49,23 +49,8 @@ export default function ProviderAuthPanel({ const [addingKey, setAddingKey] = useState(false); const [newKey, setNewKey] = useState(""); const [keyBusy, setKeyBusy] = useState(false); - const [deviceCodeCopied, setDeviceCodeCopied] = useState(false); - const [linkCopyState, setLinkCopyState] = useState<"idle" | "copied" | "unavailable">("idle"); - const linkCopyTimer = useRef | null>(null); - - useEffect(() => () => { if (linkCopyTimer.current) clearTimeout(linkCopyTimer.current); }, []); - - const copyLoginUrl = (url: string) => { - void copyTextToClipboard(url).then((ok) => { - setLinkCopyState(ok ? "copied" : "unavailable"); - if (linkCopyTimer.current) clearTimeout(linkCopyTimer.current); - linkCopyTimer.current = setTimeout(() => { - linkCopyTimer.current = null; - setLinkCopyState("idle"); - }, 2500); - }); - }; - const [copiedDoctorFor, setCopiedDoctorFor] = useState(null); + const deviceCodeCopy = useCopyFeedback(); + const doctorCopy = useCopyFeedback(); const surface = providerAuthSurface({ ...item, hasApiKey: item.hasApiKey || keys.length > 0 }); const isOauth = surface === "oauth-accounts"; @@ -90,6 +75,13 @@ export default function ProviderAuthPanel({ if (!surface || !authHandlers) return null; const hintForThis = loginHint?.provider === item.name ? loginHint : null; + const deviceCode = hintForThis?.deviceCode ?? ""; + const deviceCodeOutcome = deviceCodeCopy.outcomeFor(deviceCode); + const deviceCodeCopyLabel = deviceCodeOutcome === "copied" + ? t("prov.codeCopied") + : deviceCodeOutcome === "unavailable" + ? t("prov.linkCopyUnavailable") + : t("prov.copyCode"); const loggedIn = accounts.length > 0 || oauth?.loggedIn === true; const activeReauthAccount = accounts.find(a => a.active && a.needsReauth); const activeNeedsReauth = Boolean(activeReauthAccount); @@ -144,35 +136,13 @@ export default function ProviderAuthPanel({
{t("prov.deviceCode")} {hintForThis.deviceCode} - -
- )} - {hintForThis.url && ( -
- {hintForThis.url} -
- - - {t("prov.didntOpen")} - -
+
)} + {authHandlers.onCancelLogin && ( )}
+ ); +} + +beforeEach(() => { + previous = Object.fromEntries(globals.map((k) => [k, Reflect.get(globalThis, k)])) as typeof previous; + win = new Window({ url: "http://localhost/" }); + Object.defineProperty(win.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, + window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + installDeferredClipboard(); + // No legacy path: a rejected write must not silently succeed through execCommand. + Object.defineProperty(win.document, "execCommand", { configurable: true, value: undefined }); + host = win.document.createElement("div") as unknown as HTMLElement; + win.document.body.appendChild(host as never); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + } + await win.happyDOM?.close?.(); +}); + +async function mount() { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render(); + }); +} + +function text(id: string): string { + return host.querySelector(`[data-testid="${id}"]`)?.textContent ?? ""; +} + +async function click(id: string) { + await act(async () => { + host.querySelector(`[data-testid="${id}"]`) + ?.dispatchEvent(new win.MouseEvent("click", { bubbles: true })); + await new Promise((r) => setTimeout(r, 0)); + }); +} + +async function settle(index: number, ok: boolean) { + await act(async () => { + pending[index]?.settle(ok); + await new Promise((r) => setTimeout(r, 0)); + }); +} + +test("a stale completion never overwrites the newer click", async () => { + await mount(); + + await click("copy-a"); + await click("copy-b"); + expect(pending.map((p) => p.text)).toEqual(["a", "b"]); + + await settle(1, true); // the newer click lands first + expect(text("b")).toBe("copied"); + + await settle(0, true); // the older one arrives late + expect(text("b")).toBe("copied"); + expect(text("a")).toBe("idle"); +}); + +test("a late failure does not downgrade a newer success", async () => { + await mount(); + + await click("copy-a"); + await click("copy-b"); + + await settle(1, true); + await settle(0, false); // A was denied, but B already succeeded + + expect(text("b")).toBe("copied"); + expect(text("a")).toBe("idle"); +}); + +test("a stale timer cannot expire the newer feedback", async () => { + await mount(); + + await click("copy-a"); + await click("copy-b"); + + await settle(1, true); + await settle(0, true); + + // Past the stale attempt's would-be expiry, inside the current one's window. + await act(async () => { await new Promise((r) => setTimeout(r, 2400)); }); + expect(text("b")).toBe("copied"); +}); + +test("sequential copies still report the latest result", async () => { + await mount(); + + await click("copy-a"); + await settle(0, true); + expect(text("a")).toBe("copied"); + + await click("copy-b"); + await settle(1, false); + expect(text("b")).toBe("unavailable"); + expect(text("a")).toBe("idle"); +});