From f1f589bb90d970fb42d7bac5571810b23b5196ac Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:56:45 +0000 Subject: [PATCH 01/42] =?UTF-8?q?=E2=9C=A8=20[Palette]=20SettingsLayout=20?= =?UTF-8?q?=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=EC=84=B1=20=ED=88=B4=ED=8C=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SettingsLayout 컴포넌트 내 OIDC 로그아웃 버튼과 계정 설정 저장 버튼이 비활성화 상태일 때 툴팁이 동작하지 않는 접근성 문제를 해결했습니다. disabled 속성을 가진 button 태그에 직접 title을 부여하면 마우스 포인터 이벤트가 차단되고 키보드 포커스를 받을 수 없어 스크린 리더에서 읽을 수 없는 문제가 있습니다. 이를 해결하기 위해: - 비활성화된 버튼을 포커스 가능한 `span`으로 감싸고 `tabIndex`와 `title`을 적용 - 버튼 비활성 상태일 때 시각적인 커서 피드백을 유지하기 위해 래퍼 요소에 `cursor-not-allowed` 클래스 적용 - 자식 버튼에는 `pointer-events-none`을 추가하여 래퍼 요소가 호버 이벤트를 정상적으로 수신하도록 수정 --- frontend/src/components/SettingsLayout.tsx | 42 +++++++++++++--------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/SettingsLayout.tsx b/frontend/src/components/SettingsLayout.tsx index d5f11c1b6..aaacd1bf6 100644 --- a/frontend/src/components/SettingsLayout.tsx +++ b/frontend/src/components/SettingsLayout.tsx @@ -1299,17 +1299,22 @@ export function SettingsLayout() { 빈 secret 입력은 기존 저장값을 유지합니다. 실제 연결과 외부 쓰기는 서버 검증과 self-hosted connector 정책을 통과한 뒤 별도 실행됩니다.

- + +
@@ -1623,15 +1628,20 @@ export function SettingsLayout() { > OIDC 로그인 - + +
{oidcActionError ? ( From fc84ac3a1a51e725c4e383bf24d197ae35e99e3b Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:22:34 +0000 Subject: [PATCH 02/42] =?UTF-8?q?=E2=9C=A8=20[Palette]=20SettingsLayout=20?= =?UTF-8?q?=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=EC=84=B1=20=ED=88=B4=ED=8C=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SettingsLayout 컴포넌트 내 OIDC 로그아웃 버튼과 계정 설정 저장 버튼이 비활성화 상태일 때 툴팁이 동작하지 않는 접근성 문제를 해결했습니다. disabled 속성을 가진 button 태그에 직접 title을 부여하면 마우스 포인터 이벤트가 차단되고 키보드 포커스를 받을 수 없어 스크린 리더에서 읽을 수 없는 문제가 있습니다. 이를 해결하기 위해: - 비활성화된 버튼을 포커스 가능한 `span`으로 감싸고 `tabIndex`와 `title`을 적용 - 버튼 비활성 상태일 때 시각적인 커서 피드백을 유지하기 위해 래퍼 요소에 `cursor-not-allowed` 클래스 적용 - 자식 버튼에는 `pointer-events-none`을 추가하여 래퍼 요소가 호버 이벤트를 정상적으로 수신하도록 수정 From 3bb260f00bfbd11856b458650253e0189c03798d Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:03:12 +0000 Subject: [PATCH 03/42] =?UTF-8?q?=E2=9C=A8=20[Palette]=20SettingsLayout=20?= =?UTF-8?q?=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=EC=84=B1=20=ED=88=B4=ED=8C=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SettingsLayout 컴포넌트 내 OIDC 로그아웃 버튼과 계정 설정 저장 버튼이 비활성화 상태일 때 툴팁이 동작하지 않는 접근성 문제를 해결했습니다. disabled 속성을 가진 button 태그에 직접 title을 부여하면 마우스 포인터 이벤트가 차단되고 키보드 포커스를 받을 수 없어 스크린 리더에서 읽을 수 없는 문제가 있습니다. 이를 해결하기 위해: - 비활성화된 버튼을 포커스 가능한 `span`으로 감싸고 `tabIndex`와 `title`을 적용 - 버튼 비활성 상태일 때 시각적인 커서 피드백을 유지하기 위해 래퍼 요소에 `cursor-not-allowed` 클래스 적용 - 자식 버튼에는 `pointer-events-none`을 추가하여 래퍼 요소가 호버 이벤트를 정상적으로 수신하도록 수정 From 1bafde952adad58e0bb27577b8c0394b42b19e7b Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:41:22 +0000 Subject: [PATCH 04/42] =?UTF-8?q?=E2=9C=A8=20[Palette]=20SettingsLayout=20?= =?UTF-8?q?=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=EC=84=B1=20=ED=88=B4=ED=8C=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SettingsLayout 컴포넌트 내 OIDC 로그아웃 버튼과 계정 설정 저장 버튼이 비활성화 상태일 때 툴팁이 동작하지 않는 접근성 문제를 해결했습니다. disabled 속성을 가진 button 태그에 직접 title을 부여하면 마우스 포인터 이벤트가 차단되고 키보드 포커스를 받을 수 없어 스크린 리더에서 읽을 수 없는 문제가 있습니다. 이를 해결하기 위해: - 비활성화된 버튼을 포커스 가능한 `span`으로 감싸고 `tabIndex`와 `title`을 적용 - 버튼 비활성 상태일 때 시각적인 커서 피드백을 유지하기 위해 래퍼 요소에 `cursor-not-allowed` 클래스 적용 - 자식 버튼에는 `pointer-events-none`을 추가하여 래퍼 요소가 호버 이벤트를 정상적으로 수신하도록 수정 From f900abe8c685bbc8f5c9ab904d70b8bbf38efe35 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 18:03:54 -0700 Subject: [PATCH 05/42] test(settings): cover accessible unavailable actions --- .../SettingsLayout.accessibility.test.tsx | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 frontend/src/components/SettingsLayout.accessibility.test.tsx diff --git a/frontend/src/components/SettingsLayout.accessibility.test.tsx b/frontend/src/components/SettingsLayout.accessibility.test.tsx new file mode 100644 index 000000000..9e7c0fd2e --- /dev/null +++ b/frontend/src/components/SettingsLayout.accessibility.test.tsx @@ -0,0 +1,243 @@ +/* @vitest-environment jsdom */ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("lucide-react", () => ({ + Activity: () => , + AlertCircle: () => , + Loader2: () => , + Bell: () => , + Bot: () => , + CheckCircle2: () => , + Cpu: () => , + Mail: () => , + Monitor: () => , + Network: () => , + Plus: () => , + RefreshCw: () => , + Settings: () => , + Shield: () => , + Smartphone: () => , + User: () => , +})); + +const oidcMocks = vi.hoisted(() => ({ + clearOidcSession: vi.fn(), + getOidcBrowserConfig: vi.fn(), + startOidcLogin: vi.fn(), +})); + +vi.mock("@/lib/oidc-session", () => ({ + clearOidcSession: oidcMocks.clearOidcSession, + getOidcBrowserConfig: oidcMocks.getOidcBrowserConfig, + startOidcLogin: oidcMocks.startOidcLogin, +})); + +import { SettingsLayout } from "./SettingsLayout"; + +function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +function deferredResponse() { + let resolve!: (response: Response) => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + +function accountConfigResponse() { + return jsonResponse({ + user_id: "default", + smtp_server: "smtp.example.com", + smtp_port: 587, + smtp_username: "sender@example.com", + has_smtp_password: true, + imap_server: "imap.example.com", + imap_port: 993, + imap_username: "inbox@example.com", + has_imap_password: true, + pop3_server: null, + pop3_port: null, + pop3_username: null, + has_pop3_password: false, + oauth_client_id: "oauth-client-id", + oauth_redirect_uri: "https://naruon.net/oauth/mail/callback", + has_oauth_client_secret: true, + }); +} + +describe("SettingsLayout action availability accessibility", () => { + let root: Root | null = null; + let container: HTMLDivElement | null = null; + let accountRequest: ReturnType; + let sessionRequest: ReturnType; + + beforeEach(() => { + accountRequest = deferredResponse(); + sessionRequest = deferredResponse(); + oidcMocks.getOidcBrowserConfig.mockReturnValue({ + issuerUrl: "https://login.example.com/realms/naruon", + clientId: "naruon-web", + redirectUri: "https://app.example.com/auth/callback", + scope: "openid profile email", + authorizationEndpoint: "https://login.example.com/realms/naruon/protocol/openid-connect/auth", + tokenEndpoint: "https://login.example.com/realms/naruon/protocol/openid-connect/token", + endSessionEndpoint: "https://login.example.com/realms/naruon/protocol/openid-connect/logout", + }); + + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "/auth/session") return sessionRequest.promise; + if (url === "/api/accounts/config") return accountRequest.promise; + if (url === "/api/calendar/writeback-sources") return jsonResponse([]); + if (url === "/api/webdav/accounts") return jsonResponse([]); + if (url === "/api/llm-providers") return jsonResponse([]); + if (url === "/api/runner-config") { + return jsonResponse({ + workspace_id: "workspace-org-acme", + configured: false, + fingerprint: null, + updated_at: null, + connector_manifest: { + role: "self-hosted_connector", + network_mode: "outbound_only", + control_plane_domain: "naruon.net", + local_protocols: [], + prohibited_roles: [], + runner_usage: "ci_smoke_only", + }, + }); + } + if (url === "/api/observability/operational-signals") { + return jsonResponse({ + workspace_id: "workspace-org-acme", + audit_event: "observability.operational_signals.viewed", + telemetry: { + prometheus_metrics_enabled: false, + otel_traces_enabled: false, + otel_endpoint_configured: false, + otel_endpoint_host: null, + }, + connector: { + workspace_id: "workspace-org-acme", + registration_state: "not_registered", + connection_state: "not_connected", + active_connection_count: 0, + control_plane_domain: "naruon.net", + network_mode: "outbound_only", + runner_usage: "ci_smoke_only", + local_protocols: [], + last_heartbeat_at: null, + last_disconnect_at: null, + queue_depth_state: "clear", + queue_depth: { + pending_count: 0, + running_count: 0, + failed_count: 0, + total_count: 0, + next_retry_at: null, + }, + recent_events: [], + }, + signals: [], + }); + } + return jsonResponse({}); + }), + ); + }); + + afterEach(() => { + if (root) act(() => root?.unmount()); + root = null; + container?.remove(); + container = null; + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it("keeps unavailable account and logout actions focusable with state-specific next-action help", async () => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + await Promise.resolve(); + await Promise.resolve(); + }); + + const accountTab = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "연결 계정", + ); + await act(async () => { + accountTab?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + const saveButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "계정 설정 저장", + ); + expect(saveButton).toBeTruthy(); + expect(saveButton?.hasAttribute("disabled")).toBe(false); + expect(saveButton?.getAttribute("aria-disabled")).toBe("true"); + expect(saveButton?.getAttribute("aria-describedby")).toBe("account-save-availability"); + expect(saveButton?.getAttribute("title")).toBeNull(); + expect(container.querySelector("#account-save-availability")?.textContent).toBe( + "계정 설정을 불러오는 중입니다. 잠시 후 다시 시도하세요.", + ); + + await act(async () => { + accountRequest.resolve(accountConfigResponse()); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(saveButton?.getAttribute("aria-disabled")).toBeNull(); + expect(saveButton?.getAttribute("aria-describedby")).toBeNull(); + expect(container.querySelector("#account-save-availability")).toBeNull(); + + const developerTab = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "개발자", + ); + await act(async () => { + developerTab?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + const logoutButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "로그아웃", + ); + expect(logoutButton).toBeTruthy(); + expect(logoutButton?.hasAttribute("disabled")).toBe(false); + expect(logoutButton?.getAttribute("aria-disabled")).toBe("true"); + expect(logoutButton?.getAttribute("aria-describedby")).toBe("oidc-logout-availability"); + expect(logoutButton?.getAttribute("title")).toBeNull(); + expect(container.querySelector("#oidc-logout-availability")?.textContent).toBe( + "로그인 세션을 확인하는 중입니다. 잠시 후 다시 시도하세요.", + ); + + await act(async () => { + sessionRequest.resolve(jsonResponse({ + authenticated: true, + claims: { + userId: "alice", + organizationId: "org-acme", + workspaceId: "workspace-org-acme", + }, + })); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(logoutButton?.getAttribute("aria-disabled")).toBeNull(); + expect(logoutButton?.getAttribute("aria-describedby")).toBeNull(); + expect(container.querySelector("#oidc-logout-availability")).toBeNull(); + }); +}); From bdc95be867474f394130f42e6d966ec4d8a2a868 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 18:11:28 -0700 Subject: [PATCH 06/42] chore(settings): restore branch while repair remains open --- .../SettingsLayout.accessibility.test.tsx | 243 ------------------ 1 file changed, 243 deletions(-) delete mode 100644 frontend/src/components/SettingsLayout.accessibility.test.tsx diff --git a/frontend/src/components/SettingsLayout.accessibility.test.tsx b/frontend/src/components/SettingsLayout.accessibility.test.tsx deleted file mode 100644 index 9e7c0fd2e..000000000 --- a/frontend/src/components/SettingsLayout.accessibility.test.tsx +++ /dev/null @@ -1,243 +0,0 @@ -/* @vitest-environment jsdom */ -import React, { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("lucide-react", () => ({ - Activity: () => , - AlertCircle: () => , - Loader2: () => , - Bell: () => , - Bot: () => , - CheckCircle2: () => , - Cpu: () => , - Mail: () => , - Monitor: () => , - Network: () => , - Plus: () => , - RefreshCw: () => , - Settings: () => , - Shield: () => , - Smartphone: () => , - User: () => , -})); - -const oidcMocks = vi.hoisted(() => ({ - clearOidcSession: vi.fn(), - getOidcBrowserConfig: vi.fn(), - startOidcLogin: vi.fn(), -})); - -vi.mock("@/lib/oidc-session", () => ({ - clearOidcSession: oidcMocks.clearOidcSession, - getOidcBrowserConfig: oidcMocks.getOidcBrowserConfig, - startOidcLogin: oidcMocks.startOidcLogin, -})); - -import { SettingsLayout } from "./SettingsLayout"; - -function jsonResponse(body: unknown) { - return new Response(JSON.stringify(body), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); -} - -function deferredResponse() { - let resolve!: (response: Response) => void; - const promise = new Promise((next) => { - resolve = next; - }); - return { promise, resolve }; -} - -function accountConfigResponse() { - return jsonResponse({ - user_id: "default", - smtp_server: "smtp.example.com", - smtp_port: 587, - smtp_username: "sender@example.com", - has_smtp_password: true, - imap_server: "imap.example.com", - imap_port: 993, - imap_username: "inbox@example.com", - has_imap_password: true, - pop3_server: null, - pop3_port: null, - pop3_username: null, - has_pop3_password: false, - oauth_client_id: "oauth-client-id", - oauth_redirect_uri: "https://naruon.net/oauth/mail/callback", - has_oauth_client_secret: true, - }); -} - -describe("SettingsLayout action availability accessibility", () => { - let root: Root | null = null; - let container: HTMLDivElement | null = null; - let accountRequest: ReturnType; - let sessionRequest: ReturnType; - - beforeEach(() => { - accountRequest = deferredResponse(); - sessionRequest = deferredResponse(); - oidcMocks.getOidcBrowserConfig.mockReturnValue({ - issuerUrl: "https://login.example.com/realms/naruon", - clientId: "naruon-web", - redirectUri: "https://app.example.com/auth/callback", - scope: "openid profile email", - authorizationEndpoint: "https://login.example.com/realms/naruon/protocol/openid-connect/auth", - tokenEndpoint: "https://login.example.com/realms/naruon/protocol/openid-connect/token", - endSessionEndpoint: "https://login.example.com/realms/naruon/protocol/openid-connect/logout", - }); - - vi.stubGlobal( - "fetch", - vi.fn(async (input: RequestInfo | URL) => { - const url = String(input); - if (url === "/auth/session") return sessionRequest.promise; - if (url === "/api/accounts/config") return accountRequest.promise; - if (url === "/api/calendar/writeback-sources") return jsonResponse([]); - if (url === "/api/webdav/accounts") return jsonResponse([]); - if (url === "/api/llm-providers") return jsonResponse([]); - if (url === "/api/runner-config") { - return jsonResponse({ - workspace_id: "workspace-org-acme", - configured: false, - fingerprint: null, - updated_at: null, - connector_manifest: { - role: "self-hosted_connector", - network_mode: "outbound_only", - control_plane_domain: "naruon.net", - local_protocols: [], - prohibited_roles: [], - runner_usage: "ci_smoke_only", - }, - }); - } - if (url === "/api/observability/operational-signals") { - return jsonResponse({ - workspace_id: "workspace-org-acme", - audit_event: "observability.operational_signals.viewed", - telemetry: { - prometheus_metrics_enabled: false, - otel_traces_enabled: false, - otel_endpoint_configured: false, - otel_endpoint_host: null, - }, - connector: { - workspace_id: "workspace-org-acme", - registration_state: "not_registered", - connection_state: "not_connected", - active_connection_count: 0, - control_plane_domain: "naruon.net", - network_mode: "outbound_only", - runner_usage: "ci_smoke_only", - local_protocols: [], - last_heartbeat_at: null, - last_disconnect_at: null, - queue_depth_state: "clear", - queue_depth: { - pending_count: 0, - running_count: 0, - failed_count: 0, - total_count: 0, - next_retry_at: null, - }, - recent_events: [], - }, - signals: [], - }); - } - return jsonResponse({}); - }), - ); - }); - - afterEach(() => { - if (root) act(() => root?.unmount()); - root = null; - container?.remove(); - container = null; - vi.unstubAllGlobals(); - vi.clearAllMocks(); - }); - - it("keeps unavailable account and logout actions focusable with state-specific next-action help", async () => { - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - - await act(async () => { - root?.render(); - await Promise.resolve(); - await Promise.resolve(); - }); - - const accountTab = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent === "연결 계정", - ); - await act(async () => { - accountTab?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await Promise.resolve(); - }); - - const saveButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent === "계정 설정 저장", - ); - expect(saveButton).toBeTruthy(); - expect(saveButton?.hasAttribute("disabled")).toBe(false); - expect(saveButton?.getAttribute("aria-disabled")).toBe("true"); - expect(saveButton?.getAttribute("aria-describedby")).toBe("account-save-availability"); - expect(saveButton?.getAttribute("title")).toBeNull(); - expect(container.querySelector("#account-save-availability")?.textContent).toBe( - "계정 설정을 불러오는 중입니다. 잠시 후 다시 시도하세요.", - ); - - await act(async () => { - accountRequest.resolve(accountConfigResponse()); - await Promise.resolve(); - await Promise.resolve(); - }); - expect(saveButton?.getAttribute("aria-disabled")).toBeNull(); - expect(saveButton?.getAttribute("aria-describedby")).toBeNull(); - expect(container.querySelector("#account-save-availability")).toBeNull(); - - const developerTab = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent === "개발자", - ); - await act(async () => { - developerTab?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await Promise.resolve(); - }); - - const logoutButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent === "로그아웃", - ); - expect(logoutButton).toBeTruthy(); - expect(logoutButton?.hasAttribute("disabled")).toBe(false); - expect(logoutButton?.getAttribute("aria-disabled")).toBe("true"); - expect(logoutButton?.getAttribute("aria-describedby")).toBe("oidc-logout-availability"); - expect(logoutButton?.getAttribute("title")).toBeNull(); - expect(container.querySelector("#oidc-logout-availability")?.textContent).toBe( - "로그인 세션을 확인하는 중입니다. 잠시 후 다시 시도하세요.", - ); - - await act(async () => { - sessionRequest.resolve(jsonResponse({ - authenticated: true, - claims: { - userId: "alice", - organizationId: "org-acme", - workspaceId: "workspace-org-acme", - }, - })); - await Promise.resolve(); - await Promise.resolve(); - }); - expect(logoutButton?.getAttribute("aria-disabled")).toBeNull(); - expect(logoutButton?.getAttribute("aria-describedby")).toBeNull(); - expect(container.querySelector("#oidc-logout-availability")).toBeNull(); - }); -}); From 7f74bc274534d98b8da7d83ea83ba469587287b5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 11:47:14 +0900 Subject: [PATCH 07/42] fix(settings): expose async action readiness --- .../src/components/SettingsLayout.test.tsx | 91 +++++++++++++++++++ frontend/src/components/SettingsLayout.tsx | 19 ++-- 2 files changed, 103 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/SettingsLayout.test.tsx b/frontend/src/components/SettingsLayout.test.tsx index 53174e1bf..2fabecd83 100644 --- a/frontend/src/components/SettingsLayout.test.tsx +++ b/frontend/src/components/SettingsLayout.test.tsx @@ -491,6 +491,97 @@ describe("SettingsLayout", () => { }); }); + it("shows loading state on disabled account and OIDC actions until their reads settle", async () => { + let releaseSession!: (response: Response) => void; + let releaseAccount!: (response: Response) => void; + const sessionPending = new Promise((resolve) => { + releaseSession = resolve; + }); + const accountPending = new Promise((resolve) => { + releaseAccount = resolve; + }); + const fallbackFetch = vi.mocked(fetch).getMockImplementation(); + vi.mocked(fetch).mockImplementation((input, init) => { + if (String(input) === "/auth/session") return sessionPending; + if (String(input) === "/api/accounts/config" && init?.method !== "PUT") return accountPending; + return fallbackFetch?.(input, init) ?? Promise.resolve(jsonResponse({})); + }); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + await Promise.resolve(); + }); + + const accountTab = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "연결 계정", + ); + expect(accountTab).toBeTruthy(); + await act(async () => { + accountTab?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + const accountSaveButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("계정 설정 저장"), + ); + expect(accountSaveButton?.parentElement?.getAttribute("title")).toBe("계정 설정을 불러오는 중입니다"); + expect(accountSaveButton?.getAttribute("aria-busy")).toBe("true"); + + const developerTab = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "개발자", + ); + expect(developerTab).toBeTruthy(); + await act(async () => { + developerTab?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + const logoutButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "로그아웃", + ); + expect(logoutButton?.parentElement?.getAttribute("title")).toBe("세션을 확인하는 중입니다"); + expect(logoutButton?.getAttribute("aria-busy")).toBe("true"); + + await act(async () => { + releaseSession(jsonResponse({ + authenticated: true, + claims: { userId: "alice", organizationId: "org-acme", workspaceId: "workspace-org-acme" }, + })); + releaseAccount(jsonResponse({ + user_id: "default", + smtp_server: "smtp.example.com", + smtp_port: 587, + smtp_username: "sender@example.com", + has_smtp_password: true, + imap_server: "imap.example.com", + imap_port: 993, + imap_username: "inbox@example.com", + has_imap_password: true, + pop3_server: "pop3.example.com", + pop3_port: 995, + pop3_username: "archive@example.com", + has_pop3_password: false, + oauth_client_id: "oauth-client-id", + oauth_redirect_uri: "https://naruon.net/oauth/mail/callback", + has_oauth_client_secret: true, + })); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(logoutButton?.parentElement?.getAttribute("title")).toBe("로그아웃"); + await act(async () => { + accountTab?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + const readyAccountSaveButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("계정 설정 저장"), + ); + expect(readyAccountSaveButton?.parentElement?.getAttribute("title")).toBe("계정 설정 저장"); + }); + it("loads and saves source-backed mail account settings without public identity headers or secret replay", async () => { container = document.createElement("div"); document.body.appendChild(container); diff --git a/frontend/src/components/SettingsLayout.tsx b/frontend/src/components/SettingsLayout.tsx index aaacd1bf6..8d55e563c 100644 --- a/frontend/src/components/SettingsLayout.tsx +++ b/frontend/src/components/SettingsLayout.tsx @@ -525,6 +525,7 @@ export function SettingsLayout() { const [selectedEmbeddingModel, setSelectedEmbeddingModel] = useState('embeddinggemma'); const [embeddingSaving, setEmbeddingSaving] = useState(false); const [oidcSessionClaims, setOidcSessionClaims] = useState(EMPTY_SESSION_CLAIMS); + const [oidcSessionLoading, setOidcSessionLoading] = useState(true); const [oidcActionError, setOidcActionError] = useState(null); const smtpPasswordInputRef = useRef(null); const imapPasswordInputRef = useRef(null); @@ -729,6 +730,9 @@ export function SettingsLayout() { }) .catch(() => { if (!cancelled) setOidcSessionClaims(EMPTY_SESSION_CLAIMS); + }) + .finally(() => { + if (!cancelled) setOidcSessionLoading(false); }); void apiClient @@ -1301,14 +1305,14 @@ export function SettingsLayout() { From 4f6c07d57ab4cc08835e8265ab98ca5d0da6fe8e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 11:54:15 +0900 Subject: [PATCH 08/42] fix(settings): make disabled action guidance accessible --- .../src/components/SettingsLayout.test.tsx | 30 ++++++++- frontend/src/components/SettingsLayout.tsx | 65 +++++++++++++------ 2 files changed, 74 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/SettingsLayout.test.tsx b/frontend/src/components/SettingsLayout.test.tsx index 2fabecd83..4ba30dd20 100644 --- a/frontend/src/components/SettingsLayout.test.tsx +++ b/frontend/src/components/SettingsLayout.test.tsx @@ -492,6 +492,7 @@ describe("SettingsLayout", () => { }); it("shows loading state on disabled account and OIDC actions until their reads settle", async () => { + oidcMocks.getOidcBrowserConfig.mockReturnValue(null); let releaseSession!: (response: Response) => void; let releaseAccount!: (response: Response) => void; const sessionPending = new Promise((resolve) => { @@ -527,7 +528,12 @@ describe("SettingsLayout", () => { const accountSaveButton = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent?.includes("계정 설정 저장"), ); + expect(accountSaveButton?.parentElement?.getAttribute("tabindex")).toBe("0"); expect(accountSaveButton?.parentElement?.getAttribute("title")).toBe("계정 설정을 불러오는 중입니다"); + expect(accountSaveButton?.parentElement?.getAttribute("aria-label")).toBe("계정 설정을 불러오는 중입니다"); + expect(accountSaveButton?.disabled).toBe(true); + expect(accountSaveButton?.getAttribute("aria-disabled")).toBe("true"); + expect(accountSaveButton?.className).toContain("pointer-events-none"); expect(accountSaveButton?.getAttribute("aria-busy")).toBe("true"); const developerTab = Array.from(container.querySelectorAll("button")).find( @@ -541,7 +547,20 @@ describe("SettingsLayout", () => { const logoutButton = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent === "로그아웃", ); + const loginButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "OIDC 로그인", + ); + expect(loginButton?.parentElement?.getAttribute("tabindex")).toBe("0"); + expect(loginButton?.parentElement?.getAttribute("title")).toBe("OIDC 브라우저 설정이 없습니다"); + expect(loginButton?.disabled).toBe(true); + expect(loginButton?.getAttribute("aria-disabled")).toBe("true"); + expect(loginButton?.className).toContain("pointer-events-none"); + expect(logoutButton?.parentElement?.getAttribute("tabindex")).toBe("0"); expect(logoutButton?.parentElement?.getAttribute("title")).toBe("세션을 확인하는 중입니다"); + expect(logoutButton?.parentElement?.getAttribute("aria-label")).toBe("세션을 확인하는 중입니다"); + expect(logoutButton?.disabled).toBe(true); + expect(logoutButton?.getAttribute("aria-disabled")).toBe("true"); + expect(logoutButton?.className).toContain("pointer-events-none"); expect(logoutButton?.getAttribute("aria-busy")).toBe("true"); await act(async () => { @@ -571,7 +590,11 @@ describe("SettingsLayout", () => { await Promise.resolve(); }); - expect(logoutButton?.parentElement?.getAttribute("title")).toBe("로그아웃"); + expect(logoutButton?.parentElement?.getAttribute("title")).toBeNull(); + expect(logoutButton?.parentElement?.getAttribute("tabindex")).toBeNull(); + expect(logoutButton?.parentElement?.getAttribute("aria-label")).toBeNull(); + expect(logoutButton?.disabled).toBe(false); + expect(logoutButton?.className).not.toContain("pointer-events-none"); await act(async () => { accountTab?.dispatchEvent(new MouseEvent("click", { bubbles: true })); await Promise.resolve(); @@ -579,7 +602,10 @@ describe("SettingsLayout", () => { const readyAccountSaveButton = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent?.includes("계정 설정 저장"), ); - expect(readyAccountSaveButton?.parentElement?.getAttribute("title")).toBe("계정 설정 저장"); + expect(readyAccountSaveButton?.parentElement?.getAttribute("tabindex")).toBeNull(); + expect(readyAccountSaveButton?.parentElement?.getAttribute("title")).toBeNull(); + expect(readyAccountSaveButton?.disabled).toBe(false); + expect(readyAccountSaveButton?.className).not.toContain("pointer-events-none"); }); it("loads and saves source-backed mail account settings without public identity headers or secret replay", async () => { diff --git a/frontend/src/components/SettingsLayout.tsx b/frontend/src/components/SettingsLayout.tsx index 8d55e563c..ec4204724 100644 --- a/frontend/src/components/SettingsLayout.tsx +++ b/frontend/src/components/SettingsLayout.tsx @@ -541,6 +541,23 @@ export function SettingsLayout() { const activeModelProvider = modelProviders.find((provider) => provider.is_active) ?? modelProviders[0] ?? null; const selectedEmbeddingProvider = modelProviders.find((provider) => provider.id === selectedEmbeddingProviderId) ?? activeModelProvider; const accountReady = !accountLoading && !accountError && accountConfig !== null; + const accountActionDisabled = accountSaving || !accountReady; + const accountActionTitle = accountSaving + ? "저장 중입니다" + : accountLoading + ? "계정 설정을 불러오는 중입니다" + : accountError + ? "계정 설정을 불러오지 못했습니다" + : !accountReady + ? "입력값이 부족합니다" + : undefined; + const oidcLoginDisabled = !oidcBrowserConfig; + const oidcLogoutDisabled = oidcSessionLoading || !oidcSessionClaims.userId; + const oidcLogoutTitle = oidcSessionLoading + ? "세션을 확인하는 중입니다" + : !oidcSessionClaims.userId + ? "로그인된 세션이 없습니다" + : undefined; const oauthAppConfigured = Boolean( accountConfig?.oauth_client_id && accountConfig?.oauth_redirect_uri @@ -1304,16 +1321,17 @@ export function SettingsLayout() {

+ + From a2f9c6f9cbb7f033670df51d0cbfbd37b9016668 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 12:00:43 +0900 Subject: [PATCH 09/42] fix(settings): preserve enabled action guidance --- frontend/src/components/SettingsLayout.test.tsx | 3 ++- frontend/src/components/SettingsLayout.tsx | 14 +++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/SettingsLayout.test.tsx b/frontend/src/components/SettingsLayout.test.tsx index 4ba30dd20..872ae7056 100644 --- a/frontend/src/components/SettingsLayout.test.tsx +++ b/frontend/src/components/SettingsLayout.test.tsx @@ -474,6 +474,7 @@ describe("SettingsLayout", () => { const logoutButton = Array.from(container.querySelectorAll("button")).find((button) => button.textContent === "로그아웃"); expect(loginButton).toBeTruthy(); expect(logoutButton).toBeTruthy(); + expect(loginButton?.parentElement?.getAttribute("title")).toBe("OIDC 로그인"); await act(async () => { loginButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); @@ -603,7 +604,7 @@ describe("SettingsLayout", () => { (button) => button.textContent?.includes("계정 설정 저장"), ); expect(readyAccountSaveButton?.parentElement?.getAttribute("tabindex")).toBeNull(); - expect(readyAccountSaveButton?.parentElement?.getAttribute("title")).toBeNull(); + expect(readyAccountSaveButton?.parentElement?.getAttribute("title")).toBe("계정 설정 저장"); expect(readyAccountSaveButton?.disabled).toBe(false); expect(readyAccountSaveButton?.className).not.toContain("pointer-events-none"); }); diff --git a/frontend/src/components/SettingsLayout.tsx b/frontend/src/components/SettingsLayout.tsx index ec4204724..5a98e09c5 100644 --- a/frontend/src/components/SettingsLayout.tsx +++ b/frontend/src/components/SettingsLayout.tsx @@ -546,11 +546,11 @@ export function SettingsLayout() { ? "저장 중입니다" : accountLoading ? "계정 설정을 불러오는 중입니다" - : accountError - ? "계정 설정을 불러오지 못했습니다" - : !accountReady - ? "입력값이 부족합니다" - : undefined; + : accountError + ? "계정 설정을 불러오지 못했습니다" + : !accountReady + ? "입력값이 부족합니다" + : "계정 설정 저장"; const oidcLoginDisabled = !oidcBrowserConfig; const oidcLogoutDisabled = oidcSessionLoading || !oidcSessionClaims.userId; const oidcLogoutTitle = oidcSessionLoading @@ -1643,8 +1643,8 @@ export function SettingsLayout() {
+ {disabled && {title}} + + ); +} + const EMPTY_SESSION_CLAIMS: SessionClaims = { userId: null, organizationId: null, @@ -1320,23 +1352,16 @@ export function SettingsLayout() { 빈 secret 입력은 기존 저장값을 유지합니다. 실제 연결과 외부 쓰기는 서버 검증과 self-hosted connector 정책을 통과한 뒤 별도 실행됩니다.

- - - + {accountSaving &&