Google 로그인을 단일 버튼으로 정리

This commit is contained in:
Yun Chan 2026-08-29 06:54:11 +09:00
parent 7994175a3d
commit 471eb9621a
15 changed files with 78 additions and 85 deletions

View file

@ -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"],

View file

@ -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);

View file

@ -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(),
]);
});

View file

@ -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");

View file

@ -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);
});

View file

@ -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 계정으로 로그인",
);
});

View file

@ -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();
});

View file

@ -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() {
<LoginPanel
oauth={{
ready: oauthReady,
primaryAccountLabel,
secondaryAccountLabel,
accountLabel,
statusIcon: authConfigError ? "alert" : "info",
statusMessage: oauthStatusMessage,
onStart: startOAuth,

View file

@ -11,8 +11,7 @@ export interface LoginRoleOption {
interface LoginOAuthView {
ready: boolean;
primaryAccountLabel: string;
secondaryAccountLabel: string;
accountLabel: string;
statusIcon: Extract<IconName, "alert" | "info">;
statusMessage: string;
onStart: () => void;
@ -38,33 +37,27 @@ interface LoginPanelProps {
error: LoginErrorView;
}
function LoginProviderButton({
variant,
icon,
label,
function GoogleLoginButton({
subLabel,
disabled,
onClick,
}: {
variant: "primary" | "secondary";
icon: Extract<IconName, "google" | "school">;
label: string;
subLabel: string;
disabled: boolean;
onClick: () => void;
}) {
return (
<button
className={surfaceClassName(`lg-obtn ${variant}`, { variant: "interactive", flat: true })}
className={surfaceClassName("lg-obtn primary", { variant: "interactive", flat: true })}
type="button"
onClick={onClick}
disabled={disabled}
>
<span className="ic">
<Icon name={icon} size={19} strokeWidth={1.8} />
<Icon name="google" size={19} strokeWidth={1.8} />
</span>
<span className="txt">
{label}
Google
<span className="sub">{subLabel}</span>
</span>
<Icon name="chevron-right" size={18} strokeWidth={2} />
@ -140,19 +133,8 @@ export function LoginPanel({ oauth, devAccess, error }: LoginPanelProps) {
</p>
<div className="lg-actions">
<LoginProviderButton
variant="primary"
icon="school"
label="Google 계정으로 계속"
subLabel={oauth.primaryAccountLabel}
disabled={!oauth.ready}
onClick={oauth.onStart}
/>
<LoginProviderButton
variant="secondary"
icon="google"
label="다른 Google 계정 선택"
subLabel={oauth.secondaryAccountLabel}
<GoogleLoginButton
subLabel={oauth.accountLabel}
disabled={!oauth.ready}
onClick={oauth.onStart}
/>

View file

@ -221,14 +221,6 @@
border-color: var(--accent);
}
.lg-obtn.secondary {
color: var(--text-strong);
}
.lg-obtn.secondary:hover {
border-color: color-mix(in srgb, var(--accent) 42%, var(--glass-inset-border));
}
.lg-obtn:disabled {
cursor: not-allowed;
opacity: 1;
@ -261,10 +253,6 @@
color: var(--accent-deep);
}
.lg-obtn.secondary .ic {
background: var(--bg-surface-2);
}
.lg-obtn .txt {
min-width: 0;
display: flex;

View file

@ -21,7 +21,7 @@
| C-001 | **OPEN · 외부 GATE** | v2 P1 합성 사례 6건·상태별 canonical 판정기와 109·수단 상세 차단·런타임/carry-over/output `ideation_stage <= 3`·4개 공식 source 기술 사전검증은 완료. 사례별 5필드 30개, 청소년 답변 6개, 검토자·소속·자격·검토일·결정 근거·서면 증거/해시·검토 버전/사례 해시를 포함한 승인 10필드가 있어야 닫는다. 현재 `review_complete=false`; F절/B4에서 추적한다. |
| C-002 | **DONE · 내부 기술** | 첫 회기 라포 반영·한 초점 열린 질문 체크리스트, 근거 turn 연결, 이후 회기 not-applicable |
| C-003 | **DONE · 내부 기술** | 종료 회기 4건 미만 insufficient, 이후 최다 페르소나 비중 0.75 이상 훈련 집중 주의; 임상·공정성 판정 아님 |
| REQ-001 | **DONE · 내부 기술** | provider가 이메일을 검증한 모든 Google 계정은 도메인·사전등록 없이 learner·approved로 즉시 로그인. 관리자 exact-email 연구참여자 사전등록 create는 pending 고정, 승인은 별도 PATCH |
| REQ-001 | **DONE · 내부 기술 · 공개 UI 미승격** | provider가 이메일을 검증한 모든 Google 계정은 도메인·사전등록 없이 learner·approved로 즉시 로그인. 로그인은 단일 `Google 계정으로 로그인` CTA이며 Google 계정 선택기(`prompt=select_account`)를 유지하고 `hd`를 보내지 않는다. 관리자 exact-email 연구참여자 사전등록 create는 pending 고정, 승인은 별도 PATCH. 로컬 desktop/mobile 직접 영향 32/32, 인증 시각·경계폭 2/2, 전체 7폭 15/15, 세션 8/8, API auth 41/41을 통과했으며 단일 CTA 번들은 아직 공개 미배포다. |
| REQ-002 | **DONE · 내부 기술** | admin protocol draft→active→retired, 라이선스 C/D 외부 LLM 금지, evaluator-only RAG·rollback; migration 17 owner-run/readiness-only |
| REQ-003 | **DONE · 내부 기술** | 페르소나 주호소가 JSON 키 순서나 surface 상태보다 우선 |
| REQ-004 | **DONE · 내부 기술** | learner 현재 설정 AND 세션 snapshot. OFF는 AI 파생 API 403/UI 무요청·혼합 결과 redaction, 사용자 입력·privacy 보존; teacher/admin 유지 |

View file

@ -695,6 +695,7 @@
<p class="dg-note">212차 핫픽스(2026-08-18 오후): 공개 <code>POST /sessions/{id}/end</code> 503(소유자 실사용 보고)의 원인은 회기 종료의 pinned_fact upsert가 <b>learner 역할 연결</b>로 실행된 것이었다. <code>app.pinned_fact</code> RLS는 learner에 INSERT(소유 케이스)만 허용하고 SELECT/UPDATE는 AI 컨텍스트·admin/instructor 전용이라 <code>SELECT … FOR UPDATE + INSERT … ON CONFLICT DO UPDATE</code> 조합이 충돌 후보 가시성에서 <code>InsufficientPrivilegeError</code>로 실패하고 fallback 차단이 503으로 노출됐다. 공개 DB 롤백 재현으로 단순 INSERT는 통과·ON CONFLICT 조합은 거부·<code>ai_view=evaluator</code> 컨텍스트는 전체 upsert 통과를 실증했고(<code>bf5f7352</code>), pinned_fact 저장만 같은 learner uid의 AI evaluator 뷰 연결로 분리했다. 검증: 관련 62 passed·app <b>924 passed</b>, 공개 8001 승격 후 health ok. 엔진이 건강해 deep 평가가 처음 팩트를 반환한 순간 발화한 잠복 결함으로, 아침 degraded 구간엔 팩트가 비어 이 경로가 실행되지 않았다.</p>
<p class="dg-note">213차 복구·전환(2026-08-18 심야): 대청소 사건(node_modules·구 런타임 워크트리·Temp 소실)과 Docker Desktop 사망·whisper 9882 WinNAT 포트 예약·DHCP LAN IP 회수(223→224)가 연쇄 발생했다. 릴레이를 현행 루트 <code>bf5f7352</code>에 같은 4종 핀으로 재기동(r3 영수증)하고 <code>192.168.0.224:9100</code>으로 바인딩(방화벽 규칙 재발행), NAS는 실행 컨테이너 env 전량을 <code>.env(0600)</code>로 재구성해 ENGINE_URL 224 적용 후 api만 재생성했다. 클로드 예산 한도(Reached maximum budget $0.5)가 재발해 NAS ENGINE_MODE를 <code>agy_cli</code>(gemini)로 전환 — 공개는 소유자가 이미 관리자 UI로 agy 전환 완료. 결과: NAS origin health <b>3/3 status=ok·db·engine true</b>, auth 401, OpenAPI 126, <b>actual-origin 실회기 turn→review smoke 통과</b>(agy 턴 2개→종료 200→리뷰 200) — 2026-08-12 이후 처음으로 201차 current GREEN 조건을 모두 충족했다. 회기 종료 503 핫픽스(<code>bf5f7352</code>)도 소유자 실세션 종료 성공으로 운영 검증. 권고: 공유기 DHCP 예약(호스트 MAC), Docker VM 클럭 동기화(호스트 대비 ~4일 선행 — DB 타임스탬프 오염), NAS 로컬 agy 이전. 증거는 <code>docs/ops/evidence/nas-relay-recovery-2026-08-18.json</code>이다.</p>
<p class="dg-note">214차 개선관리 워크북 동기화(2026-08-27): <b>C-002·C-003과 REQ-001~008은 내부 기술 구현 DONE</b>이다. 첫 회기 체크리스트와 종료 회기 기반 훈련 노출 지표를 추가했고, 외부 연구참여자 사전등록은 create에서 <code>pending</code> 고정 뒤 별도 PATCH 승인으로 분리했다. 프로토콜 레지스트리는 <code>draft→active→retired</code>, 라이선스 C/D의 외부 LLM 금지, evaluator-only RAG 색인·rollback을 강제하며 migration 17은 owner-run이고 startup은 readiness-only라 runtime DDL을 하지 않는다. 주호소는 surface보다 우선하고, 새 회기는 <code>persona_id/version</code>을 고정한다. 학습자 AI 피드백은 <b>현재 계정 설정 AND 세션 snapshot</b>일 때만 노출하며 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를 함께 확인하고 구조화 <code>done</code> 없는 EOF는 <code>client_stream_incomplete</code>다. 상담자/내담자 이름은 prompt와 구조화 결과에서 <code>[COUNSELOR]</code>/<code>[CLIENT]</code>로 역할 마스킹한다. <b>C-001은 내부 안전 사전검증만 완료</b>했으며 임상 검토자 이름·소속 기관·검토일·서면 결정이 없어 외부 GATE로 계속 연다.</p>
<p class="dg-note">216차 REQ-001 로그인 단순화(2026-08-29): 실제로 같은 <code>startOAuth</code>를 호출하던 학교용·다른 계정용 중복 버튼을 단일 <b>Google 계정으로 로그인</b> CTA로 통합했다. 보조문구는 <b>모든 Google 계정</b>이며 Google authorize의 <code>prompt=select_account</code>는 남겨 공유 기기에서도 계정을 고를 수 있게 하고 특정 Workspace 도메인을 강제하는 <code>hd</code>는 보내지 않는다. Google이 검증한 이메일은 도메인·사전등록 제약 없이 즉시 승인되는 기존 서버 계약을 유지한다. <b>검증</b>: auth API <b>41 passed</b>, 직접 영향 desktop/mobile <b>32 passed</b>, 인증 시각·경계폭 <b>2 passed</b>, 7폭 전체 레이아웃 <b>15 passed</b>, 세션 무회귀 <b>8 passed</b>, typecheck·lint·build 통과. 이 단일 CTA 번들은 아직 공개 미배포라 현재 공개 Pages의 기존 2버튼 DOM을 새 증거로 대체하지 않는다.</p>
<div class="dg-principles" aria-label="디자인 생성 가드레일">
<div><b>래스터만 사용</b><span>이미지 생성 도구 산출물은 PNG 기반 시안이다. SVG·벡터·와이어프레임·로고 시트로 해석하지 않는다.</span></div>
<div><b>기능 우선</b><span>메인 라우트의 실제 액션과 정보 구조를 먼저 반영한다. 장식은 기능을 가리지 않는 수준에서만 쓴다.</span></div>

View file

@ -57,8 +57,8 @@
## 1. 인증·온보딩 (auth)
- [x] `auth-login-config-load` 로그인 설정 조회 (OAuth 구성/허용 도메인) — `GET /auth/config`로 OAuth 구성·dev 로그인 가능 여부·허용 도메인을 받아 버튼 활성/라벨/배지를 결정한다 · 검증: 기존(부분) e2e/auth.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
- [x] `auth-login-school-google-button` 학교 Google 계정으로 계속 버튼 (primary) — 클릭 시 `/auth/login?provider=google&next=`로 이동해 Google OAuth를 시작하고 oauthReady가 아니면 비활성화된다 · 검증: 기존(부분) e2e/auth.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실브라우저 순회)
- [x] `auth-login-generic-google-button` Google 계정으로 계속 버튼 (secondary) — primary와 동일한 startOAuth를 호출하며 서브라벨에 두 번째 허용 도메인을 표시한다 · 검증: 신규 spec 필요
- [x] `auth-login-google-button` 단일 Google 계정으로 로그인 버튼 — 클릭 시 `/auth/login?provider=google&next=`로 이동하고, 보조문구는 `모든 Google 계정`이다. Google authorize는 `prompt=select_account`를 유지하고 `hd`를 보내지 않으며 oauthReady가 아니면 비활성화된다 · 검증: 2026-08-29 desktop/mobile 직접 영향 32/32 + auth API 41/41
- [x] `auth-login-generic-google-button` 중복 secondary 제거 — primary와 같은 `startOAuth`를 호출하던 `다른 Google 계정 선택` 버튼을 제거하고 계정 전환은 Google 계정 선택기가 담당한다 · 검증: 2026-08-29 단일 CTA count·옛 버튼 0건 desktop/mobile GREEN
- [x] `auth-login-oauth-status-notice` OAuth 비활성 사유 status 안내 — oauthReady가 아닐 때 role=status로 설정 확인 중/미연결/로컬 테스트 계정 사유를 아이콘과 함께 표시한다 · 검증: 기존 e2e/auth.spec.ts
- [x] `auth-login-oauth-error-notice` OAuth 콜백 오류 메시지 (?oauth= 쿼리) — 쿼리 사유별(not_configured·domain_not_allowed·access_denied 등) 한국어 안내문과 원본 코드를 표시한다 · 검증: 기존 e2e/auth.spec.ts
- [x] `auth-login-dev-role-radiogroup` 로컬 테스트 역할 선택 라디오그룹 (학습자/교수자/관리자) — DEV이고 dev_login_enabled일 때만 노출되며 세 역할 버튼으로 dev 로그인 역할을 고른다 · 검증: 기존(부분) e2e/auth.spec.ts + 탐색 순회 · 탐색 GREEN(2026-07-27 실순회)
@ -94,8 +94,7 @@
**의심 결함(에이전트 리포트)**:
- `auth-login-config-load`: config 로드 실패 시 별도 오류 배너 없이 버튼이 영구 비활성 상태로만 남고, statusMessage는 '연결되지 않았습니다' 문구라서 네트워크 오류와 미설정을 구분해 주지 않는다.
- `auth-login-school-google-button`: '학교 Google 계정' 버튼과 일반 'Google 계정' 버튼이 완전히 동일한 provider=google 흐름을 호출한다(학교 SSO/SAML 별도 흐름 없음). 또한 startOAuth 내부의 !oauthReady 오류 세팅 분기는 버튼이 이미 disabled라 실제로 도달 불가한 사문(死文) 코드다.
- `auth-login-generic-google-button`: 두 버튼이 기능적으로 중복이라 사용자가 다른 인증 경로로 오해할 수 있다.
- `auth-login-school-google-button`·`auth-login-generic-google-button`: 2026-08-29 두 버튼이 같은 provider=google 흐름을 호출하던 중복을 단일 CTA로 해소했다. 계정 선택은 `prompt=select_account`로 유지한다. 공개 Pages 번들은 아직 승격 전이라 로컬 증거와 공개 상태를 구분한다.
- `auth-login-oauth-error-notice`: 오류 문단에 role=alert/aria-live가 없어 스크린리더에 즉시 공지되지 않는다.
- `auth-login-dev-role-radiogroup`: role=radio 버튼 그룹이지만 방향키 이동 등 라디오그룹 키보드 시맨틱은 구현되어 있지 않다(Tab으로만 이동).
- `auth-login-dev-enter-button`: 실패 시 err.message를 그대로 노출하는데 ApiError 메시지가 'API 500: ...' 같은 원문이라 사용자 친화적이지 않을 수 있다.

View file

@ -91,7 +91,7 @@
},
{
"path": "outputs/01a04217-f73b-7303-b597-401fa7f5d290/Vignette_개선관리_완료.xlsx",
"sha256": "478fd8612843d6a9c60288934f7992c5cad115d2a3b064a3da4e2a4e9f07b7e2"
"sha256": "b334e313c64b530b3525bb9253fef17282df361224d408f3e67a526af0ef55aa"
}
],
"verification": [
@ -113,7 +113,7 @@
},
{
"command": "artifact-tool workbook rebuild, inspect, formula error scan, six-sheet render",
"result": "formula errors 0; summary complete=10, review=1, total=11; ten independent external approval inputs G13:G22; canonical gate pending-valid; PACKAGE_MATCH and WORKTREE_DRIFT 0/8; C-001 review_complete=false; six rendered sheets plus focused C-001 sections visually inspected; workbook sha256=478fd8612843d6a9c60288934f7992c5cad115d2a3b064a3da4e2a4e9f07b7e2"
"result": "formula errors 0; summary complete=10, review=1, total=11; ten independent external approval inputs G13:G22; canonical gate pending-valid; PACKAGE_MATCH and WORKTREE_DRIFT 0/8; C-001 review_complete=false; six rendered sheets plus focused C-001 sections visually inspected; workbook sha256=b334e313c64b530b3525bb9253fef17282df361224d408f3e67a526af0ef55aa"
}
]
}

View file

@ -11,11 +11,8 @@
## 1. 접속과 로그인
1. 안내받은 주소로 접속합니다(파일럿 도메인 — 연구팀 공지).
2. **Google 계정으로 로그인**합니다. 참가자 명단에 등록된 이메일(학교/승인 도메인)로 로그인하세요.
3. 처음 로그인하면 **"승인 대기"** 화면이 나올 수 있습니다.
- 운영자가 수업·연구 참여 범위를 확인하는 동안 화면이 열리지 않습니다.
- 승인되면 화면의 **"승인 상태 새로고침"** 버튼을 누르거나 다시 접속하면 온보딩으로 넘어갑니다.
- 반나절 이상 승인이 안 되면 아래 §6 연락 채널로 이메일을 알려주세요.
2. **Google 계정으로 로그인** 버튼을 누르고 사용할 Google 계정을 선택합니다. Google이 이메일을 확인한 계정이면 학교·회사·개인 Gmail 등 도메인과 사전등록 여부에 관계없이 로그인할 수 있습니다.
3. 처음 로그인하면 온보딩으로 이동합니다. 연구참여자 사전등록과 별도 운영 승인은 Google 로그인 가능 여부를 제한하지 않습니다.
## 2. 온보딩과 동의