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;