From 1b8dc2c4abe00e24387566e9675b4465ac0e7b9e Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 6 Apr 2026 16:03:49 +0900 Subject: [PATCH] =?UTF-8?q?dev=20=EB=B8=8C=EB=9F=B0=EC=B9=98=20=EB=B3=91?= =?UTF-8?q?=ED=95=A9=20=ED=9B=84=20code=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/headless_login_e2e_test.go | 12 ++ backend/internal/handler/auth_handler.go | 4 + .../handler/auth_handler_async_test.go | 2 + .../internal/service/tenant_service_test.go | 1 + .../tests/password-and-reset.spec.ts | 72 +++++-- userfront/assets/translations/ko.toml | 183 +++++++++--------- userfront/assets/translations/template.toml | 89 +++++++-- .../auth/presentation/login_screen.dart | 11 +- 8 files changed, 245 insertions(+), 129 deletions(-) diff --git a/backend/cmd/server/headless_login_e2e_test.go b/backend/cmd/server/headless_login_e2e_test.go index f91a5b53..89a1822b 100644 --- a/backend/cmd/server/headless_login_e2e_test.go +++ b/backend/cmd/server/headless_login_e2e_test.go @@ -121,6 +121,18 @@ func (m *e2eMockKratosAdminService) DeleteIdentity(ctx context.Context, identity return nil } +func (m *e2eMockKratosAdminService) ListIdentitySessions(ctx context.Context, identityID string) ([]service.KratosSession, error) { + return nil, nil +} + +func (m *e2eMockKratosAdminService) GetSession(ctx context.Context, sessionID string) (*service.KratosSession, error) { + return nil, nil +} + +func (m *e2eMockKratosAdminService) DeleteSession(ctx context.Context, sessionID string) error { + return nil +} + func newHeadlessLoginE2EApp(h *authhandler.AuthHandler, appEnv string) *fiber.App { app := fiber.New(fiber.Config{ DisableStartupMessage: true, diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index a4993396..28259b32 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -7126,6 +7126,10 @@ func isPrivateIPAddress(raw string) bool { return utils.IsPrivateOrReservedIP(raw) } +func parseAuditDetails(details string) (map[string]any, error) { + return utils.ParseAuditDetails(details) +} + func deriveSessionClientInfo(log domain.AuditLog) (string, string) { details, _ := parseAuditDetails(log.Details) clientID := "" diff --git a/backend/internal/handler/auth_handler_async_test.go b/backend/internal/handler/auth_handler_async_test.go index d3fee7f9..b2dee1ce 100644 --- a/backend/internal/handler/auth_handler_async_test.go +++ b/backend/internal/handler/auth_handler_async_test.go @@ -80,6 +80,7 @@ func (m *AsyncMockUserRepo) Create(ctx context.Context, user *domain.User) error } return args.Error(0) } + func (m *AsyncMockUserRepo) Update(ctx context.Context, user *domain.User) error { args := m.Called(ctx, user) if m.createCalled != nil { @@ -87,6 +88,7 @@ func (m *AsyncMockUserRepo) Update(ctx context.Context, user *domain.User) error } return args.Error(0) } + func (m *AsyncMockUserRepo) Delete(ctx context.Context, id string) error { return nil } func (m *AsyncMockUserRepo) FindByEmail(ctx context.Context, email string) (*domain.User, error) { return nil, nil diff --git a/backend/internal/service/tenant_service_test.go b/backend/internal/service/tenant_service_test.go index 37c68b9b..4b24ad34 100644 --- a/backend/internal/service/tenant_service_test.go +++ b/backend/internal/service/tenant_service_test.go @@ -137,6 +137,7 @@ func (m *MockUserRepoForTenant) CountByTenantIDs(ctx context.Context, tenantIDs } return args.Get(0).(map[string]int64), args.Error(1) } + func (m *MockUserRepoForTenant) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) { args := m.Called(ctx, codes) if args.Get(0) == nil { diff --git a/userfront-e2e/tests/password-and-reset.spec.ts b/userfront-e2e/tests/password-and-reset.spec.ts index 09728c53..e722ef6d 100644 --- a/userfront-e2e/tests/password-and-reset.spec.ts +++ b/userfront-e2e/tests/password-and-reset.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page, type Route } from '@playwright/test'; +import { expect, test, type Locator, type Page, type Route } from '@playwright/test'; type RequestCapture = { loginBody?: Record; @@ -7,15 +7,26 @@ type RequestCapture = { clientLogs: string[]; }; +const resetNewPasswordName = /^(새 비밀번호|ui\.userfront\.reset\.new_password)$/; +const resetConfirmPasswordName = + /^(새 비밀번호 확인|ui\.userfront\.reset\.confirm_password)$/; +const resetSubmitButtonName = /^(비밀번호 변경|ui\.userfront\.reset\.submit)$/; + async function enableFlutterAccessibility(page: Page): Promise { - await page.waitForTimeout(300); const button = page.getByRole('button', { name: 'Enable accessibility' }); if (await button.count()) { - await button.click({ force: true }); - const placeholder = page.locator('flt-semantics-placeholder'); - if (await placeholder.count()) { - await placeholder.first().click({ force: true }); - } + await button.first().evaluate((node) => { + (node as HTMLElement).click(); + }); + await page.waitForTimeout(200); + return; + } + await page.waitForTimeout(300); + const placeholder = page.locator('flt-semantics-placeholder').first(); + if (await placeholder.count()) { + await placeholder.evaluate((node) => { + (node as HTMLElement).click(); + }); await page.waitForTimeout(800); } } @@ -109,6 +120,18 @@ async function fillAt(page: Page, x: number, y: number, value: string): Promise< await page.keyboard.type(value); } +async function typeIntoAccessibleField( + page: Page, + field: Locator, + value: string, +): Promise { + await field.click({ force: true }); + await page.waitForTimeout(100); + await page.keyboard.press('Control+A'); + await page.keyboard.press('Backspace'); + await page.keyboard.type(value); +} + async function fillPasswordLoginForm( page: Page, loginId: string, @@ -128,25 +151,29 @@ async function fillPasswordLoginForm( async function submitPasswordLogin(page: Page): Promise { if (isMobileProject(page)) { + await enableFlutterAccessibility(page); await page.getByRole('button', { name: '로그인' }).click({ force: true }); return; } - const coords = coordsFor(page); - await page.locator('flt-glass-pane').click({ - position: { x: coords.signinSubmitX, y: coords.signinSubmitY }, - force: true, - }); + await page.keyboard.press('Enter'); } async function fillResetPasswordForm(page: Page, password: string): Promise { + await enableFlutterAccessibility(page); + const newPasswordInput = page.getByRole('textbox', { + name: resetNewPasswordName, + }); + const confirmPasswordInput = page.getByRole('textbox', { + name: resetConfirmPasswordName, + }); + if ((await newPasswordInput.count()) > 0 && (await confirmPasswordInput.count()) > 0) { + await typeIntoAccessibleField(page, newPasswordInput, password); + await typeIntoAccessibleField(page, confirmPasswordInput, password); + return; + } if (isMobileProject(page)) { - await enableFlutterAccessibility(page); - await page - .getByRole('textbox', { name: /^새 비밀번호$/ }) - .fill(password); - await page - .getByRole('textbox', { name: /^새 비밀번호 확인$/ }) - .fill(password); + await page.getByRole('textbox', { name: resetNewPasswordName }).fill(password); + await page.getByRole('textbox', { name: resetConfirmPasswordName }).fill(password); return; } const coords = coordsFor(page); @@ -160,8 +187,13 @@ async function fillResetPasswordForm(page: Page, password: string): Promise { + await enableFlutterAccessibility(page); + const submitButton = page.getByRole('button', { name: resetSubmitButtonName }); + if ((await submitButton.count()) > 0) { + await submitButton.click({ force: true }); + return; + } if (isMobileProject(page)) { - await page.getByRole('button', { name: '비밀번호 변경' }).click({ force: true }); return; } const coords = coordsFor(page); diff --git a/userfront/assets/translations/ko.toml b/userfront/assets/translations/ko.toml index 3c565d55..9fc24973 100644 --- a/userfront/assets/translations/ko.toml +++ b/userfront/assets/translations/ko.toml @@ -40,9 +40,6 @@ verify_code_failed = "인증 실패: {error}" [err.userfront.session] missing = "활성 세션이 없습니다." -[msg.userfront] -greeting = "안녕하세요, {name}님" - [msg.userfront.audit] date = "접속일자: {value}" device = "접속환경: {value}" @@ -53,27 +50,6 @@ result = "인증결과: {value}" session_id = "Session ID: {value}" status = "현황: (준비중)" -[msg.userfront.consent] -accept_error = "동의 처리에 실패했습니다: {error}" -client_id = "클라이언트 ID: {id}" -client_unknown = "알 수 없는 앱" -description = "아래 서비스가 회원님의 계정 정보에 접근하려고 합니다.\\\\n계속 진행하려면 동의 여부를 선택해 주세요." -load_error = "동의 정보를 불러오는데 실패했습니다: {error}" -missing_redirect = "동의가 처리되었으나 리다이렉트 URL을 받지 못했습니다." -redirect_notice = "동의 후 자동으로 서비스로 이동합니다." -scope_count = "총 {count}개" - -[msg.userfront.consent.cancel] -confirm = "권한 동의를 취소하면 해당 서비스를 이용할 수 없습니다. 취소하시겠습니까?" -error = "취소 처리 중 오류가 발생했습니다: {error}" - -[msg.userfront.consent.scope] -email = "이메일 주소 (계정 식별 및 알림 용도)" -offline_access = "오프라인 접근 (로그인 유지)" -openid = "OpenID 인증 정보 (로그인 상태 확인)" -phone = "휴대폰 번호 (본인 인증 및 알림)" -profile = "기본 프로필 정보 (이름, 사용자 식별자)" - [msg.userfront.dashboard] approved_device = "승인 기기: {device}" approved_ip = "승인 IP: {ip}" @@ -89,27 +65,6 @@ link_open_error = "해당 링크를 열 수 없습니다." render_error = "대시보드 렌더링 오류: {error}" session_id_copied = "세션 ID가 복사되었습니다." -[msg.userfront.dashboard.activities] -empty = "연동된 앱이 없습니다." -empty_detail = "앱을 연동하면 최근 활동과 상태가 표시됩니다." -error = "연동 정보를 불러오지 못했습니다." - -[msg.userfront.dashboard.approved_session] -copy_click = "{label}: {id}\\\\n클릭하면 복사됩니다." -copy_tap = "{label}: {id}\\\\n탭하면 복사됩니다." -none = "{label} 없음" - -[msg.userfront.dashboard.revoke] -confirm = "{app} 앱과의 연동을 해지하시겠습니까?\\\\n해지하면 다음 로그인 시 다시 동의가 필요합니다." -error = "해지 실패: {error}" -success = "{app} 연동이 해지되었습니다." - -[msg.userfront.dashboard.scopes] -empty = "요청된 권한이 없습니다." - -[msg.userfront.dashboard.timeline] -load_error = "접속이력을 불러오지 못했습니다." - [msg.userfront.error] detail_contact = "관리자에게 문의해 주세요." detail_generic = "오류가 발생했습니다." @@ -120,34 +75,6 @@ title_generic = "오류가 발생했습니다" title_with_code = "오류: {code}" type = "오류 종류: {type}" -[msg.userfront.error.ory] -"$normalizedCode" = "{error}" -access_denied = "사용자가 동의를 거부했습니다." -consent_required = "앱 접근 동의가 필요합니다." -interaction_required = "추가 상호작용이 필요합니다. 다시 시도해 주세요." -invalid_client = "클라이언트 인증 정보가 유효하지 않습니다." -invalid_grant = "인증 요청이 만료되었거나 유효하지 않습니다." -invalid_request = "잘못된 요청입니다." -invalid_scope = "요청한 권한 범위가 유효하지 않습니다." -login_required = "로그인이 필요합니다." -request_forbidden = "요청이 거부되었습니다." -server_error = "인증 서버 오류가 발생했습니다." -temporarily_unavailable = "인증 서버를 일시적으로 사용할 수 없습니다." -unauthorized_client = "해당 클라이언트는 이 요청을 수행할 수 없습니다." -unsupported_response_type = "지원하지 않는 응답 타입입니다." - -[msg.userfront.error.whitelist] -"$normalizedCode" = "{error}" -bad_request = "입력값을 확인해 주세요." -invalid_session = "세션이 만료되었습니다. 다시 로그인해 주세요." -not_found = "요청한 페이지를 찾을 수 없습니다." -password_or_email_mismatch = "이메일 혹은 비밀번호가 일치하지 않습니다." -rate_limited = "요청이 많습니다. 잠시 후 다시 시도해 주세요." -recovery_expired = "재설정 링크가 만료되었습니다. 다시 요청해 주세요." -recovery_invalid = "재설정 링크가 유효하지 않습니다." -settings_disabled = "현재 계정 설정 화면은 준비 중입니다." -verification_required = "추가 인증이 필요합니다. 안내에 따라 진행해 주세요." - [msg.userfront.forgot] description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다." dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다." @@ -317,6 +244,55 @@ complete = "가입 완료" next_step = "다음 단계" title = "회원가입" +[msg.userfront] +greeting = "안녕하세요, {name}님" + +[msg.userfront.audit] +date = "접속일자: {value}" +device = "접속환경: {value}" +end = "더 이상 항목이 없습니다." +ip = "접속 IP: {value}" +load_more_error = "더 불러오지 못했습니다." +result = "인증결과: {value}" +session_id = "Session ID: {value}" +status = "현황: (준비중)" + +[msg.userfront.consent] +accept_error = "동의 처리에 실패했습니다: {error}" +client_id = "클라이언트 ID: {id}" +client_unknown = "알 수 없는 앱" +description = "아래 서비스가 회원님의 계정 정보에 접근하려고 합니다.\\\\n계속 진행하려면 동의 여부를 선택해 주세요." +load_error = "동의 정보를 불러오는데 실패했습니다: {error}" +missing_redirect = "동의가 처리되었으나 리다이렉트 URL을 받지 못했습니다." +redirect_notice = "동의 후 자동으로 서비스로 이동합니다." +scope_count = "총 {count}개" + +[msg.userfront.consent.cancel] +confirm = "권한 동의를 취소하면 해당 서비스를 이용할 수 없습니다. 취소하시겠습니까?" +error = "취소 처리 중 오류가 발생했습니다: {error}" + +[msg.userfront.consent.scope] +email = "이메일 주소 (계정 식별 및 알림 용도)" +offline_access = "오프라인 접근 (로그인 유지)" +openid = "OpenID 인증 정보 (로그인 상태 확인)" +phone = "휴대폰 번호 (본인 인증 및 알림)" +profile = "기본 프로필 정보 (이름, 사용자 식별자)" + +[msg.userfront.dashboard] +approved_device = "승인 기기: {device}" +approved_ip = "승인 IP: {ip}" +audit_empty = "최근 접속 이력이 없습니다." +audit_load_error = "접속이력을 불러오지 못했습니다." +auth_method = "인증수단: {method}" +client_id = "Client ID: {id}" +client_id_missing = "Client ID 없음" +current_status = "현재 상태: {status}" +last_auth = "최근 인증: {value}" +link_missing = "이동할 페이지 주소(Client URI)가 설정되지 않았습니다." +link_open_error = "해당 링크를 열 수 없습니다." +render_error = "대시보드 렌더링 오류: {error}" +session_id_copied = "세션 ID가 복사되었습니다." + [msg.userfront.dashboard.activities] empty = "연동된 앱이 없습니다." empty_detail = "앱을 연동하면 최근 활동과 상태가 표시됩니다." @@ -337,12 +313,12 @@ error = "세션 종료 실패: {error}" success = "세션이 종료되었습니다." [msg.userfront.dashboard.approved_session] -copy_click = "{label}: {id}\n클릭하면 복사됩니다." -copy_tap = "{label}: {id}\n탭하면 복사됩니다." +copy_click = "{label}: {id}\\\\n클릭하면 복사됩니다." +copy_tap = "{label}: {id}\\\\n탭하면 복사됩니다." none = "{label} 없음" [msg.userfront.dashboard.revoke] -confirm = "{app} 앱과의 연동을 해지하시겠습니까?\n해지하면 다음 로그인 시 다시 동의가 필요합니다." +confirm = "{app} 앱과의 연동을 해지하시겠습니까?\\\\n해지하면 다음 로그인 시 다시 동의가 필요합니다." error = "해지 실패: {error}" success = "{app} 연동이 해지되었습니다." @@ -352,17 +328,15 @@ empty = "요청된 권한이 없습니다." [msg.userfront.dashboard.timeline] load_error = "접속이력을 불러오지 못했습니다." -[msg.userfront.error.whitelist] -"$normalizedCode" = "{error}" -bad_request = "입력값을 확인해 주세요." -invalid_session = "세션이 만료되었습니다. 다시 로그인해 주세요." -not_found = "요청한 페이지를 찾을 수 없습니다." -password_or_email_mismatch = "이메일 혹은 비밀번호가 일치하지 않습니다." -rate_limited = "요청이 많습니다. 잠시 후 다시 시도해 주세요." -recovery_expired = "재설정 링크가 만료되었습니다. 다시 요청해 주세요." -recovery_invalid = "재설정 링크가 유효하지 않습니다." -settings_disabled = "현재 계정 설정 화면은 준비 중입니다." -verification_required = "추가 인증이 필요합니다. 안내에 따라 진행해 주세요." +[msg.userfront.error] +detail_contact = "관리자에게 문의해 주세요." +detail_generic = "오류가 발생했습니다." +detail_request = "요청을 처리하는 중 문제가 발생했습니다." +id = "오류 ID: {id}" +title = "인증 과정에서 오류가 발생했습니다" +title_generic = "오류가 발생했습니다" +title_with_code = "오류: {code}" +type = "오류 종류: {type}" [msg.userfront.error.ory] "$normalizedCode" = "{error}" @@ -380,6 +354,41 @@ temporarily_unavailable = "인증 서버를 일시적으로 사용할 수 없습 unauthorized_client = "해당 클라이언트는 이 요청을 수행할 수 없습니다." unsupported_response_type = "지원하지 않는 응답 타입입니다." +[msg.userfront.error.whitelist] +"$normalizedCode" = "{error}" +bad_request = "입력값을 확인해 주세요." +invalid_session = "세션이 만료되었습니다. 다시 로그인해 주세요." +not_found = "요청한 페이지를 찾을 수 없습니다." +password_or_email_mismatch = "이메일 혹은 비밀번호가 일치하지 않습니다." +rate_limited = "요청이 많습니다. 잠시 후 다시 시도해 주세요." +recovery_expired = "재설정 링크가 만료되었습니다. 다시 요청해 주세요." +recovery_invalid = "재설정 링크가 유효하지 않습니다." +settings_disabled = "현재 계정 설정 화면은 준비 중입니다." +verification_required = "추가 인증이 필요합니다. 안내에 따라 진행해 주세요." + +[msg.userfront.forgot] +description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다." +dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다." +error = "전송에 실패했습니다: {error}" +input_required = "이메일 또는 휴대폰 번호를 입력해주세요." +sent = "비밀번호 재설정 링크가 전송되었습니다. 이메일 또는 SMS를 확인해주세요." + +[msg.userfront.login] +cookie_check_failed = "로그인 확인 실패: {error}" +dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다." +link_failed = "오류: {error}" +link_send_failed = "전송 실패: {error}" +link_sent_email = "입력하신 이메일로 로그인 링크를 보냈습니다." +link_sent_phone = "입력하신 번호로 로그인 링크를 보냈습니다." +link_timeout = "시간이 경과되었습니다." +no_account = "계정이 없으신가요?" +oidc_failed = "OIDC 로그인 처리에 실패했습니다. 다시 시도해 주세요." +qr_expired = "시간이 경과되었습니다." +qr_init_failed = "QR 초기화에 실패했습니다: {error}" +qr_login_required = "로그인 한 상태여야 QR 스캔으로 로그인 할 수 있습니다" +token_missing = "로그인 토큰을 확인할 수 없습니다." +verification_failed = "승인 처리에 실패했습니다: {error}" + [msg.userfront.login.link] approved = "msg.userfront.login.link.approved" helper = "입력하신 정보로 로그인 링크를 전송합니다." diff --git a/userfront/assets/translations/template.toml b/userfront/assets/translations/template.toml index e569bcfc..c902ac09 100644 --- a/userfront/assets/translations/template.toml +++ b/userfront/assets/translations/template.toml @@ -40,18 +40,41 @@ verify_code_failed = "" [err.userfront.session] missing = "" -[msg.userfront] -greeting = "" +[msg.userfront.error] +detail_contact = "" +detail_generic = "" +detail_request = "" +id = "" +title = "" +title_generic = "" +title_with_code = "" +type = "" -[msg.userfront.audit] -date = "" -device = "" -end = "" -ip = "" -load_more_error = "" -result = "" -session_id = "" -status = "" +[msg.userfront.forgot] +description = "" +dry_send = "" +error = "" +input_required = "" +sent = "" + +[msg.userfront.login] +cookie_check_failed = "" +dry_send = "" +link_failed = "" +link_send_failed = "" +link_sent_email = "" +link_sent_phone = "" +link_timeout = "" +no_account = "" +oidc_failed = "" +qr_expired = "" +qr_init_failed = "" +qr_login_required = "" +token_missing = "" +verification_failed = "" + +[msg.userfront.login_success] +subtitle = "" [msg.userfront.consent] accept_error = "" @@ -63,16 +86,6 @@ missing_redirect = "" redirect_notice = "" scope_count = "" -[msg.userfront.consent.cancel] -confirm = "" -error = "" - -[msg.userfront.consent.scope] -email = "" -offline_access = "" -openid = "" -phone = "" - [msg.userfront.profile] department_missing = "" department_required = "" @@ -206,6 +219,40 @@ complete = "" next_step = "" title = "" +[msg.userfront] +greeting = "" + +[msg.userfront.audit] +date = "" +device = "" +end = "" +ip = "" +load_more_error = "" +result = "" +session_id = "" +status = "" + +[msg.userfront.consent] +accept_error = "" +client_id = "" +client_unknown = "" +description = "" +load_error = "" +missing_redirect = "" +redirect_notice = "" +scope_count = "" + +[msg.userfront.consent.cancel] +confirm = "" +error = "" + +[msg.userfront.consent.scope] +email = "" +offline_access = "" +openid = "" +phone = "" +profile = "" + [msg.userfront.dashboard] approved_device = "" approved_ip = "" diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index ec300fe3..460e56db 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -1032,13 +1032,22 @@ class _LoginScreenState extends ConsumerState webWindow.redirectTo(redirectTo); } else {} } catch (e) { + final errorMessage = e.toString().replaceFirst('Exception: ', ''); + try { + await AuthProxyService.logError( + '[PasswordLogin] $errorMessage', + error: e, + ); + } catch (_) { + // Ignore client-log relay failures and continue with user feedback. + } if (e.toString().contains("User not registered")) { _showUnregisteredDialog(); } else { _showError( tr( 'msg.userfront.login.password.failed', - params: {'error': e.toString().replaceFirst('Exception: ', '')}, + params: {'error': errorMessage}, ), ); }