diff --git a/apps/api/app/test_auth_providers.py b/apps/api/app/test_auth_providers.py
index 672056e..6472cea 100644
--- a/apps/api/app/test_auth_providers.py
+++ b/apps/api/app/test_auth_providers.py
@@ -1094,6 +1094,8 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(query["client_id"], ["google-client"])
self.assertEqual(query["redirect_uri"], ["https://api-vignette.test/auth/callback"])
self.assertEqual(query["response_type"], ["code"])
+ self.assertEqual(query["prompt"], ["select_account"])
+ self.assertNotIn("hd", query)
self.assertEqual(query["code_challenge_method"], ["S256"])
self.assertEqual(
query["code_challenge"],
diff --git a/apps/web/e2e/auth.spec.ts b/apps/web/e2e/auth.spec.ts
index 28cf521..6663b5b 100644
--- a/apps/web/e2e/auth.spec.ts
+++ b/apps/web/e2e/auth.spec.ts
@@ -67,13 +67,17 @@ test.describe("auth domain policy", () => {
await page.goto("/login");
const googleButtons = page.locator(".lg-obtn");
- await expect(googleButtons).toHaveCount(2);
+ await expect(googleButtons).toHaveCount(1);
+ await expect(page.getByRole("button", { name: /^Google 계정으로 로그인/ })).toHaveCount(1);
+ await expect(page.getByRole("button", { name: /다른 Google 계정 선택/ })).toHaveCount(0);
expect(config.allowed_email_domains).toEqual([]);
- await expect(page.locator(".lg-policy b")).toHaveText("모든 Google 계정");
const redirectHost = new URL(config.redirect_uri).hostname;
const devOAuthUnavailable =
config.dev_login_enabled &&
!isLocalHostname(redirectHost);
+ await expect(page.locator(".lg-policy b")).toHaveText(
+ config.google_oauth_configured && !devOAuthUnavailable ? "모든 Google 계정" : "설정 필요",
+ );
if (config.dev_login_enabled) {
await expect(page.locator(".lg-dev")).toBeVisible();
} else {
@@ -85,7 +89,6 @@ test.describe("auth domain policy", () => {
await expect(page.locator(".lg-config")).toHaveCount(0);
} else {
await expect(googleButtons.first()).toBeDisabled();
- await expect(googleButtons.nth(1)).toBeDisabled();
await expect(page.locator(".lg-config")).toBeVisible();
await expect(page).toHaveURL(/\/login$/);
await expect(page.locator("body")).not.toContainText("Google OAuth is not configured");
@@ -239,7 +242,9 @@ test.describe("auth domain policy", () => {
await page.goto(`${publicBase}/login`, { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => undefined);
- await expect(page.locator(".lg-obtn")).toHaveCount(2);
+ await expect(page.locator(".lg-obtn")).toHaveCount(1);
+ await expect(page.getByRole("button", { name: /^Google 계정으로 로그인/ })).toHaveCount(1);
+ await expect(page.getByRole("button", { name: /다른 Google 계정 선택/ })).toHaveCount(0);
await expect(page.locator(".lg-dev")).toHaveCount(0);
await expect(page.getByText("로컬 테스트")).toHaveCount(0);
await expect(page.getByText("로컬 테스트 계정으로 계속")).toHaveCount(0);
diff --git a/apps/web/e2e/full-sweep-auth.spec.ts b/apps/web/e2e/full-sweep-auth.spec.ts
index d121841..a06fffe 100644
--- a/apps/web/e2e/full-sweep-auth.spec.ts
+++ b/apps/web/e2e/full-sweep-auth.spec.ts
@@ -24,7 +24,7 @@ import { completeOnboarding, expectNoHorizontalOverflow, useRealApi } from "./su
const CONFIG_PATTERN = "**/api/auth/config";
const ME_PATTERN = "**/api/auth/me";
-/** 로컬 redirect_uri라서 두 OAuth 버튼이 모두 활성화되는 구성 fixture. */
+/** 로컬 redirect_uri라서 단일 Google OAuth 버튼이 활성화되는 구성 fixture. */
const READY_AUTH_CONFIG = {
google_oauth_configured: true,
saml_configured: false,
@@ -153,7 +153,7 @@ test.describe("full sweep auth", () => {
});
// 검증 checklist: auth-login-generic-google-button
- test("both Google buttons advertise unrestricted accounts and start the same OAuth flow", async ({
+ test("single Google button advertises unrestricted accounts and starts the account chooser flow", async ({
page,
}) => {
await mockUnauthenticatedMe(page);
@@ -173,16 +173,16 @@ test.describe("full sweep auth", () => {
});
await expectNoHorizontalOverflow(page);
- const secondary = page.locator(".lg-obtn.secondary");
- await expect(secondary).toBeEnabled();
- await expect(secondary).toContainText("다른 Google 계정 선택");
- await expect(secondary.locator(".sub")).toHaveText("이메일 확인 후 바로 시작");
- await expect(page.locator(".lg-obtn.primary .sub")).toHaveText("모든 Google 계정");
+ const googleLogin = page.getByRole("button", { name: /^Google 계정으로 로그인/ });
+ await expect(googleLogin).toHaveCount(1);
+ await expect(googleLogin).toBeEnabled();
+ await expect(googleLogin.locator(".sub")).toHaveText("모든 Google 계정");
+ await expect(page.getByRole("button", { name: /다른 Google 계정 선택/ })).toHaveCount(0);
- // 클릭 시 primary와 동일한 provider=google 시작 URL로 이동한다.
+ // 단일 CTA가 provider=google OAuth 시작 URL로 이동한다.
await Promise.all([
page.waitForURL(/\/api\/auth\/login\?provider=google&next=%2F/),
- secondary.click(),
+ googleLogin.click(),
]);
});
diff --git a/apps/web/e2e/full-sweep-shell.spec.ts b/apps/web/e2e/full-sweep-shell.spec.ts
index 7fac8dc..e8ecef7 100644
--- a/apps/web/e2e/full-sweep-shell.spec.ts
+++ b/apps/web/e2e/full-sweep-shell.spec.ts
@@ -81,7 +81,7 @@ test.describe("full sweep — shared shell, GNB, and routing guards", () => {
await page.getByRole("button", { name: "로그아웃" }).click();
await expect(page).toHaveURL(/\/login$/);
- await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible();
+ await expect(page.getByRole("button", { name: /Google 계정으로 로그인/ })).toBeVisible();
// 서버 세션도 무효화되었는지 실 API로 확인
const me = await page.request.get("/api/auth/me");
@@ -112,7 +112,7 @@ test.describe("full sweep — shared shell, GNB, and routing guards", () => {
await page.getByRole("button", { name: "로그아웃" }).click();
await expect(page).toHaveURL(/\/login$/);
- await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible();
+ await expect(page.getByRole("button", { name: /Google 계정으로 로그인/ })).toBeVisible();
expect(logoutCalled).toBeTruthy();
});
@@ -239,7 +239,7 @@ test.describe("full sweep — shared shell, GNB, and routing guards", () => {
releaseMe();
await expect(page).toHaveURL(/\/login$/);
- await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible();
+ await expect(page.getByRole("button", { name: /Google 계정으로 로그인/ })).toBeVisible();
});
// checklist: shell-suspense-fallback
@@ -269,7 +269,7 @@ test.describe("full sweep — shared shell, GNB, and routing guards", () => {
releaseChunk();
- await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible({
+ await expect(page.getByRole("button", { name: /Google 계정으로 로그인/ })).toBeVisible({
timeout: 10_000,
});
await expect(page.getByText(BOOT_SCREEN_TEXT)).toHaveCount(0);
@@ -287,7 +287,7 @@ test.describe("full sweep — shared shell, GNB, and routing guards", () => {
});
await page.goto("/login");
- await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible();
+ await expect(page.getByRole("button", { name: /Google 계정으로 로그인/ })).toBeVisible();
expect(documentRequests).toBe(1);
const firstPrevented = await page.evaluate(() => {
@@ -300,7 +300,7 @@ test.describe("full sweep — shared shell, GNB, and routing guards", () => {
expect(firstPrevented).toBeTruthy();
await expect.poll(() => documentRequests).toBe(2);
- await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible();
+ await expect(page.getByRole("button", { name: /Google 계정으로 로그인/ })).toBeVisible();
await expect
.poll(() =>
page.evaluate(() => sessionStorage.getItem("vignette:chunk-recovery:/login")),
@@ -358,7 +358,7 @@ test.describe("full sweep — shared shell, GNB, and routing guards", () => {
test("redirects unknown routes to the role-aware root destination", async ({ page }) => {
await page.goto("/definitely/not-a-route");
await expect(page).toHaveURL(/\/login$/);
- await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible();
+ await expect(page.getByRole("button", { name: /Google 계정으로 로그인/ })).toBeVisible();
await signInAsLearner(page);
await page.goto("/no-such-page");
diff --git a/apps/web/e2e/learner.spec.ts b/apps/web/e2e/learner.spec.ts
index 24cb17c..b33805d 100644
--- a/apps/web/e2e/learner.spec.ts
+++ b/apps/web/e2e/learner.spec.ts
@@ -144,7 +144,7 @@ test.describe("learner app shell and session launcher", () => {
await page.goto("/learn/practice");
await expect(page).toHaveURL(/\/login$/);
- await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible();
+ await expect(page.getByRole("button", { name: /Google 계정으로 로그인/ })).toBeVisible();
expect(await page.evaluate(() => window.localStorage.getItem("vignette.dev-auth"))).toBeNull();
});
@@ -182,7 +182,7 @@ test.describe("learner app shell and session launcher", () => {
await page.goto("/learn/practice");
await expect(page).toHaveURL(/\/login$/);
- await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible();
+ await expect(page.getByRole("button", { name: /Google 계정으로 로그인/ })).toBeVisible();
await expect(page.getByText("내담자 목록을 불러오지 못했습니다.")).toHaveCount(0);
});
diff --git a/apps/web/e2e/uc-a11y-mobile.spec.ts b/apps/web/e2e/uc-a11y-mobile.spec.ts
index de25ad0..00efbd2 100644
--- a/apps/web/e2e/uc-a11y-mobile.spec.ts
+++ b/apps/web/e2e/uc-a11y-mobile.spec.ts
@@ -905,7 +905,7 @@ async function openLogin(page: Page) {
await routeLoginScreen(page);
await page.goto("/login");
await expect(page.locator(".lg-root, .lg-enter").first()).toBeVisible({ timeout: 15_000 });
- await expect(page.getByRole("button", { name: /^Google 계정으로 계속/ })).toBeVisible();
+ await expect(page.getByRole("button", { name: /^Google 계정으로 로그인/ })).toBeVisible();
}
async function openLearnerHome(page: Page) {
@@ -970,16 +970,41 @@ test.describe("uc a11y-mobile — 주요 6화면 접근성·모바일 감사", (
// usecase: 키보드 사용자가 Tab 으로 로그인 버튼에 도달하고 포커스 위치를 눈으로 확인한다
test("로그인 화면에서 Tab 순회 시 포커스 표시가 눈에 보인다", async ({ page }) => {
await openLogin(page);
- await expectFocusVisibleOnTab(page, "login", 5);
+ const googleLogin = page.getByRole("button", { name: /^Google 계정으로 로그인/ });
+ await expect(googleLogin).toHaveCount(1);
+ await page.keyboard.press("Tab");
+ await expect(googleLogin).toBeFocused();
+ const focusReport = await googleLogin.evaluate((element) => {
+ const target = element as HTMLElement;
+ const snapshot = () => {
+ const style = window.getComputedStyle(target);
+ return [
+ style.outlineStyle,
+ style.outlineWidth,
+ style.outlineColor,
+ style.boxShadow,
+ style.borderColor,
+ style.backgroundColor,
+ ].join("|");
+ };
+ const focusVisible = target.matches(":focus-visible");
+ const focusedStyle = snapshot();
+ target.blur();
+ const blurredStyle = snapshot();
+ target.focus();
+ return { focusVisible, styleChanged: focusedStyle !== blurredStyle };
+ });
+ expect(focusReport.focusVisible).toBeTruthy();
+ expect(focusReport.styleChanged).toBeTruthy();
});
// usecase: 저시력 사용자가 주 로그인 버튼의 문구를 읽는다
- test("로그인 주 CTA(Google 계정으로 계속)의 색 대비가 4.5:1 이상이다", async ({ page }) => {
+ test("로그인 주 CTA(Google 계정으로 로그인)의 색 대비가 4.5:1 이상이다", async ({ page }) => {
await openLogin(page);
await expectCtaContrast(
- page.getByRole("button", { name: /^Google 계정으로 계속/ }),
+ page.getByRole("button", { name: /^Google 계정으로 로그인/ }),
"login",
- "Google 계정으로 계속",
+ "Google 계정으로 로그인",
);
});
diff --git a/apps/web/e2e/uc-error-resilience-shell.spec.ts b/apps/web/e2e/uc-error-resilience-shell.spec.ts
index 9faf98f..260272a 100644
--- a/apps/web/e2e/uc-error-resilience-shell.spec.ts
+++ b/apps/web/e2e/uc-error-resilience-shell.spec.ts
@@ -500,7 +500,7 @@ test.describe("uc error-resilience-shell", () => {
await expect(page).toHaveURL(/\/login$/);
await expect(
- page.getByRole("button", { name: /Google 계정으로 계속/ }),
+ page.getByRole("button", { name: /Google 계정으로 로그인/ }),
).toBeVisible();
});
diff --git a/apps/web/src/pages/Login.tsx b/apps/web/src/pages/Login.tsx
index f839aee..a6a35a9 100644
--- a/apps/web/src/pages/Login.tsx
+++ b/apps/web/src/pages/Login.tsx
@@ -131,16 +131,11 @@ export default function Login() {
!isLocalRedirectUri(authConfig.redirect_uri);
const oauthReady =
authConfig?.google_oauth_configured === true && !devOAuthUnavailable;
- const primaryAccountLabel = devOAuthUnavailable
+ const accountLabel = devOAuthUnavailable
? "로컬은 테스트 계정 사용"
: oauthChecking
? "도메인 확인 중"
: "모든 Google 계정";
- const secondaryAccountLabel = devOAuthUnavailable
- ? "공개 주소에서 사용"
- : oauthChecking
- ? "계정 정책 확인 중"
- : "이메일 확인 후 바로 시작";
const oauthStatusMessage = devOAuthUnavailable
? LOCAL_OAUTH_UNAVAILABLE_MESSAGE
: oauthChecking
@@ -182,8 +177,7 @@ export default function Login() {
212차 핫픽스(2026-08-18 오후): 공개 POST /sessions/{id}/end 503(소유자 실사용 보고)의 원인은 회기 종료의 pinned_fact upsert가 learner 역할 연결로 실행된 것이었다. app.pinned_fact RLS는 learner에 INSERT(소유 케이스)만 허용하고 SELECT/UPDATE는 AI 컨텍스트·admin/instructor 전용이라 SELECT … FOR UPDATE + INSERT … ON CONFLICT DO UPDATE 조합이 충돌 후보 가시성에서 InsufficientPrivilegeError로 실패하고 fallback 차단이 503으로 노출됐다. 공개 DB 롤백 재현으로 단순 INSERT는 통과·ON CONFLICT 조합은 거부·ai_view=evaluator 컨텍스트는 전체 upsert 통과를 실증했고(bf5f7352), pinned_fact 저장만 같은 learner uid의 AI evaluator 뷰 연결로 분리했다. 검증: 관련 62 passed·app 924 passed, 공개 8001 승격 후 health ok. 엔진이 건강해 deep 평가가 처음 팩트를 반환한 순간 발화한 잠복 결함으로, 아침 degraded 구간엔 팩트가 비어 이 경로가 실행되지 않았다.
213차 복구·전환(2026-08-18 심야): 대청소 사건(node_modules·구 런타임 워크트리·Temp 소실)과 Docker Desktop 사망·whisper 9882 WinNAT 포트 예약·DHCP LAN IP 회수(223→224)가 연쇄 발생했다. 릴레이를 현행 루트 bf5f7352에 같은 4종 핀으로 재기동(r3 영수증)하고 192.168.0.224:9100으로 바인딩(방화벽 규칙 재발행), NAS는 실행 컨테이너 env 전량을 .env(0600)로 재구성해 ENGINE_URL 224 적용 후 api만 재생성했다. 클로드 예산 한도(Reached maximum budget $0.5)가 재발해 NAS ENGINE_MODE를 agy_cli(gemini)로 전환 — 공개는 소유자가 이미 관리자 UI로 agy 전환 완료. 결과: NAS origin health 3/3 status=ok·db·engine true, auth 401, OpenAPI 126, actual-origin 실회기 turn→review smoke 통과(agy 턴 2개→종료 200→리뷰 200) — 2026-08-12 이후 처음으로 201차 current GREEN 조건을 모두 충족했다. 회기 종료 503 핫픽스(bf5f7352)도 소유자 실세션 종료 성공으로 운영 검증. 권고: 공유기 DHCP 예약(호스트 MAC), Docker VM 클럭 동기화(호스트 대비 ~4일 선행 — DB 타임스탬프 오염), NAS 로컬 agy 이전. 증거는 docs/ops/evidence/nas-relay-recovery-2026-08-18.json이다.
214차 개선관리 워크북 동기화(2026-08-27): C-002·C-003과 REQ-001~008은 내부 기술 구현 DONE이다. 첫 회기 체크리스트와 종료 회기 기반 훈련 노출 지표를 추가했고, 외부 연구참여자 사전등록은 create에서 pending 고정 뒤 별도 PATCH 승인으로 분리했다. 프로토콜 레지스트리는 draft→active→retired, 라이선스 C/D의 외부 LLM 금지, evaluator-only RAG 색인·rollback을 강제하며 migration 17은 owner-run이고 startup은 readiness-only라 runtime DDL을 하지 않는다. 주호소는 surface보다 우선하고, 새 회기는 persona_id/version을 고정한다. 학습자 AI 피드백은 현재 계정 설정 AND 세션 snapshot일 때만 노출하며 OFF에서는 순수 파생 API 403·기존 share 404·혼합 응답 AI 필드 redaction·UI 파생 endpoint별 요청 0회를 적용한다. 축어록·저장 워크시트·outcome/calibration 입력·multimodal 동의/철회/삭제/raw audio/privacy 원장은 보존하고 alliance는 self-scores-only로 보여주며, privacy 삭제 조작면은 44px 이상·가로 overflow 0을 유지한다. teacher/admin 감독 뷰는 유지한다. 옛 회기 평가 실패는 degraded 리뷰로 남아도 새 회기를 막지 않는다. 헬스는 기본 엔진과 live-client를 함께 확인하고 구조화 done 없는 EOF는 client_stream_incomplete다. 상담자/내담자 이름은 prompt와 구조화 결과에서 [COUNSELOR]/[CLIENT]로 역할 마스킹한다. C-001은 내부 안전 사전검증만 완료했으며 임상 검토자 이름·소속 기관·검토일·서면 결정이 없어 외부 GATE로 계속 연다.
216차 REQ-001 로그인 단순화(2026-08-29): 실제로 같은 startOAuth를 호출하던 학교용·다른 계정용 중복 버튼을 단일 Google 계정으로 로그인 CTA로 통합했다. 보조문구는 모든 Google 계정이며 Google authorize의 prompt=select_account는 남겨 공유 기기에서도 계정을 고를 수 있게 하고 특정 Workspace 도메인을 강제하는 hd는 보내지 않는다. Google이 검증한 이메일은 도메인·사전등록 제약 없이 즉시 승인되는 기존 서버 계약을 유지한다. 검증: auth API 41 passed, 직접 영향 desktop/mobile 32 passed, 인증 시각·경계폭 2 passed, 7폭 전체 레이아웃 15 passed, 세션 무회귀 8 passed, typecheck·lint·build 통과. 이 단일 CTA 번들은 아직 공개 미배포라 현재 공개 Pages의 기존 2버튼 DOM을 새 증거로 대체하지 않는다.