From c14580e3a4b5160818324e9b221d0620adbbb70c Mon Sep 17 00:00:00 2001 From: ttipo Date: Fri, 6 Nov 2026 15:57:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EB=84=A4=20=EB=8B=A8=EA=B3=84=20=ED=99=94=EB=A9=B4=EC=9D=84=20?= =?UTF-8?q?=EB=A7=8C=EB=93=A0=EB=8B=A4=20(=EC=95=B1=201=EB=8B=A8=EA=B3=84?= =?UTF-8?q?=20=E2=80=94=20App1Login)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주소 · 비밀번호 → 인증 앱 코드 → 새 비밀번호 정하기(임시 비밀번호) → 2단계 켜기(필수일 때)를 세션 노티파이어의 상태로 고른다. 첫 단계만 큰 브랜드 띠, 그다음은 짙은 띠에 [다른 계정으로 로그인]. - 2단계 켜기는 같은 휴대폰이라 QR 대신 [인증 앱에 추가](otpauth — url_launcher 6.3.3, canLaunchUrl 은 주소를 로그에 남겨 쓰지 않는다) · 키 [복사] · 코드 칸 - 실패는 칸 아래 · 위쪽 상자 · 전역 알림으로 웹 로그인 틀과 같게(REQ_001 은 코드 · 제한 단계에서 상자), 칸의 안내와 오류를 함께, 세션 끝은 로그인 화면 상자만 - 비밀번호 · 코드 · 2단계 키는 화면 메모리와 노티파이어의 10분 기억에만(두 제한 단계에서만 쥔다), 비밀 요청의 실패 예외는 본문을 비운다 — 시험이 모든 프로바이더 값에 비밀이 없음을 본다 - 제한 단계를 보내는 동안 [다른 계정으로 로그인]을 막는다, 주소 칸만 자판 추천(@ 자판) - 한글 낱말 단위 줄바꿈(KeepAllText), 시험 227건 리뷰 반영: 모르는 제한에서 비밀번호를 쥐지 않음, REQ_001 자리, 안내 · 오류 함께, 주소 칸 자판, 계약 테스트 목록(AUTH_005 · AUTH_008), AUTH_002 를 두 번 알리지 않음. Co-Authored-By: Claude Opus 5.5 --- flutter/assets/brand/logo-ara-white.png | Bin 0 -> 12602 bytes flutter/assets/brand/logo-arasystem-white.png | Bin 0 -> 63405 bytes flutter/lib/core/api/api_config.dart | 4 + flutter/lib/core/api/api_paths.dart | 16 + flutter/lib/core/api/error_notifier.dart | 5 +- flutter/lib/core/constants/app_assets.dart | 10 + flutter/lib/core/constants/app_constants.dart | 4 + flutter/lib/core/constants/login_texts.dart | 164 ++ flutter/lib/core/constants/screen_titles.dart | 22 +- flutter/lib/core/router/app_router.dart | 27 +- flutter/lib/core/theme/app_spacing.dart | 27 + flutter/lib/core/theme/app_theme.dart | 32 + flutter/lib/core/theme/app_typography.dart | 12 + flutter/lib/core/utils/display_text.dart | 40 + flutter/lib/core/utils/korean_text.dart | 34 + flutter/lib/core/utils/otp_code.dart | 33 + flutter/lib/core/utils/password_rules.dart | 61 + .../auth/data/authenticator_launcher.dart | 37 + .../data/models/two_factor_enrollment.dart | 48 + .../auth/data/models/two_factor_status.dart | 17 + .../data/repositories/auth_repository.dart | 44 +- .../support_contact_repository.dart | 26 + .../repositories/two_factor_repository.dart | 84 + .../auth/presentation/login_failures.dart | 28 + .../presentation/pages/login_code_page.dart | 162 ++ .../auth/presentation/pages/login_page.dart | 218 ++ .../pages/password_change_page.dart | 436 ++++ .../pages/restricted_login_page.dart | 34 + .../pages/two_factor_setup_page.dart | 633 ++++++ .../providers/login_providers.dart | 62 + .../presentation/providers/login_secret.dart | 18 +- .../providers/session_provider.dart | 205 +- .../presentation/providers/session_state.dart | 14 +- .../presentation/widgets/login_fields.dart | 395 ++++ .../presentation/widgets/login_frame.dart | 376 ++++ .../presentation/widgets/login_parts.dart | 317 +++ flutter/lib/shared/widgets/keep_all_text.dart | 17 + .../flutter/generated_plugin_registrant.cc | 4 + flutter/linux/flutter/generated_plugins.cmake | 1 + .../Flutter/GeneratedPluginRegistrant.swift | 2 + flutter/pubspec.lock | 64 + flutter/pubspec.yaml | 5 + flutter/test/app_test.dart | 14 +- .../test/core/api/auth_interceptor_test.dart | 46 +- flutter/test/core/utils/login_input_test.dart | 136 ++ flutter/test/error_lexicon_test.dart | 6 +- .../test/features/auth/login_pages_test.dart | 1873 +++++++++++++++++ .../test/features/auth/login_secret_test.dart | 47 + .../features/auth/restricted_steps_test.dart | 452 ++++ .../features/auth/session_provider_test.dart | 12 +- .../features/auth/two_factor_parts_test.dart | 159 ++ flutter/test/support/login_fakes.dart | 183 ++ .../flutter/generated_plugin_registrant.cc | 3 + .../windows/flutter/generated_plugins.cmake | 1 + 54 files changed, 6597 insertions(+), 73 deletions(-) create mode 100644 flutter/assets/brand/logo-ara-white.png create mode 100644 flutter/assets/brand/logo-arasystem-white.png create mode 100644 flutter/lib/core/constants/app_assets.dart create mode 100644 flutter/lib/core/constants/login_texts.dart create mode 100644 flutter/lib/core/utils/display_text.dart create mode 100644 flutter/lib/core/utils/korean_text.dart create mode 100644 flutter/lib/core/utils/otp_code.dart create mode 100644 flutter/lib/core/utils/password_rules.dart create mode 100644 flutter/lib/features/auth/data/authenticator_launcher.dart create mode 100644 flutter/lib/features/auth/data/models/two_factor_enrollment.dart create mode 100644 flutter/lib/features/auth/data/models/two_factor_status.dart create mode 100644 flutter/lib/features/auth/data/repositories/support_contact_repository.dart create mode 100644 flutter/lib/features/auth/data/repositories/two_factor_repository.dart create mode 100644 flutter/lib/features/auth/presentation/login_failures.dart create mode 100644 flutter/lib/features/auth/presentation/pages/login_code_page.dart create mode 100644 flutter/lib/features/auth/presentation/pages/login_page.dart create mode 100644 flutter/lib/features/auth/presentation/pages/password_change_page.dart create mode 100644 flutter/lib/features/auth/presentation/pages/restricted_login_page.dart create mode 100644 flutter/lib/features/auth/presentation/pages/two_factor_setup_page.dart create mode 100644 flutter/lib/features/auth/presentation/providers/login_providers.dart create mode 100644 flutter/lib/features/auth/presentation/widgets/login_fields.dart create mode 100644 flutter/lib/features/auth/presentation/widgets/login_frame.dart create mode 100644 flutter/lib/features/auth/presentation/widgets/login_parts.dart create mode 100644 flutter/lib/shared/widgets/keep_all_text.dart create mode 100644 flutter/test/core/utils/login_input_test.dart create mode 100644 flutter/test/features/auth/login_pages_test.dart create mode 100644 flutter/test/features/auth/login_secret_test.dart create mode 100644 flutter/test/features/auth/restricted_steps_test.dart create mode 100644 flutter/test/features/auth/two_factor_parts_test.dart create mode 100644 flutter/test/support/login_fakes.dart diff --git a/flutter/assets/brand/logo-ara-white.png b/flutter/assets/brand/logo-ara-white.png new file mode 100644 index 0000000000000000000000000000000000000000..6766d3ac2ff359e36cc2e066fee266e29db3f0d2 GIT binary patch literal 12602 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⁡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 literal 0 HcmV?d00001 diff --git a/flutter/lib/core/api/api_config.dart b/flutter/lib/core/api/api_config.dart index 91b7cb6..cfca0d0 100644 --- a/flutter/lib/core/api/api_config.dart +++ b/flutter/lib/core/api/api_config.dart @@ -11,6 +11,10 @@ const String kDefaultApiUrl = 'http://localhost:3000/api'; const Duration kApiConnectTimeout = Duration(seconds: 15); const Duration kApiReceiveTimeout = Duration(seconds: 15); +/// 보안 설정(비밀번호 바꾸기 · 2단계 인증 켜기)의 받기 한도 — 서버가 엔진을 여러 번 부르고 세션을 끊는다. +/// 기본 한도로 끊으면 화면은 실패로 보는데 서버는 이미 바꿨을 수 있다(계약 1절 · 웹 `PASSWORD_CHANGE_TIMEOUT_MS` 와 같은 30초) +const Duration kSecuritySettingTimeout = Duration(seconds: 30); + /// API 연결 설정 class ApiConfig { const ApiConfig({ diff --git a/flutter/lib/core/api/api_paths.dart b/flutter/lib/core/api/api_paths.dart index f1ceb8d..f0409f9 100644 --- a/flutter/lib/core/api/api_paths.dart +++ b/flutter/lib/core/api/api_paths.dart @@ -8,6 +8,22 @@ abstract final class ApiPaths { /// 세션 확인 — 웹과 같은 경로를 Bearer 로 부른다 static const String me = '/auth/me'; + // ── 로그인 틀의 제한 단계 (계약 5 · 6절 — 제한된 세션도 부를 수 있는 경로) ── + /// 비밀번호 바꾸기 — 새 비밀번호 단계 + static const String password = '/auth/password'; + + /// 2단계 인증 상태 — 새 비밀번호 단계가 코드 칸을 보일지 · 그사이 다른 곳에서 켰는지 + static const String twoFactor = '/two-factor'; + + /// 2단계 인증 등록 시작 — 새 키(10분) + static const String twoFactorEnrollments = '/two-factor/enrollments'; + + /// 2단계 인증 켜기 + static const String twoFactorActivation = '/two-factor/activation'; + + /// 문의 연락처 (로그인 없음) + static const String supportContact = '/support-contact'; + /// 401 에도 갱신하지 않는 경로 — 앱 login · refresh · logout (계약 1절 "앱 로그인 › 앱이 할 일") static const Set noRefresh = { appLogin, diff --git a/flutter/lib/core/api/error_notifier.dart b/flutter/lib/core/api/error_notifier.dart index f24b587..943fab6 100644 --- a/flutter/lib/core/api/error_notifier.dart +++ b/flutter/lib/core/api/error_notifier.dart @@ -1,5 +1,6 @@ import 'package:arasystem_app/core/api/api_exception.dart'; import 'package:arasystem_app/core/api/request_flags.dart'; +import 'package:arasystem_app/core/api/token_refresher.dart'; import 'package:arasystem_app/core/utils/clock.dart'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; @@ -54,7 +55,8 @@ class ErrorNotifier { /// 실패를 알리는 인터셉터 — 인터셉터 줄의 맨 끝에 둔다(갱신 · 다시 보내기가 끝난 뒤의 결과만 알린다). /// /// 알리지 않는 것: 요청의 `silent`([kSilentExtraKey]) · 취소한 요청 · 인증 인터셉터가 조용히 끝낸 실패 -/// (제한을 확인한 `AUTH_010` · 사용자가 로그아웃한 뒤의 늦은 401 — 이 인터셉터까지 오지 않는다). +/// (제한을 확인한 `AUTH_010` · 사용자가 로그아웃한 뒤의 늦은 401 — 이 인터셉터까지 오지 않는다) · +/// 세션 끝(`AUTH_002`) — 세션이 끝나면 늘 로그인 화면으로 가고, 그 화면의 위쪽 상자가 같은 사전 문구로 알린다(두 번 뜨지 않게). class ErrorNotifyInterceptor extends Interceptor { ErrorNotifyInterceptor(this._notifier); @@ -65,6 +67,7 @@ class ErrorNotifyInterceptor extends Interceptor { final Object? error = err.error; if (err.type != DioExceptionType.cancel && error is ApiException && + error.code != kSessionExpiredCode && !isSilentFor(err.requestOptions, error.code)) { _notifier.show(error); } diff --git a/flutter/lib/core/constants/app_assets.dart b/flutter/lib/core/constants/app_assets.dart new file mode 100644 index 0000000..ef602dc --- /dev/null +++ b/flutter/lib/core/constants/app_assets.dart @@ -0,0 +1,10 @@ +/// 앱에 넣은 그림 파일 경로 (pubspec `assets`). +/// +/// 흰 로고 둘은 웹 `frontend/src/assets/brand/` 와 같은 파일이다(원본 `docs/brand/`) — 어두운 브랜드 바탕(#0B3640) 위에만 둔다. +abstract final class AppAssets { + /// 전체 로고(ARA SYSTEM) — 로그인 첫 화면의 브랜드 띠. 원본 960×459 + static const String logoFull = 'assets/brand/logo-arasystem-white.png'; + + /// ARA 마크 — 로그인 다음 단계의 짙은 띠. 원본 320×111 + static const String logoMark = 'assets/brand/logo-ara-white.png'; +} diff --git a/flutter/lib/core/constants/app_constants.dart b/flutter/lib/core/constants/app_constants.dart index fec044b..7787fa6 100644 --- a/flutter/lib/core/constants/app_constants.dart +++ b/flutter/lib/core/constants/app_constants.dart @@ -7,6 +7,10 @@ library; /// 홈 화면 · 작업 전환 화면에 보이는 앱 이름 const String kAppName = 'ARA SYSTEM'; +/// 회사 메일 도메인 — 화면 안내 글에만 쓴다(로그인 화면의 "회사 메일 주소(@…)"). +/// 허용 도메인 검사는 서버가 한다. 웹 `frontend/src/shared/constants/brand.ts` 의 `COMPANY_MAIL_DOMAIN` 과 같은 값 +const String kCompanyMailDomain = 'arasystem.it.kr'; + /// 서버가 앱을 알아보는 User-Agent 제품 이름 — `ARASYSTEM-App/<판> (Android <판>; <기종>)` /// (계약 `docs/mail/api-contract.md` 1절 "앱 로그인 › 지금 세션" — 서버가 "ARA SYSTEM 앱 · Android" 꼴로 보인다) const String kUserAgentProduct = 'ARASYSTEM-App'; diff --git a/flutter/lib/core/constants/login_texts.dart b/flutter/lib/core/constants/login_texts.dart new file mode 100644 index 0000000..6f4e0d8 --- /dev/null +++ b/flutter/lib/core/constants/login_texts.dart @@ -0,0 +1,164 @@ +/// 로그인 틀 네 단계의 화면 글 — 원본은 앱 보드 `App1Login`(`docs/design/webmail/App1Login.dc.html`)이다. +/// +/// 보드에 없는 상태(보내는 중 · 임시 비밀번호 칸 · 코드 칸 · 마친 뒤 알림 등)의 글은 웹 로그인 틀의 상수 +/// (`frontend/src/shared/constants/account-security.ts` · `session-restriction.ts`)를 그대로 옮겼다 — 웹과 앱을 함께 쓰는 직원에게 같은 말. +/// 서버가 거절한 까닭의 문구는 여기 두지 않는다 — 에러 코드 사전(`ErrorLexicon`)에서만 온다. +/// 제목은 `screen_titles.dart`. 말투는 디자인 README 「화면 문구 — 사용자에게 말하듯」. +library; + +import 'package:arasystem_app/core/constants/app_constants.dart'; + +/// 첫 단계 — 주소와 비밀번호 +abstract final class LoginTexts { + /// 브랜드 띠의 큰 글 + static const String brandHeadline = '사내 업무를 한 계정으로'; + + /// 브랜드 띠의 소개 글 + static const String brandLead = '메일부터 시작해 캘린더와 파일 저장소까지 같은 계정으로 이어집니다.'; + + static const String subtitle = '회사 메일 주소(@$kCompanyMailDomain)로 로그인해 주세요.'; + + static const String emailLabel = '메일 주소'; + static const String emailPlaceholder = 'name@$kCompanyMailDomain'; + static const String passwordLabel = '비밀번호'; + static const String passwordPlaceholder = '비밀번호 입력'; + + /// [보기] 단추의 이름 (눌러 둔 상태는 화면 낭독기가 따로 읽는다) + static const String showPassword = '비밀번호 보기'; + + static const String submit = '로그인'; + + /// 보내는 동안 — 웹과 같다 + static const String submitting = '로그인하는 중…'; + + /// 코드 단계에 10분 넘게 머문 뒤 [확인]을 눌러 돌아왔을 때 위쪽 상자 (계약 5절 "로그인" 클라이언트 — 웹과 같다) + static const String codeTimedOut = '코드를 넣는 시간이 지났습니다. 다시 로그인해 주세요.'; +} + +/// 다음 단계들의 짙은 띠 +abstract final class LoginBandTexts { + static const String otherAccount = '다른 계정으로 로그인'; +} + +/// 문의 줄 — 연락처(`GET /api/support-contact`)가 있으면 물음 다음 줄에 연락처, 없거나 받지 못하면 지금 글 그대로(웹과 같다) +abstract final class SupportLineTexts { + /// 첫 단계 + static const String passwordQuestion = '비밀번호를 잊으셨나요?'; + static const String passwordFallback = '비밀번호를 잊으셨다면 시스템 관리자에게 문의해 주세요.'; + + /// 코드 단계 · 2단계 인증 켜기 + static const String authenticatorQuestion = '인증 앱을 쓸 수 없나요?'; + static const String authenticatorFallback = '인증 앱을 쓸 수 없나요? 관리자에게 문의해 주세요.'; +} + +/// 둘째 단계 — 2단계 인증 코드 (2단계를 켠 직원) +abstract final class LoginCodeTexts { + static const String subtitle = '비밀번호를 확인했습니다. 휴대폰 인증 앱에 표시된 코드를 넣어 주세요.'; + static const String codeLabel = '인증 앱에 표시된 6자리 코드'; + static const String submit = '확인'; + static const String submitting = '확인하는 중…'; + static const String note = + 'Outlook이나 휴대폰 기본 메일 앱 같은 다른 메일 프로그램은 이 코드를 묻지 않습니다. 웹메일의 [설정] → [앱 비밀번호]에서 만든 비밀번호로 연결해 주세요.'; +} + +/// 인증 앱 코드 칸 (코드 단계 · 새 비밀번호 단계 · 2단계 켜기) +abstract final class OtpTexts { + static const String placeholder = '000000'; +} + +/// 제한 단계 공용 — 단계는 마쳤지만 다음으로 넘기지 못했을 때(새 토큰 받기 · 세션 확인 모두 장애) 위쪽 상자의 단추 +abstract final class RestrictedStepTexts { + static const String retry = '다시 시도'; +} + +/// 셋째 단계 — 새 비밀번호 정하기 (제한 `passwordChange`) +abstract final class PasswordChangeTexts { + static const String subtitle = + '관리자가 정한 임시 비밀번호로 로그인했습니다. 메일을 쓰려면 먼저 새 비밀번호를 정해 주세요.'; + + /// 로그인 때 넣은 비밀번호를 쥐고 있지 않을 때만(앱을 다시 열었다 · 10분 지남) 맨 위에 — 웹과 같다 + static const String temporaryLabel = '임시 비밀번호'; + static const String showTemporary = '임시 비밀번호 보기'; + + /// 비우고 보내면 이 글이 빨간 오류가 된다 + static const String temporaryHint = '관리자에게 받은 임시 비밀번호를 다시 넣어 주세요.'; + + static const String nextLabel = '새 비밀번호'; + static const String showNext = '새 비밀번호 보기'; + + /// 규칙(8자 이상 · 임시 비밀번호와 다름)에 맞지 않으면 이 글이 빨간 오류가 된다 + static const String nextHint = + '8자 이상이고 임시 비밀번호와 달라야 합니다. 영문, 숫자, 기호를 섞으면 더 안전합니다.'; + + /// 보기 단추 없이 새 비밀번호의 [보기]를 따른다 + static const String confirmLabel = '새 비밀번호 확인'; + static const String mismatch = '새 비밀번호가 서로 다릅니다.'; + + /// 2단계 인증을 켠 직원만 — 맨 아래 (로그인 때 쓴 코드는 2분 동안 다시 받지 않는다 — 웹과 같다) + static const String codeLabel = '인증 앱 코드'; + static const String codeHint = + '로그인할 때 넣은 코드는 다시 쓸 수 없으니, 앱에 다음 코드가 뜨면 넣어 주세요.'; + + static const String submit = '비밀번호 바꾸고 계속'; + static const String submitting = '바꾸는 중…'; + static const String note = '아웃룩 같은 메일 프로그램을 연결해 두었다면 비밀번호를 바꾼 뒤 다시 연결해 주세요.'; + + /// 마친 뒤 알림 — 웹과 같다 + static const String done = '새 비밀번호를 정했습니다.'; +} + +/// 넷째 단계 — 2단계 인증 켜기 (제한 `twoFactorSetup`). 같은 휴대폰이라 QR 대신 [인증 앱에 추가] · 키 · [복사] +abstract final class TwoFactorSetupTexts { + static const String subtitle = + '회사 정책에 따라 로그인할 때 휴대폰 인증 앱의 코드도 확인합니다. 지금 켜면 바로 메일로 이어집니다.'; + + /// ① 인증 앱에 추가 + static const String addTitle = '인증 앱에 추가해 주세요'; + static const String addHint = + '이 휴대폰의 인증 앱에 바로 추가합니다. 앱이 없으면 Google Authenticator나 Microsoft Authenticator를 먼저 설치해 주세요.'; + static const String addButton = '인증 앱에 추가'; + + /// [인증 앱에 추가]로 열 앱을 찾지 못했을 때 + static const String addFailed = + '이 휴대폰에서 인증 앱을 찾지 못했습니다. 인증 앱을 설치한 뒤 다시 눌러 주세요. 아래 키를 직접 입력해도 됩니다.'; + + static const String keyLabel = '추가되지 않으면 이 키를 인증 앱에 직접 입력해 주세요'; + static const String copy = '복사'; + + /// [복사]의 화면 낭독기 이름 + static const String copyLabel = '키 복사'; + + /// 복사한 뒤 알림 — 웹과 같다. 키는 클립보드에 남는다(앱이 지우지 않는다) + static const String copied = '키를 복사했습니다.'; + + /// 복사하지 못했을 때 — 앱의 키는 골라 복사할 수 없어 웹 글("직접 골라 복사")을 고쳤다 + static const String copyFailed = '키를 복사하지 못했습니다. 키를 보고 인증 앱에 직접 입력해 주세요.'; + + /// 인증 앱에 저장될 이름 — 서버의 otpauth 주소와 같은 이름(계약 5절) + static String savedAs(String email) => '앱에는 ‘ARA SYSTEM ($email)’로 저장됩니다.'; + + /// 키를 받는 동안 회색 자리의 화면 낭독기 글 + static const String keyLoading = '키를 만드는 중…'; + + /// 키를 받지 못했을 때 사전 문구 옆의 단추 — 웹과 같다 + static const String retry = '다시 시도'; + + /// ② 코드 + static const String codeLabel = '앱에 표시된 6자리 코드'; + static const String codeHint = '인증 앱에서 코드를 복사해 와서 붙여 넣어도 됩니다.'; + + /// ③ 지금 비밀번호 — 로그인 때 넣은 비밀번호를 쥐고 있지 않을 때만(앱을 다시 열었다 · 10분 지남) + static const String passwordLabel = '지금 비밀번호'; + static const String showPassword = '지금 비밀번호 보기'; + + /// 비우고 보내면 이 글이 빨간 오류가 된다 (웹의 "새로고침한 뒤에는…"을 앱에 맞게 고쳤다) + static const String passwordHint = '지금 비밀번호를 한 번 더 넣어 주세요.'; + + static const String submit = '켜고 계속'; + static const String submitting = '켜는 중…'; + static const String note = + 'Outlook이나 휴대폰 기본 메일 앱 같은 다른 메일 프로그램은 이 코드를 묻지 않습니다. 켠 뒤에는 웹메일의 [설정] → [앱 비밀번호]에서 만든 비밀번호로 연결해 주세요.'; + + /// 켠 뒤 알림 — 웹과 같다(웹의 [앱 비밀번호 만들기] 단추는 앱에 그 화면이 없어 뺐다) + static const String done = '2단계 인증을 켰습니다. 메일 프로그램은 앱 비밀번호로 연결합니다.'; +} diff --git a/flutter/lib/core/constants/screen_titles.dart b/flutter/lib/core/constants/screen_titles.dart index d65c4b5..5bdc98a 100644 --- a/flutter/lib/core/constants/screen_titles.dart +++ b/flutter/lib/core/constants/screen_titles.dart @@ -1,24 +1,20 @@ -/// 로그인 전 · 제한 단계 화면 이름 — 지금은 자리 화면(이름만 보인다)이 쓴다. -/// 모듈 화면의 이름은 그 모듈 안에 둔다(`features/<모듈>/presentation/<모듈>_texts.dart`). +/// 로그인 전 · 제한 단계 화면의 제목 — 앱 보드 `App1Login` 의 제목 그대로다(화면 낭독기가 단계가 바뀔 때 이 글을 읽는다). +/// 그 밖의 화면 글은 `login_texts.dart`, 모듈 화면의 글은 그 모듈 안(`features/<모듈>/presentation/<모듈>_texts.dart`)에 둔다. /// -/// 화면은 캔버스로 확인받은 뒤 만든다. 그때 화면 글은 이 파일 같은 상수에 모으고 -/// 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다. +/// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다. abstract final class ScreenTitles { - /// 세션을 되살리는 동안 + /// 세션을 되살리는 동안 — 아직 자리 화면(보드 없음) static const String start = '시작하는 중'; - /// 로그인 — 비밀번호 단계 + /// 로그인 — 주소와 비밀번호 static const String login = '로그인'; - /// 로그인 — 2단계 인증 코드 단계 - static const String loginCode = '인증 코드 입력'; + /// 로그인 — 2단계 인증 코드 + static const String loginCode = '2단계 인증'; /// 제한된 세션 — 새 비밀번호 정하기 (passwordChange) - static const String passwordChange = '새 비밀번호 정하기'; + static const String passwordChange = '새 비밀번호를 정해 주세요'; /// 제한된 세션 — 2단계 인증 켜기 (twoFactorSetup) - static const String twoFactorSetup = '2단계 인증 켜기'; - - /// 제한된 세션 — 앱이 모르는 제한 (웹과 같이 사전 문구와 [다른 계정으로 로그인]만) - static const String restricted = '로그인 제한'; + static const String twoFactorSetup = '2단계 인증을 켜 주세요'; } diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart index 23d2d80..8a0784b 100644 --- a/flutter/lib/core/router/app_router.dart +++ b/flutter/lib/core/router/app_router.dart @@ -5,6 +5,11 @@ import 'package:arasystem_app/core/router/app_routes.dart'; import 'package:arasystem_app/core/router/app_shell.dart'; import 'package:arasystem_app/core/router/module_registry.dart'; import 'package:arasystem_app/core/router/session_redirect.dart'; +import 'package:arasystem_app/features/auth/presentation/pages/login_code_page.dart'; +import 'package:arasystem_app/features/auth/presentation/pages/login_page.dart'; +import 'package:arasystem_app/features/auth/presentation/pages/password_change_page.dart'; +import 'package:arasystem_app/features/auth/presentation/pages/restricted_login_page.dart'; +import 'package:arasystem_app/features/auth/presentation/pages/two_factor_setup_page.dart'; import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; import 'package:arasystem_app/shared/widgets/placeholder_page.dart'; @@ -24,8 +29,8 @@ final Provider> appModulesProvider = Provider>( /// 라우트는 모듈이 내놓은 묶음(`features/<모듈>/<모듈>_routes.dart`)을 module_registry 가 모은 것이다. /// - 모르는 주소 · 꺼진 모듈의 주소는 메일 첫 화면으로 간다(redirect · onException). /// -/// 로그인 단계 화면은 이름만 보이는 자리 화면이다 — 캔버스(로그인 4단계)대로 만들면 -/// `features/auth/presentation/pages/` 의 화면으로 바꾼다(경로 · redirect 는 그대로 둔다). +/// 로그인 틀 네 단계(앱 보드 `App1Login`)와 모르는 제한은 `features/auth/presentation/pages/` 의 화면이다. +/// 시작 화면(되살리는 중 · 되살리기 실패)은 아직 자리 화면이다(보드 없음). final Provider routerProvider = Provider((Ref ref) { final List modules = ref.watch(appModulesProvider); final List enabled = enabledAppModules(modules); @@ -59,11 +64,11 @@ final Provider routerProvider = Provider((Ref ref) { router.go(homeLocationOf(modules)), routes: [ _placeholderRoute(AppRoutes.start, ScreenTitles.start), - _placeholderRoute(AppRoutes.login, ScreenTitles.login), - _placeholderRoute(AppRoutes.loginCode, ScreenTitles.loginCode), - _placeholderRoute(AppRoutes.passwordChange, ScreenTitles.passwordChange), - _placeholderRoute(AppRoutes.twoFactorSetup, ScreenTitles.twoFactorSetup), - _placeholderRoute(AppRoutes.restricted, ScreenTitles.restricted), + _loginRoute(AppRoutes.login, const LoginPage()), + _loginRoute(AppRoutes.loginCode, const LoginCodePage()), + _loginRoute(AppRoutes.passwordChange, const PasswordChangePage()), + _loginRoute(AppRoutes.twoFactorSetup, const TwoFactorSetupPage()), + _loginRoute(AppRoutes.restricted, const RestrictedLoginPage()), StatefulShellRoute.indexedStack( builder: ( @@ -94,6 +99,14 @@ GoRoute _placeholderRoute(String path, String title) { ); } +/// 로그인 틀의 한 단계 — 세션이 바뀌면 redirect 가 단계를 바꾼다(화면끼리 서로 옮기지 않는다) +GoRoute _loginRoute(String path, Widget page) { + return GoRoute( + path: path, + builder: (BuildContext context, GoRouterState state) => page, + ); +} + /// 세션이 바뀐 것을 라우터에 알린다 (refreshListenable) class _SessionChanges extends ChangeNotifier { void notify() => notifyListeners(); diff --git a/flutter/lib/core/theme/app_spacing.dart b/flutter/lib/core/theme/app_spacing.dart index 0d28bbf..beade93 100644 --- a/flutter/lib/core/theme/app_spacing.dart +++ b/flutter/lib/core/theme/app_spacing.dart @@ -14,9 +14,14 @@ abstract final class AppSpacing { static const double s12 = 12; static const double s14 = 14; static const double s16 = 16; + static const double s18 = 18; static const double s20 = 20; + static const double s22 = 22; static const double s24 = 24; static const double s28 = 28; + + /// 웹 토큰에는 없다 — 앱 보드(`App1Login`)의 화면 아래 여백 + static const double s32 = 32; static const double s36 = 36; static const double s40 = 40; static const double s48 = 48; @@ -52,4 +57,26 @@ abstract final class AppSize { /// 초점 고리 두께 — `--focus-ring-width` 2px static const double focusRingWidth = 2; + + // ── 앱 보드에서 정한 크기 (`App1Login` — 2026-11-05 확정) ── + /// 글자 입력 칸 높이 + static const double fieldHeight = 52; + + /// 인증 앱 코드 칸 높이 (글자가 크다) + static const double codeFieldHeight = 56; + + /// 위 띠 높이 — 로그인 다음 단계의 짙은 띠 + static const double topBandHeight = 56; + + /// 알약 모양 줄(로그인한 주소) 높이 + static const double chipHeight = 36; + + /// 작은 아이콘(단추 안 · 안내 상자) + static const double iconSmall = 18; + + /// 보통 아이콘(칸 안 [보기]) + static const double iconMedium = 20; + + /// 넓은 화면(태블릿 · 가로)에서 양식 열의 최대 폭 — 휴대폰(390)에서는 화면 폭을 다 쓴다 + static const double formMaxWidth = 480; } diff --git a/flutter/lib/core/theme/app_theme.dart b/flutter/lib/core/theme/app_theme.dart index 45038b9..2bca6ef 100644 --- a/flutter/lib/core/theme/app_theme.dart +++ b/flutter/lib/core/theme/app_theme.dart @@ -159,7 +159,11 @@ class AppExtraColors extends ThemeExtension { const AppExtraColors({ required this.textMuted, required this.textBody, + required this.textStrong, required this.iconMuted, + required this.borderSubtle, + required this.onBrandMuted, + required this.onBrandLink, required this.warningSurface, required this.warningText, required this.warningBorder, @@ -170,7 +174,11 @@ class AppExtraColors extends ThemeExtension { static const AppExtraColors light = AppExtraColors( textMuted: AppColors.textMuted, textBody: AppColors.textBody, + textStrong: AppColors.textStrong, iconMuted: AppColors.iconMuted, + borderSubtle: AppColors.borderSubtle, + onBrandMuted: AppColors.onBrandMuted, + onBrandLink: AppColors.onBrandLink, warningSurface: AppColors.warningSurface, warningText: AppColors.warningText, warningBorder: AppColors.warningBorder, @@ -183,8 +191,20 @@ class AppExtraColors extends ThemeExtension { /// 메일 본문 final Color textBody; + + /// 입력 이름 · 도구 단추 · 연락처 + final Color textStrong; final Color iconMuted; + /// 옅은 나눔선 — 화면 맨 아래 안내 글 위 + final Color borderSubtle; + + /// 브랜드 바탕 위 보조 글 (로그인 첫 화면의 소개 글) + final Color onBrandMuted; + + /// 브랜드 바탕 위 링크 · 단추 ([다른 계정으로 로그인]) + final Color onBrandLink; + /// 노란 안내 줄 final Color warningSurface; final Color warningText; @@ -198,7 +218,11 @@ class AppExtraColors extends ThemeExtension { AppExtraColors copyWith({ Color? textMuted, Color? textBody, + Color? textStrong, Color? iconMuted, + Color? borderSubtle, + Color? onBrandMuted, + Color? onBrandLink, Color? warningSurface, Color? warningText, Color? warningBorder, @@ -208,7 +232,11 @@ class AppExtraColors extends ThemeExtension { return AppExtraColors( textMuted: textMuted ?? this.textMuted, textBody: textBody ?? this.textBody, + textStrong: textStrong ?? this.textStrong, iconMuted: iconMuted ?? this.iconMuted, + borderSubtle: borderSubtle ?? this.borderSubtle, + onBrandMuted: onBrandMuted ?? this.onBrandMuted, + onBrandLink: onBrandLink ?? this.onBrandLink, warningSurface: warningSurface ?? this.warningSurface, warningText: warningText ?? this.warningText, warningBorder: warningBorder ?? this.warningBorder, @@ -223,7 +251,11 @@ class AppExtraColors extends ThemeExtension { return AppExtraColors( textMuted: Color.lerp(textMuted, other.textMuted, t)!, textBody: Color.lerp(textBody, other.textBody, t)!, + textStrong: Color.lerp(textStrong, other.textStrong, t)!, iconMuted: Color.lerp(iconMuted, other.iconMuted, t)!, + borderSubtle: Color.lerp(borderSubtle, other.borderSubtle, t)!, + onBrandMuted: Color.lerp(onBrandMuted, other.onBrandMuted, t)!, + onBrandLink: Color.lerp(onBrandLink, other.onBrandLink, t)!, warningSurface: Color.lerp(warningSurface, other.warningSurface, t)!, warningText: Color.lerp(warningText, other.warningText, t)!, warningBorder: Color.lerp(warningBorder, other.warningBorder, t)!, diff --git a/flutter/lib/core/theme/app_typography.dart b/flutter/lib/core/theme/app_typography.dart index 58f2c1d..4fb9b8b 100644 --- a/flutter/lib/core/theme/app_typography.dart +++ b/flutter/lib/core/theme/app_typography.dart @@ -35,10 +35,22 @@ abstract final class AppTypography { /// 메일 본문 줄 간격 (웹 `--line-height-body` · 확정) static const double lineHeightBody = 1.75; + /// 여러 줄 안내 글 · 2단계 인증 키 (앱 보드 `App1Login` 의 1.6) + static const double lineHeightRelaxed = 1.6; + // ── 자간 (웹 `--letter-spacing-*` 의 em 값 — 크기를 곱해 dp 로 바꾼다) ── static const double letterSpacingTightEm = -0.01; static const double letterSpacingHeadingEm = -0.02; + /// 인증 앱 코드 칸 — 숫자 사이를 벌린다 (앱 보드 `App1Login` 의 0.35em) + static const double letterSpacingCodeEm = 0.35; + + /// 2단계 인증 키 — 고정폭 글자 사이 (앱 보드 `App1Login` 의 0.03em) + static const double letterSpacingKeyEm = 0.03; + + /// 고정폭 글꼴 — 2단계 인증 키. 기기의 고정폭 글꼴을 쓴다(Android 는 'monospace' 가 Droid Sans Mono 계열) + static const String monospaceFamily = 'monospace'; + // ── 굵기 — pubspec 에 넣은 Pretendard 넷과 같다 ── static const FontWeight regular = FontWeight.w400; static const FontWeight medium = FontWeight.w500; diff --git a/flutter/lib/core/utils/display_text.dart b/flutter/lib/core/utils/display_text.dart new file mode 100644 index 0000000..ef39450 --- /dev/null +++ b/flutter/lib/core/utils/display_text.dart @@ -0,0 +1,40 @@ +/// 화면에 보이기 전에 글을 다듬는 순수 함수 — 문의 연락처 한 줄 · 2단계 인증 키 줄. +library; + +import 'dart:math' as math; + +/// 문의 연락처의 길이 상한 — 서버 설정값과 같은 200자 (계약 6절 "문의 연락처") +const int kSupportContactMaxLength = 200; + +final RegExp _whitespaceRun = RegExp(r'\s+'); + +/// 문의 연락처 — 서버 설정값을 화면에 보일 한 줄로 다듬는다(웹 `support-contact.ts` 와 같다). +/// +/// 줄바꿈 · 연속 공백은 띄어쓰기 하나, 앞뒤 공백은 지우고, 200자에서 자른다. 글자로만 보인다(링크 없음). +/// 빈 글 · 글이 아닌 값은 null — 정하지 않은 것으로 보고 화면은 지금 글 그대로다. +String? supportContactTextOf(Object? value) { + if (value is! String) return null; + final String line = value.replaceAll(_whitespaceRun, ' ').trim(); + if (line.isEmpty) return null; + return line.length > kSupportContactMaxLength + ? line.substring(0, kSupportContactMaxLength) + : line; +} + +/// 2단계 인증 키를 끊어 보이는 크기 — 4자씩, 한 줄에 4묶음 (32자 키는 두 줄 — 앱 보드 `App1Login`, 웹과 같다) +const int kKeyGroupSize = 4; +const int kKeyGroupsPerLine = 4; + +/// 2단계 인증 키의 줄 — "K7QF 2M4X HB6R TZ3N" 꼴. 보기 위한 띄어쓰기다 — [복사]는 키를 띄어쓰기 없이 복사한다 +List keyLines(String secret) { + final List groups = [ + for (int start = 0; start < secret.length; start += kKeyGroupSize) + secret.substring(start, math.min(start + kKeyGroupSize, secret.length)), + ]; + return [ + for (int start = 0; start < groups.length; start += kKeyGroupsPerLine) + groups + .sublist(start, math.min(start + kKeyGroupsPerLine, groups.length)) + .join(' '), + ]; +} diff --git a/flutter/lib/core/utils/korean_text.dart b/flutter/lib/core/utils/korean_text.dart new file mode 100644 index 0000000..8c56773 --- /dev/null +++ b/flutter/lib/core/utils/korean_text.dart @@ -0,0 +1,34 @@ +/// 한국어 글의 줄 나눔 — 웹 `word-break: keep-all` 에 해당한다(앱 보드 `App1Login` 의 안내 글). +library; + +/// WORD JOINER — 보이지 않고, 그 앞뒤에서 줄을 나누지 못하게 한다(유니코드 줄 나눔 규칙 WJ) +const int _wordJoiner = 0x2060; + +/// 낱말 가운데서 줄이 바뀌지 않게 한다 — 줄은 띄어쓰기에서만 바뀐다. +/// +/// Flutter 의 줄 나눔(ICU)은 한글 글자 사이마다 줄을 바꿀 수 있어 "자동으 / 로" 처럼 낱말이 갈린다(Flutter 에 keep-all 이 아직 없다). +/// 한글 글자와 그 이웃(한글 · 영문 · 숫자 · ASCII 기호) 사이에 WORD JOINER 를 넣는다 — "Outlook이나" · "[설정]에서" 도 한 낱말로 남는다. +/// 그 밖의 글자(이모지 · 결합 문자 등) 옆에는 넣지 않는다(글자 묶음을 가르지 않게). 한 낱말이 한 줄보다 길면 Flutter 가 그 안에서 끊는다. +/// 화면 낭독기에는 원래 글을 준다(`KeepAllText` 의 `semanticsLabel`). +String keepAllWordBreak(String text) { + final StringBuffer out = StringBuffer(); + int? previous; + for (final int rune in text.runes) { + if (previous != null && + _joinable(previous) && + _joinable(rune) && + (_isHangulSyllable(previous) || _isHangulSyllable(rune))) { + out.writeCharCode(_wordJoiner); + } + out.writeCharCode(rune); + previous = rune; + } + return out.toString(); +} + +bool _isHangulSyllable(int rune) => rune >= 0xAC00 && rune <= 0xD7A3; + +/// 띄어쓰기 · 제어 문자를 뺀 ASCII 글자 — 영문 · 숫자 · 기호 +bool _isAsciiGraphic(int rune) => rune >= 0x21 && rune <= 0x7E; + +bool _joinable(int rune) => _isHangulSyllable(rune) || _isAsciiGraphic(rune); diff --git a/flutter/lib/core/utils/otp_code.dart b/flutter/lib/core/utils/otp_code.dart new file mode 100644 index 0000000..1581d25 --- /dev/null +++ b/flutter/lib/core/utils/otp_code.dart @@ -0,0 +1,33 @@ +/// 인증 앱 코드 — 숫자 6자리 · 30초 (계약 5절 "한도 · 규칙"). 웹 `frontend/src/shared/utils/account-security.ts` 와 같은 규칙이다. +library; + +/// 코드의 자릿수 +const int kOtpCodeLength = 6; + +/// 전각 숫자 0 ~ 9 의 첫 글자 — 반각 숫자로 바꾼다(웹은 NFKC 로 같은 일을 한다) +const int _fullWidthZero = 0xFF10; +const int _fullWidthNine = 0xFF19; +const int _asciiZero = 0x30; +const int _asciiNine = 0x39; + +/// 코드 칸에 들어온 글을 다듬는다 — 숫자만 남기고 6자리에서 자른다. +/// +/// 붙여 넣은 "123 456" · "123-456" 과 전각 숫자도 받는다(계약 5절 "로그인" 클라이언트). +/// 칸의 글자 수 한도(maxLength)를 따로 걸지 않는 까닭: 한도가 먼저 잘라 "123 45" 만 남는다. +String normalizeOtpInput(String raw) { + final StringBuffer digits = StringBuffer(); + for (final int rune in raw.runes) { + if (digits.length == kOtpCodeLength) break; + if (rune >= _asciiZero && rune <= _asciiNine) { + digits.writeCharCode(rune); + } else if (rune >= _fullWidthZero && rune <= _fullWidthNine) { + digits.writeCharCode(_asciiZero + rune - _fullWidthZero); + } + } + return digits.toString(); +} + +final RegExp _completeOtp = RegExp('^[0-9]{$kOtpCodeLength}\$'); + +/// 숫자 6자리를 다 넣었는가 — 서버도 같은 모양만 받는다(아니면 400 `VAL_001`) +bool isCompleteOtp(String code) => _completeOtp.hasMatch(code); diff --git a/flutter/lib/core/utils/password_rules.dart b/flutter/lib/core/utils/password_rules.dart new file mode 100644 index 0000000..6078d52 --- /dev/null +++ b/flutter/lib/core/utils/password_rules.dart @@ -0,0 +1,61 @@ +/// 비밀번호 바꾸기 — 보내기 전에 서버와 같은 규칙을 먼저 본다 (계약 1절 "PUT /api/auth/password"). +/// +/// 웹 `frontend/src/shared/utils/password-change.ts` 와 같은 규칙이다: 8자 이상 · 지금 비밀번호와 다름 · 확인 칸 일치, +/// 128자를 넘으면 서버가 `VAL_001` 로 거절하므로 그것도 먼저 막는다. 공백도 비밀번호의 글자다 — 다듬지 않는다. +/// 길이는 서버와 같이 UTF-16 코드 단위로 센다(Dart `String.length` 가 JS 와 같다). +library; + +/// 새 비밀번호의 최소 길이 +const int kPasswordMinLength = 8; + +/// 새 비밀번호의 최대 길이 +const int kPasswordMaxLength = 128; + +/// 로그인 · 지금 비밀번호의 최대 길이 — 넘으면 서버가 `VAL_001` (계약 1절) +const int kLoginPasswordMaxLength = 256; + +/// 새 비밀번호 칸의 문제 +enum NewPasswordProblem { + /// 8자 미만이거나 지금 비밀번호와 같다 — 안내 글이 그대로 빨간 오류가 된다 + rule, + + /// 128자 초과 + tooLong, +} + +/// 칸마다 하나 — 없으면 null +class PasswordChangeProblems { + const PasswordChangeProblems({ + this.currentMissing = false, + this.next, + this.confirmMismatch = false, + }); + + /// 지금(임시) 비밀번호를 비웠다 + final bool currentMissing; + final NewPasswordProblem? next; + + /// 확인 칸이 새 비밀번호와 다르다 + final bool confirmMismatch; + + bool get hasAny => currentMissing || next != null || confirmMismatch; +} + +/// 세 칸을 본다 — [current] 는 쥔 비밀번호(로그인 때 넣은 것)이거나 칸에 넣은 임시 비밀번호 +PasswordChangeProblems passwordChangeProblems({ + required String current, + required String next, + required String confirm, +}) { + NewPasswordProblem? nextProblem; + if (next.length > kPasswordMaxLength) { + nextProblem = NewPasswordProblem.tooLong; + } else if (next.length < kPasswordMinLength || next == current) { + nextProblem = NewPasswordProblem.rule; + } + return PasswordChangeProblems( + currentMissing: current.isEmpty, + next: nextProblem, + confirmMismatch: confirm != next, + ); +} diff --git a/flutter/lib/features/auth/data/authenticator_launcher.dart b/flutter/lib/features/auth/data/authenticator_launcher.dart new file mode 100644 index 0000000..aec65b9 --- /dev/null +++ b/flutter/lib/features/auth/data/authenticator_launcher.dart @@ -0,0 +1,37 @@ +import 'package:arasystem_app/features/auth/data/models/two_factor_enrollment.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// 다른 앱으로 주소를 넘기는 함수 — 기본은 url_launcher 의 [launchUrl]. 시험은 가짜를 넣는다 +typedef UrlLaunch = Future Function(Uri url, LaunchMode mode); + +Future _launchExternal(Uri url, LaunchMode mode) => + launchUrl(url, mode: mode); + +/// [인증 앱에 추가] — otpauth 주소(키가 들어 있다)를 그 주소를 받는 인증 앱에 넘긴다(2단계 켜기 · 같은 휴대폰이라 QR 대신). +/// +/// - 브라우저가 아닌 앱만 연다(`externalNonBrowserApplication` — Android 11+ 의 FLAG_ACTIVITY_REQUIRE_NON_BROWSER). +/// 받을 앱이 없으면 false — 화면이 "인증 앱을 찾지 못했습니다"와 키를 직접 넣는 길을 보인다. +/// - **`canLaunchUrl` 을 쓰지 않는다** — url_launcher_android 의 debug 판이 묻는 주소를 로그에 남긴다(키가 들어 있다). +/// 그래서 매니페스트 `` 도 필요 없다: `launchUrl` 은 startActivity 를 바로 부르고 받을 앱이 없으면 +/// ActivityNotFoundException 을 false 로 바꾼다(앱 보기 제한은 resolveActivity 류에만 걸린다). +/// - 못 열면 플러그인이 던지는 `PlatformException` 의 문장에 주소째(키째) 들어 있다 — 잡아서 버린다(로그 · 상태에 남기지 않는다). +class AuthenticatorLauncher { + const AuthenticatorLauncher({UrlLaunch launch = _launchExternal}) + : _launch = launch; + + final UrlLaunch _launch; + + /// 인증 앱을 열었으면 true. otpauth 주소가 아니거나 받을 앱이 없거나 열지 못했으면 false + Future open(String otpauthUrl) async { + if (!otpauthUrl.startsWith(kOtpauthUrlPrefix)) return false; + final Uri? url = Uri.tryParse(otpauthUrl); + if (url == null) return false; + try { + return await _launch(url, LaunchMode.externalNonBrowserApplication); + } on Object { + // PlatformException(받을 앱 없음 · 화면 없음) · ArgumentError(지원하지 않는 방식) 모두 — + // 문장에 주소(키)가 들어 있어 남기지 않고 버린다. 화면은 키를 직접 넣는 길을 보인다 + return false; + } + } +} diff --git a/flutter/lib/features/auth/data/models/two_factor_enrollment.dart b/flutter/lib/features/auth/data/models/two_factor_enrollment.dart new file mode 100644 index 0000000..3be404a --- /dev/null +++ b/flutter/lib/features/auth/data/models/two_factor_enrollment.dart @@ -0,0 +1,48 @@ +/// 2단계 인증 등록 — `POST /api/two-factor/enrollments` 의 응답 (계약 5절 "2단계 인증"). +/// +/// **비밀이다.** 키([secret])와 otpauth 주소([otpauthUrl])는 2단계 켜기 화면의 메모리에만 둔다 — +/// Riverpod 상태 · 로그 · 예외 문장에 두지 않고, 켜거나 화면을 떠나면 버린다. [toString] 도 값을 가린다. +class TwoFactorEnrollment { + const TwoFactorEnrollment({ + required this.enrollmentId, + required this.secret, + required this.otpauthUrl, + this.expiresAt, + }); + + /// 켜기 요청에 함께 보낸다 + final String enrollmentId; + + /// base32 키(32자) — [복사] · 직접 입력용 + final String secret; + + /// `otpauth://totp/…` — [인증 앱에 추가]가 이 주소로 인증 앱을 연다 + final String otpauthUrl; + + /// 이때까지 켜야 한다(10분) — 지나면 서버가 `AUTH_008` + final DateTime? expiresAt; + + /// 응답을 읽는다 — 칸이 비었거나 모양이 틀리면 null + static TwoFactorEnrollment? tryParse(Object? json) { + if (json is! Map) return null; + final Object? id = json['enrollmentId']; + final Object? secret = json['secret']; + final Object? url = json['otpauthUrl']; + if (id is! String || id.isEmpty) return null; + if (secret is! String || secret.isEmpty) return null; + if (url is! String || !url.startsWith(kOtpauthUrlPrefix)) return null; + final Object? expires = json['expiresAt']; + return TwoFactorEnrollment( + enrollmentId: id, + secret: secret, + otpauthUrl: url, + expiresAt: expires is String ? DateTime.tryParse(expires)?.toUtc() : null, + ); + } + + @override + String toString() => 'TwoFactorEnrollment(***)'; +} + +/// otpauth 주소의 앞부분 — 다른 꼴의 주소로는 다른 앱을 열지 않는다 +const String kOtpauthUrlPrefix = 'otpauth://'; diff --git a/flutter/lib/features/auth/data/models/two_factor_status.dart b/flutter/lib/features/auth/data/models/two_factor_status.dart new file mode 100644 index 0000000..093fe39 --- /dev/null +++ b/flutter/lib/features/auth/data/models/two_factor_status.dart @@ -0,0 +1,17 @@ +/// 2단계 인증 상태 — `GET /api/two-factor` · 켜기 응답 (계약 5절 "2단계 인증" · 6절 `isRequired` · `deadline`). +/// +/// 앱은 로그인 틀에서만 쓴다: 새 비밀번호 단계가 코드 칸을 보일지(켠 직원은 비밀번호를 바꿀 때 코드가 든다), +/// 2단계 켜기 단계가 그사이 다른 곳에서 켰는지(`AUTH_008`) 본다. 계정 설정 화면은 웹에 있다. +class TwoFactorStatus { + const TwoFactorStatus({required this.isEnabled}); + + final bool isEnabled; + + /// 응답을 읽는다 — `isEnabled` 가 없으면 null + static TwoFactorStatus? tryParse(Object? json) { + if (json is! Map) return null; + final Object? enabled = json['isEnabled']; + if (enabled is! bool) return null; + return TwoFactorStatus(isEnabled: enabled); + } +} diff --git a/flutter/lib/features/auth/data/repositories/auth_repository.dart b/flutter/lib/features/auth/data/repositories/auth_repository.dart index bc16563..3b719ee 100644 --- a/flutter/lib/features/auth/data/repositories/auth_repository.dart +++ b/flutter/lib/features/auth/data/repositories/auth_repository.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:arasystem_app/core/api/api_config.dart'; import 'package:arasystem_app/core/api/api_error_mapper.dart'; import 'package:arasystem_app/core/api/api_paths.dart'; import 'package:arasystem_app/core/api/request_flags.dart'; @@ -19,6 +20,23 @@ const Set kLoginInlineCodes = { 'VAL_001', }; +/// 코드 단계(코드를 실은 로그인)가 직접 보이는 실패 — 첫 단계 묶음에 시도 과다(`REQ_001` — 위쪽 상자)를 더한다. +/// 웹 코드 단계와 같다(웹 디자인 README 12단계 "로그인 — 코드 단계"). 첫 단계의 `REQ_001` 은 웹처럼 전역 알림이다 +const Set kLoginCodeInlineCodes = { + ...kLoginInlineCodes, + 'REQ_001', +}; + +/// 새 비밀번호 단계가 직접 보이는 실패 — 알림을 띄우지 않는다 +/// (`AUTH_005` 임시 비밀번호 칸 · `VAL_001` 새 비밀번호 칸 · `AUTH_006` · `AUTH_007` 코드 칸 · `REQ_001` 위쪽 상자 — 계약 1 · 5 · 6절, 웹과 같다) +const Set kPasswordChangeInlineCodes = { + 'AUTH_005', + 'AUTH_006', + 'AUTH_007', + 'VAL_001', + 'REQ_001', +}; + /// 응답이 계약과 다른 모양일 때의 코드 — 서버 쪽 문제로 본다 const String kMalformedResponseCode = 'SYS_001'; @@ -47,7 +65,9 @@ class AuthRepository { 'otpCode': ?otpCode, }, // 비밀번호 · 코드가 본문에 든다 — 실패 예외는 본문을 비워 던진다(인증 인터셉터가 지운다) - options: ApiOptions.secret(silent: kLoginInlineCodes), + options: ApiOptions.secret( + silent: otpCode == null ? kLoginInlineCodes : kLoginCodeInlineCodes, + ), ); final Object? body = response.data; if (body is Map) { @@ -100,6 +120,28 @@ class AuthRepository { return info; } + /// 비밀번호를 바꾼다 — 새 비밀번호 단계(계약 1절 `PUT /api/auth/password` · 5절 `otpCode` · 6절 제한 풀기). + /// + /// [otpCode] 는 2단계 인증을 켠 직원만. 비밀번호 · 코드가 본문에 든다 — 실패 예외는 본문을 비운 채 온다. + /// 서버가 엔진을 두 번 부르고 세션을 끊어 받기 한도를 [kSecuritySettingTimeout] 으로 늘린다(웹과 같은 30초). + Future changePassword({ + required String currentPassword, + required String newPassword, + String? otpCode, + }) async { + await _dio.put( + ApiPaths.password, + data: { + 'currentPassword': currentPassword, + 'newPassword': newPassword, + 'otpCode': ?otpCode, + }, + options: ApiOptions.secret( + silent: kPasswordChangeInlineCodes, + ).copyWith(receiveTimeout: kSecuritySettingTimeout), + ); + } + /// 토큰을 새로 받는다 — 진행 중인 갱신이 있으면 함께 기다린다(한 번에 하나). /// 제한을 푼 직후(계약 6절 — 곧바로 갱신해 새 토큰) · 옛 토큰의 `AUTH_010` 에서 부른다. Future refresh() => _refresher.refresh(); diff --git a/flutter/lib/features/auth/data/repositories/support_contact_repository.dart b/flutter/lib/features/auth/data/repositories/support_contact_repository.dart new file mode 100644 index 0000000..fe0849f --- /dev/null +++ b/flutter/lib/features/auth/data/repositories/support_contact_repository.dart @@ -0,0 +1,26 @@ +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/api/request_flags.dart'; +import 'package:arasystem_app/core/utils/display_text.dart'; +import 'package:dio/dio.dart'; + +/// 문의 연락처 — `GET /api/support-contact` (로그인 없음 · 계약 6절 "문의 연락처"). +/// +/// 운영 도구로 정하는 회사 설정값 한 줄이다(200자). 정하지 않았으면 null. 알림은 띄우지 않는다 — +/// 받지 못해도 화면은 지금 글 그대로다(웹과 같다). +class SupportContactRepository { + const SupportContactRepository(this._dio); + + final Dio _dio; + + /// 연락처 한 줄 — 정하지 않았거나 모양이 틀리면 null. 실패는 `DioException` + Future fetch() async { + final Response response = await _dio.get( + ApiPaths.supportContact, + options: ApiOptions.silent(), + ); + final Object? data = response.data; + return data is Map + ? supportContactTextOf(data['text']) + : null; + } +} diff --git a/flutter/lib/features/auth/data/repositories/two_factor_repository.dart b/flutter/lib/features/auth/data/repositories/two_factor_repository.dart new file mode 100644 index 0000000..b8e9457 --- /dev/null +++ b/flutter/lib/features/auth/data/repositories/two_factor_repository.dart @@ -0,0 +1,84 @@ +import 'package:arasystem_app/core/api/api_config.dart'; +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/api/request_flags.dart'; +import 'package:arasystem_app/features/auth/data/models/two_factor_enrollment.dart'; +import 'package:arasystem_app/features/auth/data/models/two_factor_status.dart'; +import 'package:arasystem_app/features/auth/data/repositories/auth_repository.dart'; +import 'package:dio/dio.dart'; + +/// 2단계 켜기 단계가 직접 보이는 실패 — 알림을 띄우지 않는다 +/// (`AUTH_005` 지금 비밀번호 칸 · `AUTH_006` · `AUTH_007` 코드 칸 · `AUTH_008` 위쪽 상자와 새 키 · `REQ_001` 위쪽 상자 — 계약 5절 "2단계 인증", 웹과 같다). +/// `VAL_001` 은 넣지 않는다 — 코드는 보내기 전에 6자리를 보므로 서버의 `VAL_001` 은 지금 비밀번호 쪽이고, 웹처럼 전역 알림이다 +const Set kTwoFactorSetupInlineCodes = { + 'AUTH_005', + 'AUTH_006', + 'AUTH_007', + 'AUTH_008', + 'REQ_001', +}; + +/// 2단계 인증 API — 로그인 틀(새 비밀번호 · 2단계 켜기)이 쓰는 것만. 계약 5절 "2단계 인증" · 6절 "제한된 세션"(제한된 세션도 부를 수 있다). +/// +/// 등록의 키 · otpauth 주소는 받은 값을 돌려주기만 한다 — 저장하지 않는다(화면 메모리에만, `TwoFactorEnrollment`). +/// 실패는 늘 `DioException`(`error` 에 `ApiException`). 응답 모양이 틀리면 [kMalformedResponseCode] — 응답은 싣지 않는다. +class TwoFactorRepository { + const TwoFactorRepository(this._dio); + + final Dio _dio; + + /// 지금 상태 — 알림은 띄우지 않는다(화면이 못 받아도 다른 길로 안다: 서버의 `AUTH_007` 등) + Future status() async { + final Response response = await _dio.get( + ApiPaths.twoFactor, + options: ApiOptions.silent(), + ); + final TwoFactorStatus? status = TwoFactorStatus.tryParse(response.data); + if (status == null) throw _malformed(response); + return status; + } + + /// 등록을 시작한다 — 새 키(10분 안에 켠다). 직원마다 기다리는 등록은 하나라 앞의 것은 서버가 버린다. + /// + /// 응답에 키가 들어 있어 비밀 요청으로 보낸다(실패 예외의 응답 본문을 비운다). 알림은 띄우지 않는다 — + /// 화면이 키 자리에 사전 문구와 [다시 시도]를 보인다. + Future startEnrollment() async { + final Response response = await _dio.post( + ApiPaths.twoFactorEnrollments, + options: ApiOptions.secret(silent: true), + ); + final TwoFactorEnrollment? enrollment = TwoFactorEnrollment.tryParse( + response.data, + ); + // 받은 응답 객체에 키를 남기지 않는다 + response.data = null; + if (enrollment == null) throw _malformed(response); + return enrollment; + } + + /// 켠다 — 등록의 키로 만든 코드와 지금 비밀번호를 함께 보낸다. 비밀이 본문에 든다. + /// 서버가 엔진을 여러 번 부르므로 받기 한도를 [kSecuritySettingTimeout] 으로 늘린다(웹과 같은 30초). + /// 성공 응답의 상태는 쓰지 않는다 — 다음 화면은 새 토큰의 세션 칸이 정한다. + Future activate({ + required String enrollmentId, + required String otpCode, + required String currentPassword, + }) async { + await _dio.post( + ApiPaths.twoFactorActivation, + data: { + 'enrollmentId': enrollmentId, + 'otpCode': otpCode, + 'currentPassword': currentPassword, + }, + options: ApiOptions.secret( + silent: kTwoFactorSetupInlineCodes, + ).copyWith(receiveTimeout: kSecuritySettingTimeout), + ); + } + + /// 모양이 틀린 응답 — 응답을 싣지 않고 Bearer 를 지운 실패 + DioException _malformed(Response response) => withoutSecrets( + apiFailure(response.requestOptions, kMalformedResponseCode), + ); +} diff --git a/flutter/lib/features/auth/presentation/login_failures.dart b/flutter/lib/features/auth/presentation/login_failures.dart new file mode 100644 index 0000000..182ff17 --- /dev/null +++ b/flutter/lib/features/auth/presentation/login_failures.dart @@ -0,0 +1,28 @@ +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/api_exception.dart'; +import 'package:arasystem_app/core/api/auth_interceptor.dart'; +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/api/token_refresher.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 로그인 틀이 칸 · 상자에 보이지 않는 실패를 전역 알림(스낵바)으로 보인다 — 웹의 "그 밖의 실패는 알림"과 같은 결. +/// +/// 서버가 답한 실패는 인터셉터가 이미 알렸다 — 같은 코드는 3초 안에 다시 띄우지 않아(`ErrorNotifier`) 겹치지 않는다. +/// 앱이 스스로 정한 실패(응답 모양이 틀림 · 받은 토큰을 보안 저장소에 두지 못함 — `SYS_001`)는 인터셉터를 지나지 않아 여기서만 알린다. +/// 문구는 사전에서만 온다(`ApiException.message`). 알리지 않는 것: `AUTH_010`(세션이 그 단계로 옮긴다) · +/// `AUTH_002`(세션 끝 — 로그인 화면의 위쪽 상자가 알린다, 인터셉터의 알림과 같은 규칙). +/// 부르기 전에 화면이 그 시도가 아직 지금 세션의 것인지(`SessionNotifier.attempt`) 본다 — 끝난 세션의 늦은 실패는 보이지 않는다. +void showLoginFailure(WidgetRef ref, Object error) { + final ApiException? apiError = switch (error) { + ApiException() => error, + DioException() => toApiException(error), + _ => null, + }; + if (apiError == null || + apiError.code == kRestrictedSessionCode || + apiError.code == kSessionExpiredCode) { + return; + } + ref.read(errorNotifierProvider).show(apiError); +} diff --git a/flutter/lib/features/auth/presentation/pages/login_code_page.dart b/flutter/lib/features/auth/presentation/pages/login_code_page.dart new file mode 100644 index 0000000..c66c545 --- /dev/null +++ b/flutter/lib/features/auth/presentation/pages/login_code_page.dart @@ -0,0 +1,162 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/error_lexicon.dart'; +import 'package:arasystem_app/core/constants/login_texts.dart'; +import 'package:arasystem_app/core/constants/screen_titles.dart'; +import 'package:arasystem_app/core/utils/otp_code.dart'; +import 'package:arasystem_app/features/auth/presentation/login_failures.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_fields.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_frame.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_parts.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 로그인 둘째 단계 — 2단계 인증 코드(2단계를 켠 직원 — 앱 보드 `App1Login` 「2단계 인증 코드」 · 계약 5절 "로그인"). +/// +/// - 짙은 띠([다른 계정으로 로그인]) → 제목 · 설명 · 주소 → 코드 칸 → [확인] → 문의 줄 → 메일 프로그램 안내. +/// - 비밀번호는 세션 노티파이어 메모리에만 있다(10분) — 이 화면은 코드만 칸(화면 메모리)에 둔다. +/// - 6자리가 아니면 보내지 않고 칸 아래 `AUTH_007`. 서버의 `AUTH_006`(틀림 · 다시 쓴 코드)은 칸 아래 · 칸을 비운다. +/// 시도 과다(`REQ_001`)는 위쪽 상자(웹과 같다 — 다음에 보낼 때 내린다). `AUTH_004`(그사이 비밀번호가 바뀜) · 10분 지남은 +/// 세션이 첫 단계로 옮긴다(첫 단계가 까닭을 보인다). 그 밖은 전역 알림. +/// - [다른 계정으로 로그인]은 보내는 중에도 누를 수 있다 — 아직 로그인 전이라 서버에 바뀌는 것이 없다. +/// 늦게 온 결과는 세션 노티파이어가 버리고(시도 세대 — 토큰이 실려 오면 그 세션을 끝낸다), 이 화면도 보이지 않는다. +class LoginCodePage extends ConsumerStatefulWidget { + const LoginCodePage({super.key}); + + @override + ConsumerState createState() => _LoginCodePageState(); +} + +class _LoginCodePageState extends ConsumerState { + final TextEditingController _code = TextEditingController(); + final FocusNode _codeFocus = FocusNode(); + + /// 비밀번호를 확인한 주소 — 화면이 사라지는 동안(첫 단계로 옮겨 가는 중)에도 같은 글을 보이게 처음 값을 둔다 + late final String _email; + bool _submitting = false; + String? _codeError; + + /// 위쪽 상자 — 시도 과다(`REQ_001`) 사전 문구 + String? _alert; + + @override + void initState() { + super.initState(); + final SessionState? session = ref.read(sessionProvider).value; + _email = session is CodeRequired ? session.email : ''; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + unawaited(ref.read(supportContactProvider.notifier).ensureLoaded()); + _codeFocus.requestFocus(); + }); + } + + @override + void dispose() { + _code.clear(); + _code.dispose(); + _codeFocus.dispose(); + super.dispose(); + } + + Future _submit() async { + if (_submitting) return; + final String code = _code.text; + if (!isCompleteOtp(code)) { + setState(() { + _alert = null; + _codeError = ErrorLexicon.getMessage('AUTH_007'); + }); + _codeFocus.requestFocus(); + return; + } + final SessionNotifier session = ref.read(sessionProvider.notifier); + final int attempt = session.attempt; + setState(() { + _submitting = true; + _codeError = null; + _alert = null; + }); + try { + await session.submitCode(code); + // 세션이 다음 화면(제한 단계 · 메일 · 시간이 지났으면 첫 단계)으로 옮긴다 — 쓴 코드는 칸에 남기지 않는다 + if (mounted) _code.clear(); + } on DioException catch (error) { + // 그사이 [다른 계정으로 로그인] · 첫 단계로 돌아갔으면(`AUTH_004`) 이 화면은 보이지 않는다 + if (!mounted || + session.attempt != attempt || + ref.read(sessionProvider).value is! CodeRequired) { + return; + } + _onFailure(error); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + void _onFailure(DioException error) { + final String message = toApiException(error).message; + switch (apiErrorCodeOf(error)) { + case 'AUTH_006': + case 'AUTH_007': + case 'VAL_001': + setState(() => _codeError = message); + _code.clear(); + _codeFocus.requestFocus(); + case 'REQ_001': + // 칸 · 단추는 그대로 — 잠시 뒤 같은 코드로 다시 보낼 수 있다 + setState(() => _alert = message); + default: + showLoginFailure(ref, error); + } + } + + void _setCode(String _) { + // 고치면 칸 아래 오류를 내린다(다른 칸과 같다) + if (_codeError != null) setState(() => _codeError = null); + } + + @override + Widget build(BuildContext context) { + final String? alert = _alert; + return LoginStepFrame( + onOtherAccount: () => + unawaited(ref.read(sessionProvider.notifier).switchAccount()), + children: [ + LoginHeading( + title: ScreenTitles.loginCode, + subtitle: LoginCodeTexts.subtitle, + email: _email, + ), + if (alert != null) LoginAlertBox(message: alert), + LoginCodeField( + label: LoginCodeTexts.codeLabel, + controller: _code, + focusNode: _codeFocus, + error: _codeError, + textInputAction: TextInputAction.done, + onSubmitted: (_) => unawaited(_submit()), + onChanged: _setCode, + readOnly: _submitting, + ), + LoginSubmitButton( + label: LoginCodeTexts.submit, + busyLabel: LoginCodeTexts.submitting, + busy: _submitting, + onPressed: () => unawaited(_submit()), + ), + LoginSupportLine( + question: SupportLineTexts.authenticatorQuestion, + fallback: SupportLineTexts.authenticatorFallback, + contact: ref.watch(supportContactProvider), + ), + const LoginNote(text: LoginCodeTexts.note), + ], + ); + } +} diff --git a/flutter/lib/features/auth/presentation/pages/login_page.dart b/flutter/lib/features/auth/presentation/pages/login_page.dart new file mode 100644 index 0000000..4b76811 --- /dev/null +++ b/flutter/lib/features/auth/presentation/pages/login_page.dart @@ -0,0 +1,218 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/error_lexicon.dart'; +import 'package:arasystem_app/core/api/token_refresher.dart'; +import 'package:arasystem_app/core/constants/login_texts.dart'; +import 'package:arasystem_app/core/constants/screen_titles.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/utils/password_rules.dart'; +import 'package:arasystem_app/features/auth/presentation/login_failures.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_fields.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_frame.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_parts.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 로그인 첫 단계 — 주소 · 비밀번호(앱 보드 `App1Login` 「주소와 비밀번호」). +/// +/// - 큰 브랜드 띠 → 제목 · 설명 → (위쪽 빨간 상자) → 메일 주소 · 비밀번호([보기]) → [로그인] → 문의 줄. +/// 웹 첫 화면의 메일 프로그램(IMAP) 안내 줄은 넣지 않는다(앱이 메일 앱이다 — 디자인 README). +/// - 비밀번호가 맞으면 세션이 다음 단계(코드 · 제한 단계 · 메일)로 옮긴다(라우터 redirect). 이 화면은 결과의 실패만 보인다. +/// - `AUTH_004` 는 위쪽 상자(사전 문구) + 두 칸 빨갛게 + 비밀번호 비우기, `VAL_001` 은 주소 칸 아래, 그 밖은 전역 알림. +/// 비운 칸도 보내지 않고 그 칸 아래에 `VAL_001` 사전 문구. +/// - 코드 단계에서 돌아왔으면(시간 지남 · 비밀번호 틀림) 주소를 남기고 위쪽 상자에 까닭을 보인다(세션 상태 `SignedOut.reason`), +/// 세션이 끝나 왔으면 `AUTH_002` 사전 문구. +/// - 비밀번호는 이 칸(화면 메모리)에만 있다 — 실패하면 비우고, 화면을 떠날 때 지운다. +/// 성공한 뒤 곧바로 비우지 않는 까닭: 다음 화면으로 넘어가며 이 칸 묶음(AutofillGroup)이 사라질 때 기기의 비밀번호 저장(자동 채우기)이 그 값을 본다. +class LoginPage extends ConsumerStatefulWidget { + const LoginPage({super.key}); + + @override + ConsumerState createState() => _LoginPageState(); +} + +class _LoginPageState extends ConsumerState { + final TextEditingController _email = TextEditingController(); + final TextEditingController _password = TextEditingController(); + final FocusNode _emailFocus = FocusNode(); + final FocusNode _passwordFocus = FocusNode(); + + bool _passwordShown = false; + bool _submitting = false; + + /// 위쪽 상자 — 사전 문구(`AUTH_004` · `AUTH_002`) 또는 코드 단계 시간 지남 + String? _notice; + + /// 주소 · 비밀번호가 맞지 않았다 — 두 칸을 빨갛게(글은 위쪽 상자) + bool _credentialsRejected = false; + String? _emailError; + String? _passwordError; + + @override + void initState() { + super.initState(); + final SessionState? session = ref.read(sessionProvider).value; + if (session is SignedOut) _applyArrival(session); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + unawaited(ref.read(supportContactProvider.notifier).ensureLoaded()); + // 주소가 남아 있으면 비밀번호부터 넣는다 + if (_email.text.isNotEmpty) _passwordFocus.requestFocus(); + }); + } + + /// 이 화면으로 온 까닭 — 주소를 남기고 위쪽 상자에 보일 글 + void _applyArrival(SignedOut session) { + _email.text = session.email ?? ''; + switch (session.reason) { + case SignOutReason.codeTimedOut: + _notice = LoginTexts.codeTimedOut; + case SignOutReason.passwordRejected: + _notice = ErrorLexicon.getMessage('AUTH_004'); + _credentialsRejected = true; + case SignOutReason.expired: + _notice = ErrorLexicon.getMessage(kSessionExpiredCode); + case SignOutReason.none: + case SignOutReason.switchAccount: + break; + } + } + + @override + void dispose() { + _password.clear(); + _email.dispose(); + _password.dispose(); + _emailFocus.dispose(); + _passwordFocus.dispose(); + super.dispose(); + } + + Future _submit() async { + if (_submitting) return; + final String email = _email.text.trim(); + final String password = _password.text; + final String? emailProblem = email.isEmpty + ? ErrorLexicon.getMessage('VAL_001') + : null; + final String? passwordProblem = + password.isEmpty || password.length > kLoginPasswordMaxLength + ? ErrorLexicon.getMessage('VAL_001') + : null; + setState(() { + _notice = null; + _credentialsRejected = false; + _emailError = emailProblem; + _passwordError = passwordProblem; + }); + if (emailProblem != null) { + _emailFocus.requestFocus(); + return; + } + if (passwordProblem != null) { + _passwordFocus.requestFocus(); + return; + } + + setState(() => _submitting = true); + try { + await ref + .read(sessionProvider.notifier) + .signIn(email: email, password: password); + // 세션이 다음 화면으로 옮긴다 + } on DioException catch (error) { + if (mounted) _onFailure(error); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + void _onFailure(DioException error) { + final String message = toApiException(error).message; + switch (apiErrorCodeOf(error)) { + case 'AUTH_004': + setState(() { + _notice = message; + _credentialsRejected = true; + }); + _password.clear(); + _passwordFocus.requestFocus(); + case 'VAL_001': + setState(() => _emailError = message); + _emailFocus.requestFocus(); + default: + showLoginFailure(ref, error); + } + } + + @override + Widget build(BuildContext context) { + final String? notice = _notice; + return LoginBrandFrame( + children: [ + const LoginHeading( + title: ScreenTitles.login, + subtitle: LoginTexts.subtitle, + ), + if (notice != null) LoginAlertBox(message: notice), + AutofillGroup( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s16, + children: [ + LoginTextField( + label: LoginTexts.emailLabel, + controller: _email, + focusNode: _emailFocus, + placeholder: LoginTexts.emailPlaceholder, + error: _emailError, + invalid: _credentialsRejected, + // 위쪽 상자의 글을 칸에 이어 읽힌다(보드의 aria-describedby — 웹과 같이 두 칸 모두) + describedBy: notice, + keyboardType: TextInputType.emailAddress, + // 끄면 안드로이드 자판이 메일 주소 자판(@)이 아니게 된다 — 고쳐 쓰기(autocorrect)는 그대로 끈다 + enableSuggestions: true, + textInputAction: TextInputAction.next, + onSubmitted: (_) => _passwordFocus.requestFocus(), + autofillHints: const [AutofillHints.username], + ), + LoginPasswordField( + label: LoginTexts.passwordLabel, + controller: _password, + focusNode: _passwordFocus, + placeholder: LoginTexts.passwordPlaceholder, + shown: _passwordShown, + onToggle: () => + setState(() => _passwordShown = !_passwordShown), + toggleLabel: LoginTexts.showPassword, + error: _passwordError, + invalid: _credentialsRejected, + describedBy: notice, + textInputAction: TextInputAction.done, + onSubmitted: (_) => unawaited(_submit()), + autofillHints: const [AutofillHints.password], + ), + LoginSubmitButton( + label: LoginTexts.submit, + busyLabel: LoginTexts.submitting, + busy: _submitting, + onPressed: () => unawaited(_submit()), + inForm: true, + ), + ], + ), + ), + LoginSupportLine( + question: SupportLineTexts.passwordQuestion, + fallback: SupportLineTexts.passwordFallback, + contact: ref.watch(supportContactProvider), + ), + ], + ); + } +} diff --git a/flutter/lib/features/auth/presentation/pages/password_change_page.dart b/flutter/lib/features/auth/presentation/pages/password_change_page.dart new file mode 100644 index 0000000..a01cfe1 --- /dev/null +++ b/flutter/lib/features/auth/presentation/pages/password_change_page.dart @@ -0,0 +1,436 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/error_lexicon.dart'; +import 'package:arasystem_app/core/constants/login_texts.dart'; +import 'package:arasystem_app/core/constants/screen_titles.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/utils/otp_code.dart'; +import 'package:arasystem_app/core/utils/password_rules.dart'; +import 'package:arasystem_app/features/auth/data/models/two_factor_status.dart'; +import 'package:arasystem_app/features/auth/presentation/login_failures.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_fields.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_frame.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_parts.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 로그인 셋째 단계 — 새 비밀번호 정하기(제한 `passwordChange` — 앱 보드 `App1Login` 「새 비밀번호 정하기」 · 계약 6절 "비밀번호 바꾸기 강제"). +/// +/// - 짙은 띠 → 제목 · 설명 · 주소 → (위쪽 상자) → (임시 비밀번호) · 새 비밀번호([보기]) · 확인 · (인증 앱 코드) → [비밀번호 바꾸고 계속] → 안내. +/// - 임시 비밀번호는 로그인 때 넣은 것을 세션 노티파이어가 쥐고 있다(10분) — 없을 때만(앱을 다시 열었다 · 10분 지남) 칸을 보인다. +/// - 코드 칸은 2단계 인증을 켠 직원만 — 코드 단계를 거쳐 왔거나 `GET /two-factor` 가 켜짐이거나 서버가 `AUTH_007` 로 코드를 요구했을 때(웹과 같다). +/// - 보내기 전에 서버와 같은 규칙을 본다(8자 이상 · 임시 비밀번호와 다름 · 확인 일치 · 128자). 서버 거절은 칸 아래에 — +/// `AUTH_005` 임시 비밀번호 칸(쥔 것이었으면 지우고 칸을 보인다) · `VAL_001` 새 비밀번호 칸 · `AUTH_006` · `AUTH_007` 코드 칸. +/// 시도 과다(`REQ_001`)는 위쪽 상자(웹과 같다 — 다음에 보낼 때 내린다). 그 밖은 전역 알림. +/// - 바뀌면 칸을 비우고 세션 노티파이어가 새 토큰을 받아 다음 단계(2단계 켜기) · 메일로 옮긴다. 넘기지 못하면 위쪽 상자에 사전 문구 + [다시 시도] — +/// 그동안 칸 · 단추는 막는다(이미 바꿨다). +/// - 보내는 동안 · 넘기는 동안은 [다른 계정으로 로그인]을 막는다(웹과 같다) — 서버가 이미 바꿨을 수 있는데 그 결과를 버리면 +/// 직원이 임시 비밀번호로 다시 들어가다 막힌다(`AUTH_004`). +class PasswordChangePage extends ConsumerStatefulWidget { + const PasswordChangePage({super.key}); + + @override + ConsumerState createState() => _PasswordChangePageState(); +} + +class _PasswordChangePageState extends ConsumerState { + final TextEditingController _temporary = TextEditingController(); + final TextEditingController _next = TextEditingController(); + final TextEditingController _confirm = TextEditingController(); + final TextEditingController _code = TextEditingController(); + final FocusNode _temporaryFocus = FocusNode(); + final FocusNode _nextFocus = FocusNode(); + final FocusNode _confirmFocus = FocusNode(); + final FocusNode _codeFocus = FocusNode(); + + /// 로그인 때 넣은 비밀번호(임시 비밀번호)를 세션 노티파이어가 쥐고 있나 — 없으면 칸을 보인다 + late final ValueListenable _held; + late final String _email; + + /// 코드 단계를 거쳐 왔다 — 2단계 인증을 켠 직원 + late final bool _passedCodeStep; + + /// `GET /two-factor` 가 켜짐이다 + bool _twoFactorOn = false; + + /// 서버가 코드를 요구했다(`AUTH_007` — 그사이 다른 곳에서 켬) + bool _codeRequired = false; + + bool _temporaryShown = false; + bool _nextShown = false; + bool _submitting = false; + bool _finishing = false; + + /// 바꿨지만 다음으로 넘기지 못했다 — 사전 문구(위쪽 상자 + [다시 시도]) + String? _finishError; + + /// 위쪽 상자 — 시도 과다(`REQ_001`) 사전 문구 + String? _alert; + PasswordChangeProblems _problems = const PasswordChangeProblems(); + String? _temporaryError; + String? _nextError; + String? _codeError; + + bool get _showCode => _passedCodeStep || _twoFactorOn || _codeRequired; + + bool get _busy => _submitting || _finishing; + + /// 칸 · 단추를 막는다 — 바쁘거나, 이미 바꿨는데 넘기지 못했다(다시 보내면 안 된다) + bool get _blocked => _busy || _finishError != null; + + @override + void initState() { + super.initState(); + final SessionNotifier session = ref.read(sessionProvider.notifier); + _held = session.heldLoginListenable; + _held.addListener(_onHeldChanged); + _passedCodeStep = session.passedCodeStep; + final SessionState? state = ref.read(sessionProvider).value; + _email = state is SignedIn ? state.info.email : ''; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + (_held.value ? _nextFocus : _temporaryFocus).requestFocus(); + // 코드 단계를 거치지 않았으면(앱을 다시 열었다) 2단계 인증 상태를 묻는다 — 못 받아도 서버의 AUTH_007 이 칸을 부른다 + if (!_passedCodeStep) unawaited(_loadTwoFactorStatus()); + }); + } + + @override + void dispose() { + _held.removeListener(_onHeldChanged); + _clearFields(); + for (final TextEditingController controller in [ + _temporary, + _next, + _confirm, + _code, + ]) { + controller.dispose(); + } + for (final FocusNode node in [ + _temporaryFocus, + _nextFocus, + _confirmFocus, + _codeFocus, + ]) { + node.dispose(); + } + super.dispose(); + } + + /// 쥔 임시 비밀번호가 사라졌다(10분 · 서버 거절) — 칸을 보인다 + void _onHeldChanged() { + if (mounted) setState(() {}); + } + + Future _loadTwoFactorStatus() async { + final SessionNotifier session = ref.read(sessionProvider.notifier); + final int attempt = session.attempt; + try { + final TwoFactorStatus status = await ref + .read(twoFactorRepositoryProvider) + .status(); + if (!mounted || session.attempt != attempt) return; + if (status.isEnabled) setState(() => _twoFactorOn = true); + } on DioException { + // 못 받았다 — 서버가 AUTH_007 로 코드를 요구하면 그때 칸을 보인다 + } + } + + void _clearFields() { + _temporary.clear(); + _next.clear(); + _confirm.clear(); + _code.clear(); + } + + void _focusLater(FocusNode node) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) node.requestFocus(); + }); + } + + Future _submit() async { + if (_blocked) return; + final SessionNotifier session = ref.read(sessionProvider.notifier); + final ({String email, String password})? held = session.heldLogin; + final PasswordChangeProblems problems = passwordChangeProblems( + current: held?.password ?? _temporary.text, + next: _next.text, + confirm: _confirm.text, + ); + final bool codeMissing = _showCode && !isCompleteOtp(_code.text); + setState(() { + _alert = null; + _problems = problems; + _temporaryError = null; + _nextError = problems.next == NewPasswordProblem.tooLong + ? ErrorLexicon.getMessage('VAL_001') + : null; + _codeError = codeMissing ? ErrorLexicon.getMessage('AUTH_007') : null; + }); + if (problems.hasAny) { + // 10분이 막 지나 임시 비밀번호 칸이 이제 생겼을 수 있다 — 그린 뒤에 초점 + _focusLater( + problems.currentMissing + ? _temporaryFocus + : problems.next != null + ? _nextFocus + : _confirmFocus, + ); + return; + } + if (codeMissing) { + _codeFocus.requestFocus(); + return; + } + + final int attempt = session.attempt; + setState(() => _submitting = true); + final RestrictedStepResult result; + try { + result = await session.changePassword( + newPassword: _next.text, + currentPassword: held == null ? _temporary.text : null, + otpCode: _showCode ? _code.text : null, + ); + } on DioException catch (error) { + if (mounted && session.attempt == attempt) _onFailure(error); + return; + } finally { + if (mounted) setState(() => _submitting = false); + } + if (!mounted) return; + switch (result) { + case RestrictedStepResult.stale: + return; + case RestrictedStepResult.needsPassword: + // 쥔 비밀번호가 그사이 사라졌다 — 칸을 보이고 안내 글을 빨갛게 + setState( + () => _problems = const PasswordChangeProblems(currentMissing: true), + ); + _focusLater(_temporaryFocus); + return; + case RestrictedStepResult.done: + setState(() { + _clearFields(); + _problems = const PasswordChangeProblems(); + }); + } + await _finish(); + } + + void _onFailure(DioException error) { + final String message = toApiException(error).message; + switch (apiErrorCodeOf(error)) { + case 'AUTH_005': + // 쥔 비밀번호였으면 세션 노티파이어가 지웠다 — 임시 비밀번호 칸이 이 오류와 함께 보인다 + setState(() => _temporaryError = message); + _focusLater(_temporaryFocus); + case 'VAL_001': + setState(() => _nextError = message); + _nextFocus.requestFocus(); + case 'AUTH_006': + setState(() => _codeError = message); + _code.clear(); + _codeFocus.requestFocus(); + case 'AUTH_007': + // 그사이 다른 곳에서 2단계 인증을 켰다 — 칸을 보인다 + setState(() { + _codeRequired = true; + _codeError = message; + }); + _code.clear(); + _focusLater(_codeFocus); + case 'REQ_001': + // 칸 · 단추는 그대로 — 잠시 뒤 다시 보낼 수 있다 + setState(() => _alert = message); + default: + showLoginFailure(ref, error); + } + } + + /// 바꾼 뒤 — 새 토큰을 받아 다음 단계로. 넘기지 못하면 위쪽 상자 + [다시 시도] + Future _finish() async { + final SessionNotifier session = ref.read(sessionProvider.notifier); + final ScaffoldMessengerState? messenger = ScaffoldMessenger.maybeOf( + context, + ); + setState(() { + _finishing = true; + _finishError = null; + }); + try { + final RestrictedStepResult result = await session.finishRestrictedStep(); + if (result == RestrictedStepResult.done) { + messenger?.showSnackBar( + const SnackBar(content: Text(PasswordChangeTexts.done)), + ); + } + } on DioException catch (error) { + if (mounted) { + setState(() => _finishError = toApiException(error).message); + } + } finally { + if (mounted) setState(() => _finishing = false); + } + } + + // 칸을 고치면 그 칸에 걸린 오류를 내린다(임시 비밀번호를 고치면 "임시 비밀번호와 같음"도 다시 본다 — 웹과 같다) + void _onTemporaryChanged(String _) { + if (!_problems.currentMissing && + _temporaryError == null && + _problems.next != NewPasswordProblem.rule) { + return; + } + setState(() { + _problems = PasswordChangeProblems( + next: _problems.next == NewPasswordProblem.rule ? null : _problems.next, + confirmMismatch: _problems.confirmMismatch, + ); + _temporaryError = null; + }); + } + + void _onNextChanged(String _) { + if (_problems.next == null && + !_problems.confirmMismatch && + _nextError == null) { + return; + } + setState(() { + _problems = PasswordChangeProblems( + currentMissing: _problems.currentMissing, + ); + _nextError = null; + }); + } + + void _onConfirmChanged(String _) { + if (!_problems.confirmMismatch) return; + setState(() { + _problems = PasswordChangeProblems( + currentMissing: _problems.currentMissing, + next: _problems.next, + ); + }); + } + + void _onCodeChanged(String _) { + if (_codeError != null) setState(() => _codeError = null); + } + + @override + Widget build(BuildContext context) { + final bool askTemporary = !_held.value; + final String? finishError = _finishError; + final String? alert = _alert; + final bool showCode = _showCode; + return LoginStepFrame( + onOtherAccount: () => + unawaited(ref.read(sessionProvider.notifier).switchAccount()), + otherAccountEnabled: !_busy, + children: [ + LoginHeading( + title: ScreenTitles.passwordChange, + subtitle: PasswordChangeTexts.subtitle, + email: _email, + ), + if (finishError != null) + LoginAlertBox( + message: finishError, + actionLabel: RestrictedStepTexts.retry, + onAction: _busy ? null : () => unawaited(_finish()), + ) + else if (alert != null) + LoginAlertBox(message: alert), + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s16, + children: [ + if (askTemporary) + LoginPasswordField( + label: PasswordChangeTexts.temporaryLabel, + controller: _temporary, + focusNode: _temporaryFocus, + shown: _temporaryShown, + onToggle: () => + setState(() => _temporaryShown = !_temporaryShown), + toggleLabel: PasswordChangeTexts.showTemporary, + hint: PasswordChangeTexts.temporaryHint, + hintIsError: _problems.currentMissing, + error: _temporaryError, + textInputAction: TextInputAction.next, + onSubmitted: (_) => _nextFocus.requestFocus(), + onChanged: _onTemporaryChanged, + autofillHints: const [AutofillHints.password], + readOnly: _blocked, + ), + LoginPasswordField( + label: PasswordChangeTexts.nextLabel, + controller: _next, + focusNode: _nextFocus, + shown: _nextShown, + onToggle: () => setState(() => _nextShown = !_nextShown), + toggleLabel: PasswordChangeTexts.showNext, + hint: PasswordChangeTexts.nextHint, + hintIsError: _problems.next == NewPasswordProblem.rule, + error: _nextError, + textInputAction: TextInputAction.next, + onSubmitted: (_) => _confirmFocus.requestFocus(), + onChanged: _onNextChanged, + autofillHints: const [AutofillHints.newPassword], + readOnly: _blocked, + ), + LoginTextField( + label: PasswordChangeTexts.confirmLabel, + controller: _confirm, + focusNode: _confirmFocus, + // 보기 단추 없이 새 비밀번호의 [보기]를 따른다 + obscureText: !_nextShown, + keyboardType: TextInputType.visiblePassword, + error: _problems.confirmMismatch + ? PasswordChangeTexts.mismatch + : null, + textInputAction: showCode + ? TextInputAction.next + : TextInputAction.done, + onSubmitted: (_) => + showCode ? _codeFocus.requestFocus() : unawaited(_submit()), + onChanged: _onConfirmChanged, + autofillHints: const [AutofillHints.newPassword], + readOnly: _blocked, + ), + if (showCode) + LoginCodeField( + label: PasswordChangeTexts.codeLabel, + controller: _code, + focusNode: _codeFocus, + hint: PasswordChangeTexts.codeHint, + error: _codeError, + textInputAction: TextInputAction.done, + onSubmitted: (_) => unawaited(_submit()), + onChanged: _onCodeChanged, + readOnly: _blocked, + ), + LoginSubmitButton( + label: PasswordChangeTexts.submit, + busyLabel: PasswordChangeTexts.submitting, + busy: _busy, + enabled: finishError == null, + onPressed: () => unawaited(_submit()), + inForm: true, + ), + ], + ), + const LoginNote(text: PasswordChangeTexts.note), + ], + ); + } +} diff --git a/flutter/lib/features/auth/presentation/pages/restricted_login_page.dart b/flutter/lib/features/auth/presentation/pages/restricted_login_page.dart new file mode 100644 index 0000000..2083254 --- /dev/null +++ b/flutter/lib/features/auth/presentation/pages/restricted_login_page.dart @@ -0,0 +1,34 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/auth_interceptor.dart'; +import 'package:arasystem_app/core/api/error_lexicon.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_frame.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_parts.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 앱이 모르는 제한(서버가 새 제한을 더했을 때) — `AUTH_010` 사전 문구와 [다른 계정으로 로그인]만(웹과 같다 — 다시 받기 · 다시 보내기를 되풀이하지 않는다). +/// +/// 제목이 없는 화면이라 사전 문구 상자가 화면 이름이다(단계가 바뀌면 화면 낭독기가 읽는다). +class RestrictedLoginPage extends ConsumerWidget { + const RestrictedLoginPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final SessionState? state = ref.watch(sessionProvider).value; + final String? email = state is SignedIn ? state.info.email : null; + return LoginStepFrame( + onOtherAccount: () => + unawaited(ref.read(sessionProvider.notifier).switchAccount()), + children: [ + if (email != null) LoginAccountChip(email: email), + LoginAlertBox( + message: ErrorLexicon.getMessage(kRestrictedSessionCode), + namesRoute: true, + ), + ], + ); + } +} diff --git a/flutter/lib/features/auth/presentation/pages/two_factor_setup_page.dart b/flutter/lib/features/auth/presentation/pages/two_factor_setup_page.dart new file mode 100644 index 0000000..0685756 --- /dev/null +++ b/flutter/lib/features/auth/presentation/pages/two_factor_setup_page.dart @@ -0,0 +1,633 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/error_lexicon.dart'; +import 'package:arasystem_app/core/constants/login_texts.dart'; +import 'package:arasystem_app/core/constants/screen_titles.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/core/utils/display_text.dart'; +import 'package:arasystem_app/core/utils/otp_code.dart'; +import 'package:arasystem_app/features/auth/data/models/two_factor_enrollment.dart'; +import 'package:arasystem_app/features/auth/data/models/two_factor_status.dart'; +import 'package:arasystem_app/features/auth/presentation/login_failures.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_fields.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_frame.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_parts.dart'; +import 'package:arasystem_app/shared/widgets/keep_all_text.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 키를 받는 상태 +enum _EnrollmentLoad { loading, ready, failed } + +/// 로그인 넷째 단계 — 2단계 인증 켜기(제한 `twoFactorSetup` — 앱 보드 `App1Login` 「2단계 인증 켜기」 · 계약 6절 "2단계 인증 필수"). +/// +/// - 같은 휴대폰이라 QR 대신 ① [인증 앱에 추가](otpauth 주소로 인증 앱을 바로 연다) · 키와 [복사] ② 코드 칸 (③ 지금 비밀번호 — 쥐고 있지 않을 때만). +/// - 키는 이 화면을 열 때마다 새로 받는다(`POST /two-factor/enrollments` — 10분 안에 켠다). **키 · otpauth 주소는 이 화면의 메모리에만** 두고 +/// 켜거나 떠나면 버린다(Riverpod 상태 · 로그에 두지 않는다). [복사]한 키는 클립보드에 남는다 — 앱이 지우지 않는다. +/// - 인증 앱을 찾지 못하면(열 앱 없음) ① 아래에 안내 글 — 키를 직접 넣는 길이 바로 아래에 있다. +/// - 서버 거절: `AUTH_006` 코드 칸(비우고 초점 — 키는 그대로), `AUTH_005` 지금 비밀번호 칸(쥔 것이었으면 지우고 ③ 을 보인다), +/// `AUTH_008`(시간 지남 · 그사이 바뀜) 위쪽 상자 + 새 키(다른 곳에서 이미 켰으면 이 단계를 건너뛴다), 시도 과다(`REQ_001`) 위쪽 상자. +/// 그 밖은 전역 알림 — 서버의 `VAL_001` 도(코드는 보내기 전에 6자리를 보므로 지금 비밀번호 쪽이다 · 맞는 코드는 지우지 않는다, 웹과 같다). +/// - 켜면 세션 노티파이어가 새 토큰을 받아 메일로 옮긴다. 넘기지 못하면 위쪽 상자에 사전 문구 + [다시 시도] — 그동안 칸 · 단추는 막는다(이미 켰다). +/// - 보내는 동안 · 넘기는 동안은 [다른 계정으로 로그인]을 막는다(웹과 같다 — 서버가 이미 켰을 수 있다). +class TwoFactorSetupPage extends ConsumerStatefulWidget { + const TwoFactorSetupPage({super.key}); + + @override + ConsumerState createState() => _TwoFactorSetupPageState(); +} + +class _TwoFactorSetupPageState extends ConsumerState { + final TextEditingController _code = TextEditingController(); + final TextEditingController _password = TextEditingController(); + final FocusNode _codeFocus = FocusNode(); + final FocusNode _passwordFocus = FocusNode(); + + /// 로그인 때 넣은 비밀번호(또는 방금 정한 새 비밀번호)를 세션 노티파이어가 쥐고 있나 — 없으면 ③ 칸 + late final ValueListenable _held; + late final String _email; + + /// 등록 — 키 · otpauth 주소(비밀). 이 화면의 메모리에만 + TwoFactorEnrollment? _enrollment; + _EnrollmentLoad _load = _EnrollmentLoad.loading; + + /// 키를 받지 못한 까닭 — 사전 문구 + String? _loadError; + + /// 키를 받는 차례 — 다시 받으면 앞의 응답은 버린다 + int _loadSeq = 0; + + /// [인증 앱에 추가]로 열 앱을 찾지 못했다 + bool _openFailed = false; + bool _passwordShown = false; + bool _submitting = false; + bool _finishing = false; + + /// 위쪽 상자 — 처음부터 다시(`AUTH_008`) · 시도 과다(`REQ_001`) 사전 문구. 다음에 보낼 때 내린다 + String? _alert; + + /// 켰지만 다음으로 넘기지 못했다 — 사전 문구(위쪽 상자 + [다시 시도]) + String? _finishError; + String? _codeError; + String? _passwordError; + + /// ③ 을 비우고 보냈다 — 안내 글이 빨간 오류가 된다 + bool _passwordMissing = false; + + /// 켰다(또는 다른 곳에서 이미 켰다) — 키 · 칸은 쓸 일이 없다. 넘기지 못했으면 위쪽 상자와 [다시 시도]만 남긴다 + bool _enabled = false; + + bool get _busy => _submitting || _finishing; + + /// 칸 · 단추를 막는다 — 바쁘거나, 이미 켰는데 넘기지 못했다 + bool get _blocked => _busy || _finishError != null; + + @override + void initState() { + super.initState(); + final SessionNotifier session = ref.read(sessionProvider.notifier); + _held = session.heldLoginListenable; + _held.addListener(_onHeldChanged); + final SessionState? state = ref.read(sessionProvider).value; + _email = state is SignedIn ? state.info.email : ''; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + unawaited(ref.read(supportContactProvider.notifier).ensureLoaded()); + unawaited(_loadEnrollment()); + }); + } + + @override + void dispose() { + _held.removeListener(_onHeldChanged); + // 비밀을 버린다 — 키 · otpauth 주소 · 코드 · 비밀번호 + _loadSeq++; + _enrollment = null; + _code.clear(); + _password.clear(); + _code.dispose(); + _password.dispose(); + _codeFocus.dispose(); + _passwordFocus.dispose(); + super.dispose(); + } + + void _onHeldChanged() { + if (mounted) setState(() {}); + } + + /// 이 화면이 아직 있고 세션도 그대로인가 — 늦게 온 응답을 쓸지 + bool _stillHere(int attempt) => + mounted && ref.read(sessionProvider.notifier).attempt == attempt; + + Future _loadEnrollment() async { + final int seq = ++_loadSeq; + final int attempt = ref.read(sessionProvider.notifier).attempt; + setState(() { + _enrollment = null; + _load = _EnrollmentLoad.loading; + _loadError = null; + _openFailed = false; + }); + try { + final TwoFactorEnrollment enrollment = await ref + .read(twoFactorRepositoryProvider) + .startEnrollment(); + if (seq != _loadSeq || !_stillHere(attempt)) return; + setState(() { + _enrollment = enrollment; + _load = _EnrollmentLoad.ready; + }); + } on DioException catch (error) { + if (seq != _loadSeq || !_stillHere(attempt)) return; + // 이미 켜져 있다 — 다른 곳에서 켰다 + if (apiErrorCodeOf(error) == 'AUTH_008' && + await _skipIfEnabledElsewhere(attempt)) { + return; + } + if (seq != _loadSeq || !_stillHere(attempt)) return; + setState(() { + _load = _EnrollmentLoad.failed; + _loadError = toApiException(error).message; + }); + } + } + + /// 그사이 다른 곳에서 켰는지 — 켰으면 이 단계를 건너뛴다(true) + Future _skipIfEnabledElsewhere(int attempt) async { + final TwoFactorStatus status; + try { + status = await ref.read(twoFactorRepositoryProvider).status(); + } on DioException { + return false; + } + if (!status.isEnabled) return false; + if (_stillHere(attempt)) { + setState(() { + _enabled = true; + _loadSeq++; + _enrollment = null; + _code.clear(); + _password.clear(); + }); + await _finish(announce: false); + } + return true; + } + + Future _openAuthenticator() async { + final TwoFactorEnrollment? enrollment = _enrollment; + if (enrollment == null) return; + final bool opened = await ref + .read(authenticatorLauncherProvider) + .open(enrollment.otpauthUrl); + if (mounted) setState(() => _openFailed = !opened); + } + + Future _copyKey() async { + final TwoFactorEnrollment? enrollment = _enrollment; + if (enrollment == null) return; + final ScaffoldMessengerState messenger = ScaffoldMessenger.of(context); + bool copied = true; + try { + // 키를 띄어쓰기 없이 — 클립보드에 남는다(앱이 지우지 않는다) + await Clipboard.setData(ClipboardData(text: enrollment.secret)); + } on PlatformException { + copied = false; + } + messenger + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + copied + ? TwoFactorSetupTexts.copied + : TwoFactorSetupTexts.copyFailed, + ), + ), + ); + } + + Future _submit() async { + final TwoFactorEnrollment? enrollment = _enrollment; + if (_blocked || enrollment == null) return; + final SessionNotifier session = ref.read(sessionProvider.notifier); + final bool held = session.heldLogin != null; + final bool codeMissing = !isCompleteOtp(_code.text); + final bool passwordMissing = !held && _password.text.isEmpty; + setState(() { + _alert = null; + _codeError = codeMissing ? ErrorLexicon.getMessage('AUTH_007') : null; + _passwordError = null; + _passwordMissing = passwordMissing; + }); + if (codeMissing) { + _codeFocus.requestFocus(); + return; + } + if (passwordMissing) { + // 10분이 막 지나 ③ 칸이 이제 생겼을 수 있다 — 그린 뒤에 초점 + _focusLater(_passwordFocus); + return; + } + + final int attempt = session.attempt; + setState(() => _submitting = true); + final RestrictedStepResult result; + try { + result = await session.enableTwoFactor( + enrollmentId: enrollment.enrollmentId, + otpCode: _code.text, + currentPassword: held ? null : _password.text, + ); + } on DioException catch (error) { + if (_stillHere(attempt)) await _onFailure(error, attempt); + return; + } finally { + if (mounted) setState(() => _submitting = false); + } + if (!mounted) return; + switch (result) { + case RestrictedStepResult.stale: + return; + case RestrictedStepResult.needsPassword: + setState(() => _passwordMissing = true); + _focusLater(_passwordFocus); + return; + case RestrictedStepResult.done: + // 켰다 — 다시 쓸 일이 없는 비밀(키 · 코드 · 비밀번호)을 버린다 + setState(() { + _enabled = true; + _loadSeq++; + _enrollment = null; + _code.clear(); + _password.clear(); + }); + } + await _finish(announce: true); + } + + Future _onFailure(DioException error, int attempt) async { + final String message = toApiException(error).message; + switch (apiErrorCodeOf(error)) { + case 'AUTH_006': + case 'AUTH_007': + setState(() => _codeError = message); + _code.clear(); + _codeFocus.requestFocus(); + case 'REQ_001': + // 칸 · 단추 · 키는 그대로 — 잠시 뒤 다시 보낼 수 있다 + setState(() => _alert = message); + case 'AUTH_005': + // 쥔 비밀번호였으면 세션 노티파이어가 지웠다 — ③ 칸이 이 오류와 함께 보인다 + setState(() => _passwordError = message); + _focusLater(_passwordFocus); + case 'AUTH_008': + if (await _skipIfEnabledElsewhere(attempt)) return; + if (!_stillHere(attempt)) return; + setState(() { + _alert = message; + _codeError = null; + _passwordError = null; + }); + _code.clear(); + await _loadEnrollment(); + default: + showLoginFailure(ref, error); + } + } + + /// 켠 뒤 — 새 토큰을 받아 메일로. 넘기지 못하면 위쪽 상자 + [다시 시도] + Future _finish({required bool announce}) async { + final SessionNotifier session = ref.read(sessionProvider.notifier); + final ScaffoldMessengerState? messenger = ScaffoldMessenger.maybeOf( + context, + ); + setState(() { + _finishing = true; + _finishError = null; + }); + try { + final RestrictedStepResult result = await session.finishRestrictedStep(); + if (announce && result == RestrictedStepResult.done) { + messenger?.showSnackBar( + const SnackBar(content: Text(TwoFactorSetupTexts.done)), + ); + } + } on DioException catch (error) { + if (mounted) { + setState(() => _finishError = toApiException(error).message); + } + } finally { + if (mounted) setState(() => _finishing = false); + } + } + + void _focusLater(FocusNode node) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) node.requestFocus(); + }); + } + + void _onCodeChanged(String _) { + if (_codeError != null) setState(() => _codeError = null); + } + + void _onPasswordChanged(String _) { + if (_passwordError != null || _passwordMissing) { + setState(() { + _passwordError = null; + _passwordMissing = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final TextStyle? stepTitle = theme.textTheme.titleSmall; + // 켠 뒤에는 쥔 비밀번호를 지웠다 — ③ 을 다시 보이지 않는다 + final bool askPassword = !_held.value && !_enabled; + final String? finishError = _finishError; + final String? alert = _alert; + // 켰지만 넘기지 못했다 — 키 · 칸 · 단추는 쓸 일이 없어 위쪽 상자와 [다시 시도]만 남긴다 + final bool onlyRetry = _enabled && finishError != null; + return LoginStepFrame( + topPadding: AppSpacing.s24, + onOtherAccount: () => + unawaited(ref.read(sessionProvider.notifier).switchAccount()), + otherAccountEnabled: !_busy, + children: [ + LoginHeading( + title: ScreenTitles.twoFactorSetup, + subtitle: TwoFactorSetupTexts.subtitle, + email: _email, + ), + if (finishError != null) + LoginAlertBox( + message: finishError, + actionLabel: RestrictedStepTexts.retry, + onAction: _busy ? null : () => unawaited(_finish(announce: true)), + ) + else if (alert != null) + LoginAlertBox(message: alert), + if (!onlyRetry) ...[ + _steps(stepTitle, askPassword), + LoginSubmitButton( + label: TwoFactorSetupTexts.submit, + busyLabel: TwoFactorSetupTexts.submitting, + busy: _busy, + enabled: _load == _EnrollmentLoad.ready && finishError == null, + onPressed: () => unawaited(_submit()), + ), + ], + LoginSupportLine( + question: SupportLineTexts.authenticatorQuestion, + fallback: SupportLineTexts.authenticatorFallback, + contact: ref.watch(supportContactProvider), + ), + const LoginNote(text: TwoFactorSetupTexts.note), + ], + ); + } + + /// 차례 ① 인증 앱에 추가 · ② 코드 · (③ 지금 비밀번호) + Widget _steps(TextStyle? stepTitle, bool askPassword) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s22, + children: [ + LoginStepItem( + number: 1, + child: _AddToAuthenticator( + email: _email, + enrollment: _enrollment, + load: _load, + loadError: _loadError, + openFailed: _openFailed, + enabled: !_blocked, + onOpen: () => unawaited(_openAuthenticator()), + onCopy: () => unawaited(_copyKey()), + onRetry: () => unawaited(_loadEnrollment()), + ), + ), + LoginStepItem( + number: 2, + child: LoginCodeField( + label: TwoFactorSetupTexts.codeLabel, + labelStyle: stepTitle, + description: TwoFactorSetupTexts.codeHint, + controller: _code, + focusNode: _codeFocus, + error: _codeError, + textInputAction: askPassword + ? TextInputAction.next + : TextInputAction.done, + onSubmitted: (_) => askPassword + ? _passwordFocus.requestFocus() + : unawaited(_submit()), + onChanged: _onCodeChanged, + readOnly: _blocked, + ), + ), + if (askPassword) + LoginStepItem( + number: 3, + child: LoginPasswordField( + label: TwoFactorSetupTexts.passwordLabel, + controller: _password, + focusNode: _passwordFocus, + shown: _passwordShown, + onToggle: () => setState(() => _passwordShown = !_passwordShown), + toggleLabel: TwoFactorSetupTexts.showPassword, + hint: TwoFactorSetupTexts.passwordHint, + hintIsError: _passwordMissing, + error: _passwordError, + textInputAction: TextInputAction.done, + onSubmitted: (_) => unawaited(_submit()), + onChanged: _onPasswordChanged, + autofillHints: const [AutofillHints.password], + readOnly: _blocked, + ), + ), + ], + ); + } +} + +/// ① 인증 앱에 추가 — [인증 앱에 추가] · (찾지 못함 안내) · 키와 [복사] · 저장될 이름. 키를 받는 동안은 회색 자리, 못 받으면 사전 문구 + [다시 시도] +class _AddToAuthenticator extends StatelessWidget { + const _AddToAuthenticator({ + required this.email, + required this.enrollment, + required this.load, + required this.loadError, + required this.openFailed, + required this.enabled, + required this.onOpen, + required this.onCopy, + required this.onRetry, + }); + + final String email; + final TwoFactorEnrollment? enrollment; + final _EnrollmentLoad load; + final String? loadError; + final bool openFailed; + + /// 켜는 중 · 이미 켰으면 누를 수 없다 + final bool enabled; + final VoidCallback onOpen; + final VoidCallback onCopy; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + final String? error = loadError; + final bool ready = enrollment != null && enabled; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s10, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s4, + children: [ + Semantics( + header: true, + child: KeepAllText( + TwoFactorSetupTexts.addTitle, + style: theme.textTheme.titleSmall, + ), + ), + KeepAllText( + TwoFactorSetupTexts.addHint, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + if (load == _EnrollmentLoad.failed && error != null) + LoginAlertBox( + message: error, + actionLabel: TwoFactorSetupTexts.retry, + onAction: onRetry, + ) + else ...[ + OutlinedButton.icon( + onPressed: ready ? onOpen : null, + icon: const Icon(Icons.open_in_new, size: AppSize.iconSmall), + label: const Text(TwoFactorSetupTexts.addButton), + ), + if (openFailed) + const LoginFieldMessage( + text: TwoFactorSetupTexts.addFailed, + isError: true, + ), + Padding( + padding: const EdgeInsets.only(top: AppSpacing.s2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s6, + children: [ + KeepAllText( + TwoFactorSetupTexts.keyLabel, + style: theme.textTheme.labelMedium?.copyWith( + fontWeight: AppTypography.semiBold, + color: extra.textStrong, + ), + ), + _KeyBox(enrollment: enrollment, onCopy: ready ? onCopy : null), + KeepAllText( + TwoFactorSetupTexts.savedAs(email), + style: theme.textTheme.labelSmall?.copyWith( + color: extra.textMuted, + ), + ), + ], + ), + ), + ], + ], + ); + } +} + +/// 키 상자 — 4자씩 · 두 줄 · 고정폭(보기 위한 띄어쓰기) + [복사](키를 띄어쓰기 없이). 받는 동안은 회색 자리 +class _KeyBox extends StatelessWidget { + const _KeyBox({required this.enrollment, required this.onCopy}); + + final TwoFactorEnrollment? enrollment; + final VoidCallback? onCopy; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + final TextStyle? base = theme.textTheme.bodyMedium; + final double fontSize = base?.fontSize ?? AppTypography.bodyMedium; + final TextStyle keyStyle = TextStyle( + fontFamily: AppTypography.monospaceFamily, + fontSize: fontSize, + height: AppTypography.lineHeightRelaxed, + letterSpacing: fontSize * AppTypography.letterSpacingKeyEm, + color: scheme.onSurface, + ); + final TwoFactorEnrollment? current = enrollment; + final Widget key = current == null + // 키를 받는 중 — 두 줄 높이의 회색 자리 + ? Semantics( + label: TwoFactorSetupTexts.keyLoading, + child: Container( + height: fontSize * AppTypography.lineHeightRelaxed * 2, + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + ), + ) + : Text(keyLines(current.secret).join('\n'), style: keyStyle); + return Container( + padding: const EdgeInsetsDirectional.fromSTEB( + AppSpacing.s12, + AppSpacing.s4, + AppSpacing.s2, + AppSpacing.s4, + ), + decoration: BoxDecoration( + color: scheme.surfaceContainer, + border: Border.all(color: scheme.outlineVariant), + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + child: Row( + spacing: AppSpacing.s4, + children: [ + Expanded(child: key), + TextButton.icon( + onPressed: onCopy, + icon: const Icon( + Icons.content_copy_outlined, + size: AppSize.iconSmall, + ), + label: const Text( + TwoFactorSetupTexts.copy, + semanticsLabel: TwoFactorSetupTexts.copyLabel, + ), + ), + ], + ), + ); + } +} diff --git a/flutter/lib/features/auth/presentation/providers/login_providers.dart b/flutter/lib/features/auth/presentation/providers/login_providers.dart new file mode 100644 index 0000000..2592a82 --- /dev/null +++ b/flutter/lib/features/auth/presentation/providers/login_providers.dart @@ -0,0 +1,62 @@ +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/utils/provider_retry.dart'; +import 'package:arasystem_app/features/auth/data/authenticator_launcher.dart'; +import 'package:arasystem_app/features/auth/data/repositories/support_contact_repository.dart'; +import 'package:arasystem_app/features/auth/data/repositories/two_factor_repository.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 2단계 인증 API — 로그인 틀의 제한 단계가 쓴다 +final Provider twoFactorRepositoryProvider = + Provider( + (Ref ref) => TwoFactorRepository(ref.watch(dioProvider)), + ); + +/// 문의 연락처 API +final Provider supportContactRepositoryProvider = + Provider( + (Ref ref) => SupportContactRepository(ref.watch(dioProvider)), + ); + +/// [인증 앱에 추가] — 시험은 가짜로 덮어쓴다 +final Provider authenticatorLauncherProvider = + Provider((Ref ref) => const AuthenticatorLauncher()); + +/// 문의 연락처 한 줄 — 로그인 첫 화면 · 코드 단계 · 2단계 켜기의 문의 줄. 정하지 않았거나 받지 못했으면 null(화면은 지금 글). +/// +/// 화면이 [SupportContactNotifier.ensureLoaded] 를 부른다. 한 번 받으면 앱이 떠 있는 동안 다시 묻지 않고(운영 도구로 바꾸면 다음 실행에 반영), +/// 받지 못했으면 다음에 화면을 열 때 다시 묻는다(웹 `support-contact.store` 와 같다). 회사 설정값이라 로그아웃 때 지우지 않는다. +final NotifierProvider supportContactProvider = + NotifierProvider( + SupportContactNotifier.new, + retry: noAutomaticRetry, + ); + +class SupportContactNotifier extends Notifier { + Future? _loading; + bool _loaded = false; + + @override + String? build() => null; + + /// 아직 받지 않았으면 받는다 — 몇 번을 불러도 요청은 하나 + Future ensureLoaded() { + if (_loaded) return Future.value(); + return _loading ??= _load(); + } + + Future _load() async { + try { + final String? text = await ref + .read(supportContactRepositoryProvider) + .fetch(); + if (!ref.mounted) return; + state = text; + _loaded = true; + } on DioException { + // 받지 못했다 — 지금 글 그대로(다음에 화면을 열 때 다시 묻는다) + } finally { + _loading = null; + } + } +} diff --git a/flutter/lib/features/auth/presentation/providers/login_secret.dart b/flutter/lib/features/auth/presentation/providers/login_secret.dart index cbf5ea8..9de70bd 100644 --- a/flutter/lib/features/auth/presentation/providers/login_secret.dart +++ b/flutter/lib/features/auth/presentation/providers/login_secret.dart @@ -1,15 +1,17 @@ import 'dart:async'; import 'package:arasystem_app/core/utils/clock.dart'; +import 'package:flutter/foundation.dart'; /// 로그인 때 넣은 비밀번호를 쥐고 있는 시간 — 웹과 같다(계약 5절 코드 단계 · 6절 제한 단계) const Duration kLoginSecretLifetime = Duration(minutes: 10); /// 로그인 때 넣은 주소 · 비밀번호 — 화면 상태(Riverpod 상태)에 두지 않고 세션 노티파이어 메모리에만 둔다. /// -/// 쓰는 곳: 코드 단계(비밀번호를 한 번 더 보낸다) · 새 비밀번호 단계(지금 비밀번호로 쓴다). -/// 지우는 때: 로그인을 마침 · [다른 계정으로 로그인] · 로그아웃 · 세션 끝 · 서버가 마지막으로 확인하고 10분. +/// 쓰는 곳: 코드 단계(비밀번호를 한 번 더 보낸다) · 새 비밀번호 단계(지금 비밀번호로 쓴다) · 2단계 켜기(지금 비밀번호 — 새 비밀번호를 정했으면 그것). +/// 지우는 때: 로그인을 마침 · [다른 계정으로 로그인] · 로그아웃 · 세션 끝 · 서버가 거절함(`AUTH_005`) · 서버가 마지막으로 확인하고 10분. /// 10분은 타이머와 시계 둘 다로 본다 — 앱이 뒤에 있는 동안 타이머가 늦어도 읽을 때 시계로 다시 본다. +/// 쥐고 있는지([held] — 참 · 거짓 하나, 비밀이 아니다)는 화면이 듣는다: 없으면 임시 · 지금 비밀번호 칸을 보인다. class LoginSecret { LoginSecret({ Clock clock = systemClock, @@ -19,6 +21,7 @@ class LoginSecret { final Clock _clock; final Duration _lifetime; + final ValueNotifier _held = ValueNotifier(false); String? _email; String? _password; @@ -27,10 +30,11 @@ class LoginSecret { /// 서버가 비밀번호를 맞다고 본 때 쥔다 — 이미 쥐고 있으면 바꾼다 void hold(String email, String password) { - clear(); + _forget(); _email = email; _password = password; renew(); + _held.value = true; } /// 서버가 다시 확인했다 — 10분을 이때부터 다시 센다 @@ -57,7 +61,15 @@ class LoginSecret { /// 쥐고 있는가 (10분이 지났는지는 [read] 가 본다) bool get isHeld => _password != null; + /// 쥐고 있는지가 바뀔 때 알린다 — 화면이 칸을 보일지 정한다(값은 참 · 거짓뿐) + ValueListenable get held => _held; + void clear() { + _forget(); + _held.value = false; + } + + void _forget() { _timer?.cancel(); _timer = null; _email = null; diff --git a/flutter/lib/features/auth/presentation/providers/session_provider.dart b/flutter/lib/features/auth/presentation/providers/session_provider.dart index a13f072..8ddd681 100644 --- a/flutter/lib/features/auth/presentation/providers/session_provider.dart +++ b/flutter/lib/features/auth/presentation/providers/session_provider.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/api_exception.dart'; import 'package:arasystem_app/core/api/api_paths.dart'; import 'package:arasystem_app/core/api/dio_provider.dart'; import 'package:arasystem_app/core/api/session_events.dart'; @@ -13,9 +14,12 @@ import 'package:arasystem_app/core/utils/provider_retry.dart'; import 'package:arasystem_app/features/auth/data/models/app_login_result.dart'; import 'package:arasystem_app/features/auth/data/models/auth_session_info.dart'; import 'package:arasystem_app/features/auth/data/repositories/auth_repository.dart'; +import 'package:arasystem_app/features/auth/data/repositories/two_factor_repository.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart'; import 'package:arasystem_app/features/auth/presentation/providers/login_secret.dart'; import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -38,18 +42,31 @@ final AsyncNotifierProvider sessionProvider = retry: noAutomaticRetry, ); -/// 세션 노티파이어 — 되살리기 · 로그인 · 코드 단계 · 로그아웃 · 세션 끝 · 제한 다시 맞추기. +/// 제한 단계(새 비밀번호 · 2단계 켜기)의 일이 어떻게 끝났나 — 실패는 `DioException` 으로 던진다 +enum RestrictedStepResult { + /// 마쳤다 — 세션이 그대로다 + done, + + /// 지금 비밀번호가 없다(로그인 때 넣은 것을 쥐고 있지 않다 — 10분 지남 · 앱을 다시 열었다). 보내지 않았다 — 화면이 칸을 보인다 + needsPassword, + + /// 그사이 세션이 바뀌었다(로그아웃 · 다른 계정 · 세션 끝) — 결과를 쓰지 않았다 + stale, +} + +/// 세션 노티파이어 — 되살리기 · 로그인 · 코드 단계 · 제한 단계(새 비밀번호 · 2단계 켜기) · 로그아웃 · 세션 끝 · 제한 다시 맞추기. /// /// 계약 `docs/mail/api-contract.md` 1절 "앱 로그인 › 앱이 할 일"대로 한다. /// - 실패는 늘 `DioException`(`error` 에 `ApiException`)으로 던진다 — 화면이 `apiErrorCodeOf` 로 갈라 칸 아래 · 위쪽 상자에 보인다. /// 알림은 인터셉터가 이미 띄웠거나(알림을 끄지 않은 코드) 끈 것이다 — 여기서 문구를 만들지 않는다. -/// 비밀이 든 요청(로그인 · 코드)의 실패 예외는 본문을 비운 채 온다 — 화면이 `AsyncValue` 로 상태에 두어도 된다. -/// - **시도 세대**([_epoch]): 로그아웃 · 다른 계정으로 로그인 · 세션 끝 때 오른다. 그 전에 시작한 되살리기 · 로그인 · 코드 · 제한 다시 맞추기의 -/// 늦은 결과는 버리고, 버린 로그인 결과에 토큰이 있으면 그 토큰으로 조용히 앱 logout 한다(끝낸 세션을 되살리지 않게). +/// 비밀이 든 요청(로그인 · 코드 · 비밀번호 바꾸기 · 2단계 켜기)의 실패 예외는 본문을 비운 채 온다 — 화면이 `AsyncValue` 로 상태에 두어도 된다. +/// - **시도 세대**([_epoch] — 화면은 [attempt]): 로그아웃 · 다른 계정으로 로그인 · 세션 끝 때 오른다. 그 전에 시작한 되살리기 · 로그인 · 코드 · +/// 제한 단계 · 제한 다시 맞추기의 늦은 결과는 버리고, 버린 로그인 결과에 토큰이 있으면 그 토큰으로 조용히 앱 logout 한다(끝낸 세션을 되살리지 않게). class SessionNotifier extends AsyncNotifier { LoginSecret? _secretHolder; Future? _rechecking; int _epoch = 0; + bool _passedCodeStep = false; /// 답을 기다리는 제한 다시 맞추기 요청 — 세션이 바뀌면(로그아웃 · 세션 끝) stale 로 답해 인터셉터를 놓아준다 final List _pendingRechecks = []; @@ -59,6 +76,8 @@ class SessionNotifier extends AsyncNotifier { AuthRepository get _repository => ref.read(authRepositoryProvider); + TwoFactorRepository get _twoFactor => ref.read(twoFactorRepositoryProvider); + TokenStore get _store => ref.read(tokenStoreProvider); TokenRefresher get _refresher => ref.read(tokenRefresherProvider); @@ -82,9 +101,18 @@ class SessionNotifier extends AsyncNotifier { return _restore(); } - /// 로그인 때 넣은 주소 · 비밀번호 — 코드 단계 · 새 비밀번호 단계가 쓴다(10분이 지났거나 제한이 풀렸으면 null) + /// 로그인 때 넣은 주소 · 비밀번호 — 코드 단계 · 제한 단계가 쓴다(10분이 지났거나 제한이 풀렸으면 null) ({String email, String password})? get heldLogin => _secret.read(); + /// 로그인 때 넣은 비밀번호를 쥐고 있는지 — 제한 단계 화면이 듣고, 없으면 임시 · 지금 비밀번호 칸을 보인다(값은 참 · 거짓뿐) + ValueListenable get heldLoginListenable => _secret.held; + + /// 시도 세대 — 화면이 요청 전에 읽어 두고, 끝난 뒤 다르면(그사이 로그아웃 · 다른 계정 · 세션 끝) 결과를 보이지 않는다 + int get attempt => _epoch; + + /// 이번 로그인이 코드 단계를 거쳤다 — 2단계 인증을 켠 직원이라 새 비밀번호 단계가 처음부터 코드 칸을 보인다(계약 6절) + bool get passedCodeStep => _passedCodeStep; + /// 되살리기가 장애로 실패했을 때 다시 한다 — 방금의 갱신 실패를 다시 쓰지 않고 새로 묻는다 void retry() { _refresher.forgetFailure(); @@ -94,6 +122,7 @@ class SessionNotifier extends AsyncNotifier { /// 비밀번호 단계 Future signIn({required String email, required String password}) async { final int epoch = _epoch; + _passedCodeStep = false; final AppLoginResult result = await _repository.login( email: email, password: password, @@ -103,12 +132,18 @@ class SessionNotifier extends AsyncNotifier { /// 코드 단계 — 쥐고 있는 비밀번호와 함께 보낸다(계약 5절). /// - /// 10분이 지났으면 보내지 않고 첫 단계로 돌아간다. `AUTH_004`(그사이 비밀번호가 바뀜)도 첫 단계로 돌아간 뒤 던진다. + /// - 10분이 지났으면 보내지 않고 첫 단계로 돌아간다(주소는 남긴다). + /// - `AUTH_004`(그사이 비밀번호가 바뀜)도 첫 단계로 돌아간 뒤 던진다 — 첫 단계가 위쪽 상자에 보인다(주소는 남긴다). + /// - 코드를 실었는데도 서버가 코드를 묻으면(`twoFactorRequired`) 받아들이지 않은 코드로 보고 `AUTH_006` 으로 던진다(웹과 같다). Future submitCode(String otpCode) async { + final SessionState? current = state.value; final ({String email, String password})? held = _secret.read(); if (held == null) { - state = const AsyncData( - SignedOut(reason: SignOutReason.codeTimedOut), + state = AsyncData( + SignedOut( + reason: SignOutReason.codeTimedOut, + email: current is CodeRequired ? current.email : null, + ), ); return; } @@ -126,18 +161,150 @@ class SessionNotifier extends AsyncNotifier { ref.mounted && apiErrorCodeOf(error) == 'AUTH_004') { _secret.clear(); - state = const AsyncData(SignedOut()); + state = AsyncData( + SignedOut(reason: SignOutReason.passwordRejected, email: held.email), + ); } rethrow; } + if (result.status == LoginStatus.twoFactorRequired) { + if (!ref.mounted || epoch != _epoch) return; + throw apiFailure(RequestOptions(path: ApiPaths.appLogin), 'AUTH_006'); + } await _apply( result, epoch: epoch, email: held.email, password: held.password, + viaCode: true, ); } + /// 새 비밀번호 단계(제한 `passwordChange` — 계약 6절 "비밀번호 바꾸기 강제") — `PUT /api/auth/password`. + /// + /// [currentPassword] 가 null 이면 로그인 때 쥔 비밀번호(임시 비밀번호)를 쓴다 — 쥐고 있지 않으면 보내지 않고 [RestrictedStepResult.needsPassword]. + /// [otpCode] 는 2단계 인증을 켠 직원만(로그인 때 쓴 코드는 2분 동안 다시 받지 않는다 — 다음 코드). + /// - 쥔 비밀번호를 서버가 거절하면(`AUTH_005`) 지운다 — 화면이 임시 비밀번호 칸을 그 오류와 함께 보인다. + /// - 바뀌면 새 비밀번호를 쥔다(다음 단계 2단계 켜기의 지금 비밀번호 — 서버가 방금 확인했으니 10분을 새로 센다). + /// 다음 화면으로 옮기는 일은 [finishRestrictedStep] 이 한다. + Future changePassword({ + required String newPassword, + String? currentPassword, + String? otpCode, + }) async { + final int epoch = _epoch; + final SessionState? current = state.value; + if (current is! SignedIn) return RestrictedStepResult.stale; + final ({String email, String password})? held = currentPassword == null + ? _secret.read() + : null; + final String? password = currentPassword ?? held?.password; + if (password == null) return RestrictedStepResult.needsPassword; + try { + await _repository.changePassword( + currentPassword: password, + newPassword: newPassword, + otpCode: otpCode, + ); + } on DioException catch (error) { + if (epoch == _epoch && + ref.mounted && + held != null && + apiErrorCodeOf(error) == 'AUTH_005') { + _secret.clear(); + } + rethrow; + } + if (!ref.mounted || epoch != _epoch) return RestrictedStepResult.stale; + _secret.hold(current.info.email, newPassword); + return RestrictedStepResult.done; + } + + /// 2단계 켜기 단계(제한 `twoFactorSetup` — 계약 6절 "2단계 인증 필수") — `POST /api/two-factor/activation`. + /// + /// [currentPassword] 가 null 이면 쥔 비밀번호(로그인 때 넣은 것 — 방금 새 비밀번호를 정했으면 그것)를 쓴다 — 없으면 [RestrictedStepResult.needsPassword]. + /// 쥔 비밀번호를 서버가 거절하면(`AUTH_005`) 지운다. 켜면 쥔 비밀번호를 지운다(다시 쓸 일이 없다). 다음 화면은 [finishRestrictedStep]. + Future enableTwoFactor({ + required String enrollmentId, + required String otpCode, + String? currentPassword, + }) async { + final int epoch = _epoch; + if (state.value is! SignedIn) return RestrictedStepResult.stale; + final ({String email, String password})? held = currentPassword == null + ? _secret.read() + : null; + final String? password = currentPassword ?? held?.password; + if (password == null) return RestrictedStepResult.needsPassword; + try { + await _twoFactor.activate( + enrollmentId: enrollmentId, + otpCode: otpCode, + currentPassword: password, + ); + } on DioException catch (error) { + if (epoch == _epoch && + ref.mounted && + held != null && + apiErrorCodeOf(error) == 'AUTH_005') { + _secret.clear(); + } + rethrow; + } + if (!ref.mounted || epoch != _epoch) return RestrictedStepResult.stale; + _secret.clear(); + return RestrictedStepResult.done; + } + + /// 제한 단계를 마친 뒤 — 새 토큰을 받아 다음 제한(있으면 그 단계) · 제한 없음(메일)으로 옮긴다 + /// (계약 6절 "제한된 세션" — 푸는 요청이 성공하면 곧바로 갱신. 갱신도 한 번에 하나인 [TokenRefresher] 를 지난다). + /// + /// - 갱신이 일시 장애면 세션 확인(me — 세션 표의 지금 값)으로 정한다(웹과 같다). 둘 다 받지 못하면 갱신의 실패를 `DioException` 으로 던진다 — + /// 화면은 그 단계에 머물며 위쪽 상자에 사전 문구와 [다시 시도](이것을 다시 부른다). + /// - 갱신이 세션을 끝냈거나(401 · 유예 응답) 그사이 로그아웃했으면 [RestrictedStepResult.stale] — 세션 끝 흐름이 로그인 화면으로 옮긴다. + /// - 방금의 갱신 실패를 다시 쓰지 않는다 — [다시 시도]는 사용자가 누른 것이라 새로 묻는다(되살리기의 [retry] 와 같다). + Future finishRestrictedStep() async { + final int epoch = _epoch; + final SessionState? current = state.value; + if (current is! SignedIn) return RestrictedStepResult.stale; + bool isCurrent() => + epoch == _epoch && ref.mounted && state.value is SignedIn; + + _refresher.forgetFailure(); + final RefreshOutcome outcome = await _repository.refresh(); + if (!isCurrent()) return RestrictedStepResult.stale; + ApiException? refreshError; + switch (outcome) { + case RefreshSucceeded(:final Map? body): + final AuthSessionInfo? info = AuthSessionInfo.tryParse(body); + if (info != null && info.email == current.info.email) { + _setSignedIn(info); + return RestrictedStepResult.done; + } + // 세션 칸이 없다(그사이 다른 요청이 먼저 받아 두었다) — 아래 세션 확인으로 정한다 + case RefreshRejected(): + case RefreshDiscarded(): + return RestrictedStepResult.stale; + case RefreshFailed(:final ApiException error): + refreshError = error; + } + + final AuthSessionInfo info; + try { + info = await _repository.readSessionDirect(); + } on DioException { + if (!isCurrent()) return RestrictedStepResult.stale; + if (refreshError == null) rethrow; + throw DioException( + requestOptions: RequestOptions(path: ApiPaths.appRefresh), + error: refreshError, + ); + } + if (!isCurrent()) return RestrictedStepResult.stale; + _setSignedIn(info); + return RestrictedStepResult.done; + } + /// [다른 계정으로 로그인] — 리프레시 토큰이 있으면(로그인한 세션 · 되살리는 중 · 되살리기 실패) 로그아웃하고, /// 코드 단계면 쥔 비밀번호만 지운다. 진행 중인 로그인 · 코드의 늦은 결과는 버린다. Future switchAccount() async { @@ -195,6 +362,7 @@ class SessionNotifier extends AsyncNotifier { /// 답을 기다리는 요청에는 stale 로 답한다(다른 계정의 새 로그인 오류가 앞 세션의 me 뒤에서 기다리지 않게) void _advanceEpoch() { _epoch++; + _passedCodeStep = false; _rechecking = null; final List waiting = List.of( _pendingRechecks, @@ -277,6 +445,7 @@ class SessionNotifier extends AsyncNotifier { required int epoch, required String email, required String password, + bool viaCode = false, }) async { if (!ref.mounted || epoch != _epoch) { // 그사이 로그아웃 · [다른 계정으로 로그인] · 세션 끝 — 늦게 온 결과는 쓰지 않는다 @@ -311,15 +480,17 @@ class SessionNotifier extends AsyncNotifier { return; } final AuthSessionInfo info = result.session!; - if (info.restriction != null) { - // 제한 단계(새 비밀번호 · 2단계 켜기)가 지금 비밀번호로 쓴다 — 서버가 방금 확인했으니 10분을 새로 센다 + _passedCodeStep = viaCode; + if (_usesHeldPassword(info.restriction)) { + // 제한 단계(새 비밀번호 · 2단계 켜기)가 지금 비밀번호로 쓴다 — 서버가 방금 확인했으니 10분을 새로 센다. + // 모르는 제한은 쓸 단계가 없어 쥐지 않는다(웹 LoginPage 의 onSignedIn 과 같다) _secret.hold(email, password); } _setSignedIn(info); } } - /// 로그인한 상태로 둔다 — 제한이 풀렸으면 쥔 비밀번호를 지우고, 새로 쓸 수 있게 됐으면 푸시 기기를 등록한다 + /// 로그인한 상태로 둔다 — 제한이 풀렸거나 모르는 제한이 됐으면 쥔 비밀번호를 지우고, 새로 쓸 수 있게 됐으면 푸시 기기를 등록한다 void _setSignedIn(AuthSessionInfo info) { final SessionState? previous = state.value; final bool wasReady = @@ -329,12 +500,18 @@ class SessionNotifier extends AsyncNotifier { } void _afterSignedIn(AuthSessionInfo info, {required bool wasReady}) { + // 제한이 풀렸다 · 모르는 제한이 됐다(갱신 · 다시 맞추기) — 다시 쓸 일이 없는 비밀번호를 쥐고 있지 않는다 + // (계약 1절 "앱 로그인 › 앱이 할 일" · 웹 LoginPage 의 onSignedIn) + if (!_usesHeldPassword(info.restriction)) _secret.clear(); if (info.restriction != null) return; - // 제한이 풀렸다 — 다시 쓸 일이 없는 비밀번호를 쥐고 있지 않는다(계약 1절 "앱 로그인 › 앱이 할 일") - _secret.clear(); if (!wasReady) unawaited(_registerPush()); } + /// 로그인 때 넣은 비밀번호를 지금 비밀번호로 쓰는 제한 단계인가 — 새 비밀번호 · 2단계 켜기만 + static bool _usesHeldPassword(SessionRestriction? restriction) => + restriction == SessionRestriction.passwordChange || + restriction == SessionRestriction.twoFactorSetup; + Future _registerPush() async { try { await _push.register(); diff --git a/flutter/lib/features/auth/presentation/providers/session_state.dart b/flutter/lib/features/auth/presentation/providers/session_state.dart index 050244e..b9f07e1 100644 --- a/flutter/lib/features/auth/presentation/providers/session_state.dart +++ b/flutter/lib/features/auth/presentation/providers/session_state.dart @@ -9,17 +9,20 @@ sealed class SessionState { /// 로그아웃 상태 — 로그인 화면 final class SignedOut extends SessionState { - const SignedOut({this.reason = SignOutReason.none}); + const SignedOut({this.reason = SignOutReason.none, this.email}); - /// 로그인 화면이 위쪽 상자에 보일 까닭 (화면 작업 때 쓴다) + /// 로그인 화면이 위쪽 상자에 보일 까닭 final SignOutReason reason; + /// 로그인 화면의 주소 칸에 남길 주소 — 코드 단계에서 첫 단계로 돌아왔을 때(시간 지남 · 비밀번호 틀림). 비밀이 아니다 + final String? email; + @override bool operator ==(Object other) => - other is SignedOut && other.reason == reason; + other is SignedOut && other.reason == reason && other.email == email; @override - int get hashCode => reason.hashCode; + int get hashCode => Object.hash(reason, email); } /// 로그아웃한 까닭 @@ -33,6 +36,9 @@ enum SignOutReason { /// 코드 단계에 10분 넘게 머물렀다 (계약 5절 — "코드를 넣는 시간이 지났습니다. 다시 로그인해 주세요.") codeTimedOut, + /// 코드 단계에서 보낸 비밀번호를 서버가 거절했다(`AUTH_004` — 그사이 바뀜). 위쪽 상자에 사전 문구 · 칸은 빨갛게(계약 5절) + passwordRejected, + /// [다른 계정으로 로그인] switchAccount, } diff --git a/flutter/lib/features/auth/presentation/widgets/login_fields.dart b/flutter/lib/features/auth/presentation/widgets/login_fields.dart new file mode 100644 index 0000000..c525bd1 --- /dev/null +++ b/flutter/lib/features/auth/presentation/widgets/login_fields.dart @@ -0,0 +1,395 @@ +import 'package:arasystem_app/core/constants/login_texts.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/core/utils/otp_code.dart'; +import 'package:arasystem_app/shared/widgets/keep_all_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter/services.dart'; + +/// 로그인 틀의 글자 칸 — 칸 이름은 칸 위, 안내 · 오류는 칸 아래(앱 보드 `App1Login` · 웹 PasswordField · OtpCodeField 와 같은 모양). +/// +/// - 안내와 오류는 따로 그린다 — 오류가 나도 안내는 남는다(예: 코드 칸의 "다음 코드가 뜨면 넣어 주세요"). [hintIsError] 면 안내 글이 빨갛다. +/// - 화면 낭독기: 보이는 이름은 빼고 같은 이름을 칸에 붙인다(이름을 두 번 읽지 않는다). 칸의 설명은 위쪽 상자([describedBy]) → 안내 → 오류 +/// 차례로 칸에 이어 둔다 — 칸에 다시 와도 함께 읽힌다(웹 `aria-describedby` 와 같은 결 · Android 는 칸의 hint 글). +/// 칸 아래 오류는 나타날 때도 읽어 준다(live region). 오류가 있으면 칸이 빨갛고 "잘못됨"(validationResult)이다. +/// - 비밀이 드는 칸(비밀번호 · 코드)의 글은 이 칸의 컨트롤러(화면 메모리)에만 있다 — 화면이 떠날 때 지운다. +class LoginTextField extends StatelessWidget { + const LoginTextField({ + required this.label, + required this.controller, + this.focusNode, + this.placeholder, + this.description, + this.hint, + this.hintIsError = false, + this.error, + this.invalid = false, + this.describedBy, + this.obscureText = false, + this.keyboardType, + this.enableSuggestions = false, + this.textInputAction, + this.onSubmitted, + this.onChanged, + this.autofillHints, + this.suffix, + this.readOnly = false, + this.inputFormatters, + this.style, + this.contentPadding, + this.labelStyle, + super.key, + }); + + final String label; + final TextEditingController controller; + final FocusNode? focusNode; + + /// 칸 안의 흐린 글 + final String? placeholder; + + /// 이름과 칸 사이의 설명 (2단계 켜기의 ② — 보드) + final String? description; + + /// 칸 아래 안내 + final String? hint; + final bool hintIsError; + + /// 칸 아래 오류 — 사전 문구 + final String? error; + + /// 칸 아래 글 없이 빨간 테두리만 — 오류 글은 위쪽 상자에 있다(첫 단계의 `AUTH_004` — 보드) + final bool invalid; + + /// 칸을 설명하는 위쪽 상자의 글 — 칸에 이어 함께 읽힌다(보드의 `aria-describedby="err-login-alert"`) + final String? describedBy; + final bool obscureText; + final TextInputType? keyboardType; + + /// 자판의 추천 · 학습 — 기본은 끈다. 메일 주소 칸만 켠다: 끄면 Flutter Android 가 VISIBLE_PASSWORD 변형을 더해 + /// EMAIL_ADDRESS 와 겹쳐(FILTER 변형이 된다) 자판에서 @ 가 빠진다(엔진 TextInputPlugin `inputTypeFromTextInputType`) + final bool enableSuggestions; + final TextInputAction? textInputAction; + final ValueChanged? onSubmitted; + final ValueChanged? onChanged; + final Iterable? autofillHints; + + /// 칸 끝의 단추([보기]) + final Widget? suffix; + + /// 보내는 동안 읽기만 + final bool readOnly; + final List? inputFormatters; + + /// 칸 글자 — 기본은 테마(bodyLarge 16) + final TextStyle? style; + final EdgeInsetsGeometry? contentPadding; + + /// 이름 글자 — 기본은 칸 이름(13 · 굵게) + final TextStyle? labelStyle; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + final InputDecorationThemeData decorations = InputDecorationTheme.of( + context, + ); + final String? hintText = hint; + final String? errorText = error; + final bool red = + invalid || errorText != null || (hintIsError && hintText != null); + final String? describe = description; + final String semanticsHint = [ + describedBy, + hintText, + errorText, + ].whereType().join('\n'); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s6, + children: [ + ExcludeSemantics( + child: KeepAllText( + label, + style: + labelStyle ?? + theme.textTheme.labelMedium?.copyWith( + fontWeight: AppTypography.semiBold, + color: extra.textStrong, + ), + ), + ), + if (describe != null) + KeepAllText( + describe, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + Semantics( + label: label, + hint: semanticsHint.isEmpty ? null : semanticsHint, + validationResult: red + ? SemanticsValidationResult.invalid + : SemanticsValidationResult.none, + child: TextField( + controller: controller, + focusNode: focusNode, + obscureText: obscureText, + keyboardType: keyboardType, + textInputAction: textInputAction, + onSubmitted: onSubmitted, + onChanged: onChanged, + autofillHints: autofillHints, + readOnly: readOnly, + inputFormatters: inputFormatters, + style: style, + autocorrect: false, + enableSuggestions: enableSuggestions, + // 안내 · 오류는 입력 장식에 두지 않고 아래에 직접 그린다 — 보드처럼 칸의 왼쪽 끝에 맞추고 낱말 가운데서 줄을 바꾸지 않게 + decoration: InputDecoration( + hintText: placeholder, + hintStyle: style?.copyWith(color: theme.colorScheme.outline), + enabledBorder: red ? decorations.errorBorder : null, + focusedBorder: red ? decorations.focusedErrorBorder : null, + suffixIcon: suffix, + contentPadding: contentPadding, + ), + ), + ), + if (hintText != null) + LoginFieldMessage( + text: hintText, + isError: hintIsError, + announce: false, + ), + if (errorText != null) + LoginFieldMessage(text: errorText, isError: true), + ], + ); + } +} + +/// 칸 아래 글 — 안내(회색 · 규칙에 어긋나면 빨강) 또는 오류(빨강). +/// [announce] 면 나타날 때 화면 낭독기가 읽는다(live region — 서버 거절 · 보내기 전 검사의 오류 글) +class LoginFieldMessage extends StatelessWidget { + const LoginFieldMessage({ + required this.text, + required this.isError, + bool? announce, + super.key, + }) : announce = announce ?? isError; + + final String text; + final bool isError; + final bool announce; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final Widget message = KeepAllText( + text, + style: theme.textTheme.bodySmall?.copyWith( + color: isError + ? theme.colorScheme.error + : theme.extension()!.textMuted, + ), + ); + if (!announce) return message; + return Semantics(container: true, liveRegion: true, child: message); + } +} + +/// 비밀번호 칸 — 끝에 [보기](48). 보드: 첫 단계 비밀번호 · 새 비밀번호 · 임시 비밀번호 · 지금 비밀번호 +class LoginPasswordField extends StatelessWidget { + const LoginPasswordField({ + required this.label, + required this.controller, + required this.shown, + required this.onToggle, + required this.toggleLabel, + this.focusNode, + this.placeholder, + this.hint, + this.hintIsError = false, + this.error, + this.invalid = false, + this.describedBy, + this.textInputAction, + this.onSubmitted, + this.onChanged, + this.autofillHints, + this.readOnly = false, + super.key, + }); + + final String label; + final TextEditingController controller; + + /// 글자를 보이는 중 + final bool shown; + final VoidCallback onToggle; + + /// [보기]의 화면 낭독기 이름 — "비밀번호 보기" + final String toggleLabel; + final FocusNode? focusNode; + final String? placeholder; + final String? hint; + final bool hintIsError; + final String? error; + final bool invalid; + final String? describedBy; + final TextInputAction? textInputAction; + final ValueChanged? onSubmitted; + final ValueChanged? onChanged; + final Iterable? autofillHints; + final bool readOnly; + + @override + Widget build(BuildContext context) { + return LoginTextField( + label: label, + controller: controller, + focusNode: focusNode, + placeholder: placeholder, + hint: hint, + hintIsError: hintIsError, + error: error, + invalid: invalid, + describedBy: describedBy, + obscureText: !shown, + // 보이는 동안에도 자판의 추천 · 학습을 끈다 + keyboardType: TextInputType.visiblePassword, + textInputAction: textInputAction, + onSubmitted: onSubmitted, + onChanged: onChanged, + autofillHints: autofillHints, + readOnly: readOnly, + suffix: PasswordVisibilityToggle( + shown: shown, + label: toggleLabel, + onToggle: onToggle, + ), + ); + } +} + +/// [보기] — 눌러 둔 상태(toggled)를 화면 낭독기가 읽는다(웹의 aria-pressed). 이름은 그대로 둔다 +class PasswordVisibilityToggle extends StatelessWidget { + const PasswordVisibilityToggle({ + required this.shown, + required this.label, + required this.onToggle, + super.key, + }); + + final bool shown; + final String label; + final VoidCallback onToggle; + + @override + Widget build(BuildContext context) { + return MergeSemantics( + child: Semantics( + label: label, + toggled: shown, + child: IconButton( + onPressed: onToggle, + color: Theme.of(context).colorScheme.onSurfaceVariant, + icon: Icon( + shown ? Icons.visibility_off_outlined : Icons.visibility_outlined, + size: AppSize.iconMedium, + ), + ), + ), + ); + } +} + +/// 인증 앱 코드 칸 — 숫자 6자리 · 높이 56 · 큰 글자 사이를 벌린다(보드). 붙여 넣은 글의 띄어쓰기 · 하이픈은 지운다. +/// +/// 숫자 자판 · 자동 채우기 이름 one-time-code(웹 `inputmode="numeric"` · `autocomplete="one-time-code"`). +class LoginCodeField extends StatelessWidget { + const LoginCodeField({ + required this.label, + required this.controller, + this.focusNode, + this.description, + this.hint, + this.error, + this.textInputAction, + this.onSubmitted, + this.onChanged, + this.readOnly = false, + this.labelStyle, + super.key, + }); + + final String label; + final TextEditingController controller; + final FocusNode? focusNode; + final String? description; + final String? hint; + final String? error; + final TextInputAction? textInputAction; + final ValueChanged? onSubmitted; + final ValueChanged? onChanged; + final bool readOnly; + final TextStyle? labelStyle; + + @override + Widget build(BuildContext context) { + final TextTheme text = Theme.of(context).textTheme; + final double fontSize = + text.headlineSmall?.fontSize ?? AppTypography.headline; + final TextStyle style = TextStyle( + fontSize: fontSize, + fontWeight: AppTypography.regular, + height: AppTypography.lineHeightBase, + letterSpacing: fontSize * AppTypography.letterSpacingCodeEm, + fontFeatures: const [FontFeature.tabularFigures()], + ); + final double lineHeight = fontSize * AppTypography.lineHeightBase; + return LoginTextField( + label: label, + controller: controller, + focusNode: focusNode, + placeholder: OtpTexts.placeholder, + description: description, + hint: hint, + error: error, + keyboardType: TextInputType.number, + textInputAction: textInputAction, + onSubmitted: onSubmitted, + onChanged: onChanged, + autofillHints: const [AutofillHints.oneTimeCode], + readOnly: readOnly, + inputFormatters: [OtpInputFormatter()], + style: style, + labelStyle: labelStyle, + contentPadding: EdgeInsets.symmetric( + horizontal: AppSpacing.s16, + vertical: (AppSize.codeFieldHeight - lineHeight) / 2, + ), + ); + } +} + +/// 코드 칸의 글을 숫자 6자리로 다듬는다 — 붙여 넣은 "123 456" · "123-456" · 전각 숫자도 받는다(`normalizeOtpInput`) +class OtpInputFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + final String digits = normalizeOtpInput(newValue.text); + if (digits == newValue.text) return newValue; + return TextEditingValue( + text: digits, + selection: TextSelection.collapsed(offset: digits.length), + ); + } +} diff --git a/flutter/lib/features/auth/presentation/widgets/login_frame.dart b/flutter/lib/features/auth/presentation/widgets/login_frame.dart new file mode 100644 index 0000000..d7a7ce7 --- /dev/null +++ b/flutter/lib/features/auth/presentation/widgets/login_frame.dart @@ -0,0 +1,376 @@ +import 'package:arasystem_app/core/constants/app_assets.dart'; +import 'package:arasystem_app/core/constants/app_constants.dart'; +import 'package:arasystem_app/core/constants/login_texts.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/shared/widgets/keep_all_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// 로그인 틀의 크기 — 앱 보드 `App1Login` (2026-11-05 확정). 여러 화면이 함께 쓰는 크기는 `AppSize` · `AppSpacing` 에 있다 +abstract final class LoginLayout { + /// 첫 단계 브랜드 띠의 전체 로고 폭 (높이는 그림 비율대로) + static const double logoWidth = 150; + + /// 다음 단계 짙은 띠의 ARA 마크 폭 + static const double markWidth = 48; + + /// 2단계 켜기의 차례 숫자(1 · 2 · 3) 칸 폭 + static const double stepNumberWidth = 18; + + /// 보내는 동안 단추를 흐리게 — 웹 로그인 단추와 같다 + static const double busyOpacity = 0.7; + + /// 보내는 동안 [다른 계정으로 로그인]을 흐리게 — 웹 LoginAccountRow 와 같다 + static const double busyLinkOpacity = 0.6; +} + +/// 로그인 틀의 상태 표시줄 — 위가 어두운 브랜드 띠라 밝은 아이콘. 탐색 막대는 건드리지 않는다(AppBar 와 같다) +const SystemUiOverlayStyle kLoginOverlayStyle = SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + statusBarBrightness: Brightness.dark, + systemStatusBarContrastEnforced: false, +); + +/// 로그인 틀의 칸 · 단추 모양 — 보드는 모서리 10 · 글자 칸 52 · 단추 48 이다(앱 전체 테마의 8 과 다르다) +ThemeData loginThemeOf(ThemeData base) { + final ColorScheme scheme = base.colorScheme; + final TextTheme text = base.textTheme; + final BorderRadius radius = BorderRadius.circular(AppRadius.lg); + final RoundedRectangleBorder shape = RoundedRectangleBorder( + borderRadius: radius, + ); + OutlineInputBorder border(Color color, double width) => OutlineInputBorder( + borderRadius: radius, + borderSide: BorderSide(color: color, width: width), + ); + final TextStyle input = text.bodyLarge ?? const TextStyle(); + final double lineHeight = + (input.fontSize ?? AppTypography.bodyLarge) * + (input.height ?? AppTypography.lineHeightBase); + const Size buttonSize = Size.fromHeight(AppSize.touchMin); + + return base.copyWith( + inputDecorationTheme: InputDecorationThemeData( + filled: true, + fillColor: scheme.surface, + isDense: true, + contentPadding: EdgeInsets.symmetric( + horizontal: AppSpacing.s14, + vertical: (AppSize.fieldHeight - lineHeight) / 2, + ), + hintStyle: input.copyWith(color: scheme.outline), + // 칸 아래 안내 · 오류는 칸이 직접 그린다(LoginFieldMessage) — 오류 테두리만 여기서 정한다 + border: border(scheme.outline, AppSize.borderWidth), + enabledBorder: border(scheme.outline, AppSize.borderWidth), + focusedBorder: border(scheme.primary, AppSize.focusRingWidth), + errorBorder: border(scheme.error, AppSize.focusRingWidth), + focusedErrorBorder: border(scheme.error, AppSize.focusRingWidth), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + minimumSize: buttonSize, + shape: shape, + textStyle: text.labelLarge, + // 보내는 동안(누를 수 없음)에도 같은 색을 흐리게 — 회색 단추로 바뀌지 않게 + disabledBackgroundColor: scheme.primary.withValues( + alpha: LoginLayout.busyOpacity, + ), + disabledForegroundColor: scheme.onPrimary, + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + minimumSize: buttonSize, + shape: shape, + side: BorderSide(color: scheme.primary), + foregroundColor: scheme.onPrimaryContainer, + textStyle: text.labelLarge, + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + minimumSize: const Size(AppSize.touchMin, AppSize.touchMin), + shape: shape, + textStyle: text.labelLarge, + ), + ), + ); +} + +/// 첫 단계의 틀 — 큰 브랜드 띠(로고 · 소개)가 내용과 함께 흐른다 +class LoginBrandFrame extends StatelessWidget { + const LoginBrandFrame({required this.children, super.key}); + + /// 브랜드 띠 아래의 내용 — 사이 20 + final List children; + + @override + Widget build(BuildContext context) { + return _LoginScaffold( + top: const _StatusBarStrip(), + body: _LoginScroll( + children: [ + const _BrandBand(), + _LoginColumn(topPadding: AppSpacing.s28, children: children), + ], + ), + ); + } +} + +/// 다음 단계들의 틀 — 짙은 띠(56 — ARA 마크 · [다른 계정으로 로그인])는 위에 남고 내용만 흐른다 +class LoginStepFrame extends StatelessWidget { + const LoginStepFrame({ + required this.onOtherAccount, + required this.children, + this.otherAccountEnabled = true, + this.topPadding = AppSpacing.s28, + super.key, + }); + + /// [다른 계정으로 로그인] + final VoidCallback onOtherAccount; + + /// [다른 계정으로 로그인]을 누를 수 있는가 — 제한 단계는 보내는 동안 · 넘기는 동안 막는다(웹 LoginAccountRow 의 busy 와 같다). + /// 서버가 이미 비밀번호를 바꿨거나 2단계를 켰을 수 있는데 그 결과를 버리면, 직원이 옛 비밀번호로 다시 들어가다 막힌다 + final bool otherAccountEnabled; + + /// 띠 아래의 내용 — 사이 20 + final List children; + + /// 띠와 첫 내용 사이 (보드: 코드 · 새 비밀번호 28, 2단계 켜기 24) + final double topPadding; + + @override + Widget build(BuildContext context) { + return _LoginScaffold( + top: _StepBand( + onOtherAccount: otherAccountEnabled ? onOtherAccount : null, + ), + body: _LoginScroll( + children: [ + _LoginColumn(topPadding: topPadding, children: children), + ], + ), + ); + } +} + +class _LoginScaffold extends StatelessWidget { + const _LoginScaffold({required this.top, required this.body}); + + final Widget top; + final Widget body; + + @override + Widget build(BuildContext context) { + final ThemeData theme = loginThemeOf(Theme.of(context)); + return AnnotatedRegion( + value: kLoginOverlayStyle, + child: Theme( + data: theme, + child: Scaffold( + backgroundColor: theme.colorScheme.surface, + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + top, + Expanded(child: body), + ], + ), + ), + ), + ); + } +} + +/// 상태 표시줄 뒤 — 브랜드 바탕(내용이 흘러 올라가도 밝은 아이콘이 보이게) +class _StatusBarStrip extends StatelessWidget { + const _StatusBarStrip(); + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: Theme.of(context).colorScheme.secondary, + child: SizedBox(height: MediaQuery.paddingOf(context).top), + ); + } +} + +class _LoginScroll extends StatelessWidget { + const _LoginScroll({required this.children}); + + final List children; + + @override + Widget build(BuildContext context) { + return SafeArea( + top: false, + child: SingleChildScrollView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ), + ), + ); + } +} + +/// 내용 열 — 휴대폰은 화면 폭을 다 쓰고, 넓은 화면은 [AppSize.formMaxWidth] 로 가운데 +class _LoginColumn extends StatelessWidget { + const _LoginColumn({required this.topPadding, required this.children}); + + final double topPadding; + final List children; + + @override + Widget build(BuildContext context) { + return _FormWidth( + child: Padding( + padding: EdgeInsets.fromLTRB( + AppSpacing.s20, + topPadding, + AppSpacing.s20, + AppSpacing.s32, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s20, + children: children, + ), + ), + ); + } +} + +class _FormWidth extends StatelessWidget { + const _FormWidth({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: AppSize.formMaxWidth), + child: child, + ), + ); + } +} + +/// 첫 단계의 브랜드 띠 — 흰 로고 · 큰 글 · 소개 글 +class _BrandBand extends StatelessWidget { + const _BrandBand(); + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return ColoredBox( + color: theme.colorScheme.secondary, + child: _FormWidth( + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s24, + AppSpacing.s36, + AppSpacing.s24, + AppSpacing.s28, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: AppSpacing.s18, + children: [ + Image.asset( + AppAssets.logoFull, + width: LoginLayout.logoWidth, + semanticLabel: kAppName, + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: AppSpacing.s6, + children: [ + KeepAllText( + LoginTexts.brandHeadline, + // 흰 글 — 브랜드 바탕 위 큰 글(웹 --color-on-brand) + style: theme.textTheme.headlineSmall?.copyWith( + color: theme.colorScheme.onPrimary, + ), + ), + KeepAllText( + LoginTexts.brandLead, + style: theme.textTheme.bodyMedium?.copyWith( + color: extra.onBrandMuted, + height: AppTypography.lineHeightRelaxed, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +/// 다음 단계의 짙은 띠 — ARA 마크와 [다른 계정으로 로그인] (웹처럼 주소 줄 안에 두면 390 폭에 들지 않는다) +class _StepBand extends StatelessWidget { + const _StepBand({required this.onOtherAccount}); + + /// null 이면 누를 수 없다(흐리게) + final VoidCallback? onOtherAccount; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return ColoredBox( + color: theme.colorScheme.secondary, + child: Padding( + padding: EdgeInsets.only(top: MediaQuery.paddingOf(context).top), + child: SafeArea( + top: false, + bottom: false, + child: SizedBox( + height: AppSize.topBandHeight, + child: Padding( + padding: const EdgeInsets.only( + left: AppSpacing.s20, + right: AppSpacing.s6, + ), + child: Row( + children: [ + Image.asset( + AppAssets.logoMark, + width: LoginLayout.markWidth, + semanticLabel: kAppName, + ), + const Spacer(), + TextButton( + onPressed: onOtherAccount, + style: TextButton.styleFrom( + foregroundColor: extra.onBrandLink, + disabledForegroundColor: extra.onBrandLink.withValues( + alpha: LoginLayout.busyLinkOpacity, + ), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.s12, + ), + ), + child: const Text(LoginBandTexts.otherAccount), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/flutter/lib/features/auth/presentation/widgets/login_parts.dart b/flutter/lib/features/auth/presentation/widgets/login_parts.dart new file mode 100644 index 0000000..a5c9d26 --- /dev/null +++ b/flutter/lib/features/auth/presentation/widgets/login_parts.dart @@ -0,0 +1,317 @@ +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/core/utils/korean_text.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_frame.dart'; +import 'package:arasystem_app/shared/widgets/keep_all_text.dart'; +import 'package:flutter/material.dart'; + +/// 단계의 머리 — 제목 · 설명 (다음 단계는 로그인한 주소 줄도). +/// +/// 제목은 화면 낭독기의 머리글이자 **화면 이름**이다(`namesRoute`) — 단계가 바뀌어 화면이 바뀌면 낭독기가 이 글을 읽어 알린다. +class LoginHeading extends StatelessWidget { + const LoginHeading({ + required this.title, + required this.subtitle, + this.email, + super.key, + }); + + final String title; + final String subtitle; + + /// 로그인한(비밀번호를 확인한) 주소 — 다음 단계만 + final String? email; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final String? address = email; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: address == null ? AppSpacing.s4 : AppSpacing.s10, + children: [ + Semantics( + header: true, + namesRoute: true, + child: KeepAllText(title, style: theme.textTheme.headlineSmall), + ), + KeepAllText( + subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + if (address != null) LoginAccountChip(email: address), + ], + ); + } +} + +/// 로그인한 주소 — 사람 아이콘 + 주소(넘치면 줄임)의 알약(36) +class LoginAccountChip extends StatelessWidget { + const LoginAccountChip({required this.email, super.key}); + + final String email; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return Align( + alignment: AlignmentDirectional.centerStart, + child: Container( + constraints: const BoxConstraints(minHeight: AppSize.chipHeight), + padding: const EdgeInsetsDirectional.only( + start: AppSpacing.s10, + end: AppSpacing.s14, + ), + decoration: ShapeDecoration( + color: theme.colorScheme.surfaceContainerHigh, + shape: const StadiumBorder(), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: AppSpacing.s8, + children: [ + Icon( + Icons.person_outline, + size: AppSize.iconSmall, + color: extra.textMuted, + ), + Flexible( + child: Text( + email, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: extra.textStrong, + ), + ), + ), + ], + ), + ), + ); + } +} + +/// 위쪽 빨간 상자 — 사전 문구(`AUTH_004` 등)나 단계 안내. 나타나면 화면 낭독기가 읽는다(live region — 웹의 role="alert"). +/// [actionLabel] 이 있으면 글 아래에 단추([다시 시도]) — [onAction] 이 null 이면(다시 하는 중) 자리는 두고 누를 수 없다. +class LoginAlertBox extends StatelessWidget { + const LoginAlertBox({ + required this.message, + this.actionLabel, + this.onAction, + this.namesRoute = false, + super.key, + }); + + final String message; + final String? actionLabel; + final VoidCallback? onAction; + + /// 이 상자가 화면 이름이다(제목 없는 화면 — 모르는 제한) + final bool namesRoute; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + final String? label = actionLabel; + return Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.s14, + vertical: AppSpacing.s12, + ), + decoration: BoxDecoration( + color: scheme.errorContainer, + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s8, + children: [ + Semantics( + container: true, + liveRegion: true, + namesRoute: namesRoute, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: AppSpacing.s8, + children: [ + Icon( + Icons.error_outline, + size: AppSize.iconSmall, + color: scheme.error, + ), + Expanded( + child: KeepAllText( + message, + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.error, + ), + ), + ), + ], + ), + ), + if (label != null) + Align( + alignment: AlignmentDirectional.centerEnd, + child: OutlinedButton( + onPressed: onAction, + style: OutlinedButton.styleFrom( + minimumSize: const Size(AppSize.touchMin, AppSize.touchMin), + side: BorderSide(color: scheme.outlineVariant), + backgroundColor: scheme.surface, + foregroundColor: theme + .extension()! + .textStrong, + ), + child: Text(label), + ), + ), + ], + ), + ); + } +} + +/// 단계의 큰 단추 — 보내는 동안은 [busyLabel] 로 바꾸고 누를 수 없다(두 번 보내면 서버가 `REQ_001` — 계약 1절) +class LoginSubmitButton extends StatelessWidget { + const LoginSubmitButton({ + required this.label, + required this.busyLabel, + required this.busy, + required this.onPressed, + this.enabled = true, + this.inForm = false, + super.key, + }); + + final String label; + final String busyLabel; + final bool busy; + final VoidCallback onPressed; + + /// 보내는 중이 아니어도 누를 수 없음(키를 받는 중 · 이미 마쳤는데 넘기지 못함) + final bool enabled; + + /// 칸 묶음(사이 16) 안의 단추 — 위를 4 더 띄워 칸과 20 을 둔다(보드) + final bool inForm; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only(top: inForm ? AppSpacing.s4 : 0), + child: FilledButton( + onPressed: busy || !enabled ? null : onPressed, + child: Text(busy ? busyLabel : label), + ), + ); + } +} + +/// 문의 줄 — 연락처가 있으면 물음 다음 줄에 연락처(진하게), 없거나 받지 못했으면 지금 글(웹과 같다). 글자로만 보인다 +class LoginSupportLine extends StatelessWidget { + const LoginSupportLine({ + required this.question, + required this.fallback, + required this.contact, + super.key, + }); + + final String question; + final String fallback; + + /// 연락처 한 줄 — 없으면 null + final String? contact; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + final TextStyle? style = theme.textTheme.bodySmall?.copyWith( + color: extra.textMuted, + ); + final String? line = contact; + if (line == null) return KeepAllText(fallback, style: style); + return Text.rich( + TextSpan( + children: [ + TextSpan(text: '${keepAllWordBreak(question)}\n'), + TextSpan( + text: keepAllWordBreak(line), + style: TextStyle( + color: extra.textStrong, + fontWeight: AppTypography.medium, + ), + ), + ], + ), + style: style, + semanticsLabel: '$question\n$line', + ); + } +} + +/// 맨 아래 안내 — 위쪽 옅은 선 · 작은 회색 글(메일 프로그램 연결 안내) +class LoginNote extends StatelessWidget { + const LoginNote({required this.text, super.key}); + + final String text; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return DecoratedBox( + decoration: BoxDecoration( + border: Border(top: BorderSide(color: extra.borderSubtle)), + ), + child: Padding( + padding: const EdgeInsets.only(top: AppSpacing.s16), + child: KeepAllText( + text, + style: theme.textTheme.bodySmall?.copyWith( + color: extra.textMuted, + height: AppTypography.lineHeightRelaxed, + ), + ), + ), + ); + } +} + +/// 2단계 켜기의 차례 하나 — 청록 굵은 숫자(동그라미 없음) + 내용 (숫자는 화면 낭독기가 읽지 않는다 — 보드의 aria-hidden) +class LoginStepItem extends StatelessWidget { + const LoginStepItem({required this.number, required this.child, super.key}); + + final int number; + final Widget child; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: AppSpacing.s12, + children: [ + SizedBox( + width: LoginLayout.stepNumberWidth, + child: ExcludeSemantics( + child: Text( + '$number', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: AppTypography.bold, + color: theme.colorScheme.primary, + ), + ), + ), + ), + Expanded(child: child), + ], + ); + } +} diff --git a/flutter/lib/shared/widgets/keep_all_text.dart b/flutter/lib/shared/widgets/keep_all_text.dart new file mode 100644 index 0000000..e27ff8c --- /dev/null +++ b/flutter/lib/shared/widgets/keep_all_text.dart @@ -0,0 +1,17 @@ +import 'package:arasystem_app/core/utils/korean_text.dart'; +import 'package:flutter/widgets.dart'; + +/// 여러 줄 한국어 글 — 낱말 가운데서 줄이 바뀌지 않는다(웹 `word-break: keep-all` — `keepAllWordBreak`). +/// +/// 화면 낭독기에는 원래 글을 준다(보이지 않는 줄 나눔 표시가 읽히지 않게). +class KeepAllText extends StatelessWidget { + const KeepAllText(this.text, {this.style, super.key}); + + final String text; + final TextStyle? style; + + @override + Widget build(BuildContext context) { + return Text(keepAllWordBreak(text), style: style, semanticsLabel: text); + } +} diff --git a/flutter/linux/flutter/generated_plugin_registrant.cc b/flutter/linux/flutter/generated_plugin_registrant.cc index d0e7f79..38dd0bc 100644 --- a/flutter/linux/flutter/generated_plugin_registrant.cc +++ b/flutter/linux/flutter/generated_plugin_registrant.cc @@ -7,9 +7,13 @@ #include "generated_plugin_registrant.h" #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/flutter/linux/flutter/generated_plugins.cmake b/flutter/linux/flutter/generated_plugins.cmake index ce58916..7e7bd77 100644 --- a/flutter/linux/flutter/generated_plugins.cmake +++ b/flutter/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_linux + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/flutter/macos/Flutter/GeneratedPluginRegistrant.swift b/flutter/macos/Flutter/GeneratedPluginRegistrant.swift index 348142a..820b636 100644 --- a/flutter/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/flutter/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,10 +9,12 @@ import device_info_plus import flutter_secure_storage_darwin import package_info_plus import shared_preferences_foundation +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index b0c0ca1..861a6cc 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -762,6 +762,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: e8cc955c97e06df9879140a1fe301f3d493f01b7c62dec7f46834be31d7e9fda + url: "https://pub.dev" + source: hosted + version: "6.3.3" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c" + url: "https://pub.dev" + source: hosted + version: "6.3.30" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a" + url: "https://pub.dev" + source: hosted + version: "6.4.2" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" + url: "https://pub.dev" + source: hosted + version: "3.2.3" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201" + url: "https://pub.dev" + source: hosted + version: "3.2.6" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" + url: "https://pub.dev" + source: hosted + version: "3.1.6" vector_math: dependency: transitive description: diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 2eb9065..42ed8cb 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -35,6 +35,9 @@ dependencies: package_info_plus: ^10.2.2 # 첫 실행 표시 하나만 둔다 — 토큰은 여기에 두지 않는다 (core/storage/first_run_marker.dart) shared_preferences: ^2.5.6 + # 다른 앱 열기 — 로그인 2단계 켜기의 [인증 앱에 추가](otpauth 주소 — features/auth/data/authenticator_launcher.dart). + # 6.3.3 은 2026-10-02 배포(Flutter 팀). canLaunchUrl 은 쓰지 않는다 — debug 판 안드로이드가 주소째 로그에 남긴다 + url_launcher: ^6.3.3 dev_dependencies: flutter_test: @@ -49,6 +52,8 @@ flutter: - .env # 글꼴 라이선스 — 설정의 [오픈소스 라이선스]에 보인다 (core/theme/app_fonts.dart) - assets/licenses/pretendard-ofl.txt + # 흰 로고 둘 — 웹 frontend/src/assets/brand/ 와 같은 파일 (core/constants/app_assets.dart) + - assets/brand/ # Pretendard 1.3.9 (SIL OFL 1.1) — 웹(frontend)과 같은 글꼴. 정적 OTF 넷만 넣는다 fonts: diff --git a/flutter/test/app_test.dart b/flutter/test/app_test.dart index b2e8263..4a84e83 100644 --- a/flutter/test/app_test.dart +++ b/flutter/test/app_test.dart @@ -5,9 +5,11 @@ import 'package:arasystem_app/core/api/dio_provider.dart'; import 'package:arasystem_app/core/api/error_lexicon.dart'; import 'package:arasystem_app/core/api/error_notifier.dart'; import 'package:arasystem_app/core/constants/app_constants.dart'; +import 'package:arasystem_app/core/constants/login_texts.dart'; import 'package:arasystem_app/core/constants/screen_titles.dart'; import 'package:arasystem_app/core/router/app_router.dart'; import 'package:arasystem_app/core/utils/provider_retry.dart'; +import 'package:arasystem_app/features/auth/presentation/pages/login_page.dart'; import 'package:arasystem_app/features/mail/mail_routes.dart'; import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; import 'package:arasystem_app/features/settings/presentation/settings_texts.dart'; @@ -17,6 +19,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'support/fakes.dart'; +import 'support/login_fakes.dart'; import 'support/provider_harness.dart'; void main() { @@ -43,13 +46,12 @@ void main() { await pumpApp(tester); - expect(find.text(ScreenTitles.login), findsOneWidget); + expect(find.byType(LoginPage), findsOneWidget); + expect(findCopy(LoginTexts.subtitle), findsOneWidget); final MaterialApp app = tester.widget(find.byType(MaterialApp)); expect(app.title, kAppName); expect(app.locale, kAppLocale); - final BuildContext context = tester.element( - find.text(ScreenTitles.login), - ); + final BuildContext context = tester.element(find.byType(LoginPage)); expect(Localizations.localeOf(context).languageCode, 'ko'); }); @@ -72,7 +74,7 @@ void main() { await pumpApp(tester); - expect(find.text(ScreenTitles.passwordChange), findsOneWidget); + expect(findCopy(ScreenTitles.passwordChange), findsOneWidget); }); testWidgets('되살린 세션에 제한이 없으면 메일 화면이다', (WidgetTester tester) async { @@ -227,7 +229,7 @@ void main() { await tester.tap(page(SettingsTexts.signOut)); await tester.pumpAndSettle(); - expect(find.text(ScreenTitles.login), findsOneWidget); + expect(find.byType(LoginPage), findsOneWidget); expect(router.routerDelegate.currentConfiguration.uri.path, '/login'); expect(h.server.received('POST', ApiPaths.appLogout), hasLength(1)); }); diff --git a/flutter/test/core/api/auth_interceptor_test.dart b/flutter/test/core/api/auth_interceptor_test.dart index 2b46a69..9bb61a6 100644 --- a/flutter/test/core/api/auth_interceptor_test.dart +++ b/flutter/test/core/api/auth_interceptor_test.dart @@ -115,25 +115,29 @@ void main() { expect(refreshCount(), 2); }); - test('미리 갱신이 거절되면 원래 요청을 보내지 않고 AUTH_002 로 한 번 알린다', () async { - await api.coldStartWithRefreshToken('refresh-1'); - api.server.on( - 'POST', - ApiPaths.appRefresh, - (_) => FakeResponse.fail(401, 'AUTH_001'), - ); - protectedRoute(validToken: 'access-2'); + test( + '미리 갱신이 거절되면 원래 요청을 보내지 않고 AUTH_002 로 끝낸다 — 알림은 로그인 화면의 위쪽 상자가 한다', + () async { + await api.coldStartWithRefreshToken('refresh-1'); + api.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.fail(401, 'AUTH_001'), + ); + protectedRoute(validToken: 'access-2'); - final ApiException error = apiErrorOf( - await errorOf(api.dio.get(_mailState)), - ); + final ApiException error = apiErrorOf( + await errorOf(api.dio.get(_mailState)), + ); - expect(error.code, 'AUTH_002'); - expect(api.server.received('GET', _mailState), isEmpty); - expect(api.notifier.shownCodes, ['AUTH_002']); - expect(api.emitted.whereType(), hasLength(1)); - expect(api.store.hasSession, isFalse); - }); + expect(error.code, 'AUTH_002'); + expect(api.server.received('GET', _mailState), isEmpty); + // 세션 끝은 늘 로그인 화면으로 가고 그 화면의 위쪽 상자가 같은 사전 문구로 알린다 — 스낵바는 띄우지 않는다 + expect(api.notifier.shownCodes, isEmpty); + expect(api.emitted.whereType(), hasLength(1)); + expect(api.store.hasSession, isFalse); + }, + ); test('미리 갱신이 장애면 원래 요청을 보내지 않고 그 코드로 알리며 세션은 둔다', () async { await api.coldStartWithRefreshToken('refresh-1'); @@ -262,7 +266,7 @@ void main() { expect(second.files.single.value.filename, 'a.txt'); }); - test('갱신이 401 이면 토큰을 지우고 AUTH_002 로 알리며 세션 끝을 알린다', () async { + test('갱신이 401 이면 토큰을 지우고 AUTH_002 로 끝내며 세션 끝을 알린다(스낵바 없음)', () async { await api.signIn(); api.server.on( 'POST', @@ -280,12 +284,12 @@ void main() { expect(api.store.hasSession, isFalse); expect(api.secure.values.containsKey(kRefreshTokenKey), isFalse); expect(api.emitted.whereType(), hasLength(1)); - expect(api.notifier.shownCodes, ['AUTH_002']); + expect(api.notifier.shownCodes, isEmpty); // 원래 요청은 다시 보내지 않았다 expect(api.server.received('GET', _mailState), hasLength(1)); }); - test('동시에 세션이 끝난 요청들의 AUTH_002 알림은 한 번만 뜬다', () async { + test('동시에 세션이 끝난 요청들은 갱신 한 번으로 모두 AUTH_002 — 스낵바는 띄우지 않는다', () async { await api.signIn(); api.server.on( 'POST', @@ -304,7 +308,7 @@ void main() { 'AUTH_002', ]); expect(refreshCount(), 1); - expect(api.notifier.shownCodes, ['AUTH_002']); + expect(api.notifier.shownCodes, isEmpty); }); test('갱신이 일시 장애(503 · 연결 끊김)면 세션을 두고 그 코드로 알린다', () async { diff --git a/flutter/test/core/utils/login_input_test.dart b/flutter/test/core/utils/login_input_test.dart new file mode 100644 index 0000000..09d1bda --- /dev/null +++ b/flutter/test/core/utils/login_input_test.dart @@ -0,0 +1,136 @@ +import 'package:arasystem_app/core/utils/display_text.dart'; +import 'package:arasystem_app/core/utils/korean_text.dart'; +import 'package:arasystem_app/core/utils/otp_code.dart'; +import 'package:arasystem_app/core/utils/password_rules.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('인증 앱 코드 칸 다듬기', () { + test('숫자만 남기고 6자리에서 자른다 — 붙여 넣은 띄어쓰기 · 하이픈은 지운다', () { + expect(normalizeOtpInput('123 456'), '123456'); + expect(normalizeOtpInput('123-456'), '123456'); + expect(normalizeOtpInput(' 12a3b4 56 789'), '123456'); + expect(normalizeOtpInput(''), ''); + }); + + test('전각 숫자도 반각으로 받는다(웹의 NFKC 와 같다)', () { + expect(normalizeOtpInput('123 456'), '123456'); + }); + + test('숫자 6자리일 때만 다 넣은 것이다', () { + expect(isCompleteOtp('123456'), isTrue); + expect(isCompleteOtp('12345'), isFalse); + expect(isCompleteOtp('1234567'), isFalse); + expect(isCompleteOtp('12345a'), isFalse); + expect(isCompleteOtp(''), isFalse); + }); + }); + + group('새 비밀번호 규칙 (웹 password-change.ts 와 같다)', () { + test('8자 미만이거나 지금 비밀번호와 같으면 안내 글이 오류가 된다', () { + expect( + passwordChangeProblems( + current: 'temp-1234', + next: 'short', + confirm: 'short', + ).next, + NewPasswordProblem.rule, + ); + expect( + passwordChangeProblems( + current: 'temp-1234', + next: 'temp-1234', + confirm: 'temp-1234', + ).next, + NewPasswordProblem.rule, + ); + }); + + test('128자를 넘으면 tooLong, 확인 칸이 다르면 mismatch, 지금 비밀번호가 비면 missing', () { + final String long = 'a' * (kPasswordMaxLength + 1); + final PasswordChangeProblems problems = passwordChangeProblems( + current: '', + next: long, + confirm: 'other', + ); + expect(problems.next, NewPasswordProblem.tooLong); + expect(problems.confirmMismatch, isTrue); + expect(problems.currentMissing, isTrue); + expect(problems.hasAny, isTrue); + }); + + test('규칙에 맞으면 문제가 없다 — 공백도 비밀번호의 글자다', () { + final PasswordChangeProblems problems = passwordChangeProblems( + current: 'temp-1234', + next: ' new pass ', + confirm: ' new pass ', + ); + expect(problems.hasAny, isFalse); + }); + }); + + group('문의 연락처 한 줄 (웹 support-contact.ts 와 같다)', () { + test('줄바꿈 · 연속 공백은 띄어쓰기 하나, 앞뒤 공백은 지운다', () { + expect( + supportContactTextOf(' IT 담당\n홍길동 02-000-0000 '), + 'IT 담당 홍길동 02-000-0000', + ); + }); + + test('빈 글 · 글이 아닌 값은 null(정하지 않음), 200자에서 자른다', () { + expect(supportContactTextOf(null), isNull); + expect(supportContactTextOf(' '), isNull); + expect(supportContactTextOf(3), isNull); + expect( + supportContactTextOf('가' * 250), + hasLength(kSupportContactMaxLength), + ); + }); + }); + + group('2단계 인증 키 줄', () { + test('4자씩 끊어 한 줄에 4묶음 — 32자 키는 두 줄이다', () { + expect(keyLines('K7QF2M4XHB6RTZ3NPW5CYL7DJQ2VMGAS'), [ + 'K7QF 2M4X HB6R TZ3N', + 'PW5C YL7D JQ2V MGAS', + ]); + }); + + test('길이가 4의 배수가 아니어도 남은 글자를 잃지 않는다', () { + expect(keyLines('ABCDEFGHIJ'), ['ABCD EFGH IJ']); + expect(keyLines(''), isEmpty); + }); + }); + + group('한국어 줄 나눔 (keep-all)', () { + // WORD JOINER(U+2060) — 보이지 않는 글자라 코드 포인트로 만든다 + final String joiner = String.fromCharCode(0x2060); + + test('한글 낱말 안에 줄 나눔 막기를 넣고 띄어쓰기는 그대로 둔다', () { + final String first = ['자', '동', '으', '로'].join(joiner); + final String second = ['맞', '춰'].join(joiner); + expect(keepAllWordBreak('자동으로 맞춰'), '$first $second'); + }); + + test('한글과 붙은 영문 · 기호도 한 낱말로 남는다', () { + expect( + keepAllWordBreak('Outlook이나'), + ['Outlook', '이', '나'].join(joiner), + ); + expect( + keepAllWordBreak('[설정]에서'), + ['[', '설', '정', ']', '에', '서'].join(joiner), + ); + }); + + test('줄 나눔 막기를 지우면 원래 글이다', () { + const String text = 'Outlook이나 휴대폰 기본 메일 앱 같은 다른 메일 프로그램은'; + expect(keepAllWordBreak(text).replaceAll(joiner, ''), text); + }); + + test('한글이 없는 낱말 · 이모지 옆에는 넣지 않는다(글자 묶음을 가르지 않게)', () { + expect(keepAllWordBreak('02-000-0000 @it'), '02-000-0000 @it'); + expect(keepAllWordBreak('가👍나'), '가👍나'); + }); + }); +} diff --git a/flutter/test/error_lexicon_test.dart b/flutter/test/error_lexicon_test.dart index f455b57..c391edd 100644 --- a/flutter/test/error_lexicon_test.dart +++ b/flutter/test/error_lexicon_test.dart @@ -20,7 +20,9 @@ void main() { test('클라이언트 로직이 참조하는 코드가 사전에 존재한다', () { // 상태 코드 역매핑(api_error_mapper) · 세션 만료 승격 · AUTH_010 다시 확인(auth_interceptor) · - // 로그인 화면이 칸에 보이는 실패(auth_repository 의 kLoginInlineCodes)가 이 코드들을 직접 사용한다. + // 로그인 화면이 칸에 보이는 실패(auth_repository 의 kLoginInlineCodes) · + // 제한 단계(새 비밀번호 · 2단계 켜기 — kPasswordChangeInlineCodes · kTwoFactorSetupInlineCodes 의 AUTH_005 · AUTH_008)가 + // 이 코드들을 직접 사용한다. // // ⚠️ 전체 목록을 비교하지 않는 이유: 세 플랫폼의 사전은 backend 레지스트리에서 // 생성되므로 목록 일치는 구조적으로 보장되고, CI 가 드리프트를 검사한다. @@ -32,8 +34,10 @@ void main() { 'AUTH_002', 'AUTH_003', 'AUTH_004', + 'AUTH_005', 'AUTH_006', 'AUTH_007', + 'AUTH_008', 'AUTH_010', 'VAL_001', 'RES_001', diff --git a/flutter/test/features/auth/login_pages_test.dart b/flutter/test/features/auth/login_pages_test.dart new file mode 100644 index 0000000..ea5118e --- /dev/null +++ b/flutter/test/features/auth/login_pages_test.dart @@ -0,0 +1,1873 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_config.dart'; +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/api/dio_client.dart'; +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/api/error_lexicon.dart'; +import 'package:arasystem_app/core/api/error_notifier.dart'; +import 'package:arasystem_app/core/api/session_events.dart'; +import 'package:arasystem_app/core/api/token_refresher.dart'; +import 'package:arasystem_app/core/constants/login_texts.dart'; +import 'package:arasystem_app/core/constants/screen_titles.dart'; +import 'package:arasystem_app/core/modules/app_modules.dart'; +import 'package:arasystem_app/core/push/push_registration.dart'; +import 'package:arasystem_app/core/storage/token_store.dart'; +import 'package:arasystem_app/core/utils/clock.dart'; +import 'package:arasystem_app/features/auth/data/authenticator_launcher.dart'; +import 'package:arasystem_app/features/auth/data/repositories/auth_repository.dart'; +import 'package:arasystem_app/features/auth/data/repositories/support_contact_repository.dart'; +import 'package:arasystem_app/features/auth/data/repositories/two_factor_repository.dart'; +import 'package:arasystem_app/features/auth/presentation/pages/login_code_page.dart'; +import 'package:arasystem_app/features/auth/presentation/pages/login_page.dart'; +import 'package:arasystem_app/features/auth/presentation/pages/password_change_page.dart'; +import 'package:arasystem_app/features/auth/presentation/pages/restricted_login_page.dart'; +import 'package:arasystem_app/features/auth/presentation/pages/two_factor_setup_page.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_fields.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_parts.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +import '../../support/fakes.dart'; +import '../../support/login_fakes.dart'; + +/// 로그인 틀 네 단계 (앱 보드 `App1Login`) — 앱 전체를 가짜 서버로 띄워 단계 · 오류 · 비밀 지우기 · 늦은 결과를 본다 +void main() { + late LoginHarness h; + + setUp(() => h = LoginHarness()); + + Finder button(String label) => find.widgetWithText(FilledButton, label); + + /// 단계의 제목 — 단추에도 같은 글("로그인")이 있어 머리 안에서 찾는다 + Finder heading(String title) => + find.descendant(of: find.byType(LoginHeading), matching: findCopy(title)); + + Finder otherAccount() => + find.widgetWithText(TextButton, LoginBandTexts.otherAccount); + + /// [다른 계정으로 로그인]을 지금 누를 수 있나 + bool otherAccountEnabled(WidgetTester tester) => + tester.widget(otherAccount()).onPressed != null; + + /// 위쪽 상자 안의 글 + Finder alertWith(String text) => + find.descendant(of: find.byType(LoginAlertBox), matching: findCopy(text)); + + /// 칸 이름으로 그 칸의 화면 낭독기 노드(칸에 붙인 이름 · 설명이 합쳐진 곳)를 찾는다 + Finder fieldEditable(String label) => find.descendant( + of: fieldInput(label), + matching: find.byType(EditableText), + ); + + String message(String code) => ErrorLexicon.getMessage(code); + + Map bodyOf(RequestOptions options) => + options.data! as Map; + + List logins() => h.server.received('POST', ApiPaths.appLogin); + + /// 로그인 응답 — 2단계를 켠 직원이면 코드 단계를 거친다 + void loginAnswers({String? restriction, bool twoFactor = false}) { + h.server.on('POST', ApiPaths.appLogin, (RequestOptions options) { + if (twoFactor && bodyOf(options)['otpCode'] == null) { + return codeStepResponse(); + } + return signedInResponse(1, now: h.clock.now, restriction: restriction); + }); + } + + Future tapAndSettle(WidgetTester tester, Finder finder) async { + await tester.ensureVisible(finder); + await tester.tap(finder); + await tester.pumpAndSettle(); + } + + Future submitLogin( + WidgetTester tester, { + String email = kTestEmail, + String password = 'temp-pw-1', + }) async { + await tester.enterText(fieldInput(LoginTexts.emailLabel), email); + await tester.enterText(fieldInput(LoginTexts.passwordLabel), password); + await tapAndSettle(tester, button(LoginTexts.submit)); + } + + Future submitCode(WidgetTester tester, String code) async { + await tester.enterText(fieldInput(LoginCodeTexts.codeLabel), code); + await tapAndSettle(tester, button(LoginCodeTexts.submit)); + } + + group('첫 단계 — 주소와 비밀번호', () { + testWidgets('보드의 글 — 브랜드 띠 · 제목 · 칸 이름 · 문의 줄의 연락처', ( + WidgetTester tester, + ) async { + h.supportContact('IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it'); + + await h.pump(tester); + + expect(find.byType(LoginPage), findsOneWidget); + expect(heading(ScreenTitles.login), findsOneWidget); + for (final String text in [ + LoginTexts.brandHeadline, + LoginTexts.brandLead, + LoginTexts.subtitle, + LoginTexts.emailLabel, + LoginTexts.passwordLabel, + ]) { + expect(findCopy(text), findsOneWidget, reason: text); + } + expect(find.text(LoginTexts.emailPlaceholder), findsOneWidget); + expect(find.text(LoginTexts.passwordPlaceholder), findsOneWidget); + expect(button(LoginTexts.submit), findsOneWidget); + expect( + findCopy( + '${SupportLineTexts.passwordQuestion}\nIT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it', + ), + findsOneWidget, + ); + // 로그인 없이 묻는다 + expect( + bearerTokenOf(h.server.received('GET', ApiPaths.supportContact).single), + isNull, + ); + // 웹 첫 화면의 메일 프로그램(IMAP) 안내 줄은 넣지 않는다 + expect(find.textContaining('IMAP'), findsNothing); + }); + + testWidgets('연락처를 받지 못하면 지금 글 그대로 — 알리지 않는다', (WidgetTester tester) async { + await h.pump(tester); + + expect(findCopy(SupportLineTexts.passwordFallback), findsOneWidget); + expect(h.notifier.shownCodes, isEmpty); + }); + + testWidgets('[보기]는 비밀번호를 보이고 눌린 상태를 화면 낭독기에 알린다', ( + WidgetTester tester, + ) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + await h.pump(tester); + TextField password() => + tester.widget(fieldInput(LoginTexts.passwordLabel)); + expect(password().obscureText, isTrue); + expect( + tester.getSemantics(find.byType(PasswordVisibilityToggle)), + isSemantics( + label: LoginTexts.showPassword, + isButton: true, + hasToggledState: true, + isToggled: false, + ), + ); + + await tapAndSettle(tester, find.byType(PasswordVisibilityToggle)); + + expect(password().obscureText, isFalse); + expect( + tester.getSemantics(find.byType(PasswordVisibilityToggle)), + isSemantics(hasToggledState: true, isToggled: true), + ); + semantics.dispose(); + }); + + testWidgets('빈 칸은 보내지 않고 그 칸 아래에 VAL_001 사전 문구', ( + WidgetTester tester, + ) async { + await h.pump(tester); + + await tapAndSettle(tester, button(LoginTexts.submit)); + + expect(logins(), isEmpty); + // 두 칸 모두 비었다 — 칸마다 아래에 + expect(findCopy(message('VAL_001')), findsNWidgets(2)); + expect( + find.descendant( + of: find.byWidgetPredicate( + (Widget w) => + w is LoginTextField && w.label == LoginTexts.emailLabel, + ), + matching: findCopy(message('VAL_001')), + ), + findsOneWidget, + ); + }); + + testWidgets('AUTH_004 — 위쪽 상자에 사전 문구, 두 칸 빨강, 비밀번호는 비우고 알리지 않는다', ( + WidgetTester tester, + ) async { + h.server.on( + 'POST', + ApiPaths.appLogin, + (_) => FakeResponse.fail(401, 'AUTH_004'), + ); + await h.pump(tester); + + await submitLogin(tester, password: 'wrong'); + + expect(findCopy(message('AUTH_004')), findsOneWidget); + expect(fieldText(tester, LoginTexts.emailLabel), kTestEmail); + expect(fieldText(tester, LoginTexts.passwordLabel), isEmpty); + for (final String label in [ + LoginTexts.emailLabel, + LoginTexts.passwordLabel, + ]) { + final TextField field = tester.widget(fieldInput(label)); + expect( + field.decoration?.enabledBorder?.borderSide.color, + Theme.of(tester.element(fieldInput(label))).colorScheme.error, + reason: label, + ); + } + expect(h.notifier.shownCodes, isEmpty); + }); + + testWidgets('그 밖의 실패(REQ_001)는 전역 알림 하나 — 칸 · 상자에 보이지 않는다', ( + WidgetTester tester, + ) async { + h.server.on( + 'POST', + ApiPaths.appLogin, + (_) => FakeResponse.fail(429, 'REQ_001'), + ); + await h.pump(tester); + + await submitLogin(tester); + + expect(h.notifier.shownCodes, ['REQ_001']); + expect(findCopy(message('REQ_001')), findsNothing); + }); + + testWidgets('앱이 정한 실패(응답 모양이 틀림 — SYS_001)도 전역 알림으로 보인다', ( + WidgetTester tester, + ) async { + h.server.on( + 'POST', + ApiPaths.appLogin, + (_) => FakeResponse.ok({'status': 'signedIn'}), + ); + await h.pump(tester); + + await submitLogin(tester); + + expect(h.notifier.shownCodes, ['SYS_001']); + expect(find.byType(LoginPage), findsOneWidget); + }); + + testWidgets('보내는 동안 단추는 「로그인하는 중…」이고 두 번 보내지 않는다', ( + WidgetTester tester, + ) async { + final Completer release = Completer(); + h.server.on('POST', ApiPaths.appLogin, (_) async { + await release.future; + return FakeResponse.fail(401, 'AUTH_004'); + }); + await h.pump(tester); + await tester.enterText(fieldInput(LoginTexts.emailLabel), kTestEmail); + await tester.enterText(fieldInput(LoginTexts.passwordLabel), 'pw'); + + await tester.tap(button(LoginTexts.submit)); + await tester.pump(); + expect(button(LoginTexts.submitting), findsOneWidget); + expect( + tester.widget(button(LoginTexts.submitting)).onPressed, + isNull, + ); + await tester.tap(button(LoginTexts.submitting), warnIfMissed: false); + await tester.pump(); + + release.complete(); + await tester.pumpAndSettle(); + expect(logins(), hasLength(1)); + expect(button(LoginTexts.submit), findsOneWidget); + }); + + testWidgets('주소 칸은 자판 추천을 켠다(@ 가 있는 주소 자판) — 고쳐 쓰기는 끄고, 비밀번호 칸은 둘 다 끈다', ( + WidgetTester tester, + ) async { + await h.pump(tester); + + final TextField email = tester.widget( + fieldInput(LoginTexts.emailLabel), + ); + expect(email.keyboardType, TextInputType.emailAddress); + expect(email.enableSuggestions, isTrue); + expect(email.autocorrect, isFalse); + final TextField password = tester.widget( + fieldInput(LoginTexts.passwordLabel), + ); + expect(password.enableSuggestions, isFalse); + expect(password.autocorrect, isFalse); + }); + + testWidgets('위쪽 상자의 글(AUTH_004)은 두 칸의 설명으로도 붙는다 — 칸에 다시 와도 함께 읽힌다', ( + WidgetTester tester, + ) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + h.server.on( + 'POST', + ApiPaths.appLogin, + (_) => FakeResponse.fail(401, 'AUTH_004'), + ); + await h.pump(tester); + + await submitLogin(tester, password: 'wrong'); + + for (final String label in [ + LoginTexts.emailLabel, + LoginTexts.passwordLabel, + ]) { + expect( + tester.getSemantics(fieldEditable(label)).hint, + contains(message('AUTH_004')), + reason: label, + ); + } + semantics.dispose(); + }); + + testWidgets('세션이 끝나 돌아오면 위쪽 상자에 AUTH_002 — 전역 알림은 띄우지 않는다', ( + WidgetTester tester, + ) async { + loginAnswers(); + final ProviderContainer container = await h.pump(tester); + await submitLogin(tester); + expect(find.byType(NavigationBar), findsOneWidget); + h.server.on( + 'GET', + '/mailboxes', + (_) => FakeResponse.fail(401, 'AUTH_001'), + ); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.fail(401, 'AUTH_001'), + ); + + Object? failure; + unawaited( + container + .read(dioProvider) + .get('/mailboxes') + .then((_) {}, onError: (Object error) => failure = error), + ); + await tester.pumpAndSettle(); + + expect(apiErrorOf(failure!).code, 'AUTH_002'); + expect(find.byType(LoginPage), findsOneWidget); + expect(alertWith(message('AUTH_002')), findsOneWidget); + expect(h.notifier.shownCodes, isEmpty); + expect(find.byType(SnackBar), findsNothing); + }); + }); + + group('둘째 단계 — 2단계 인증 코드', () { + testWidgets('비밀번호가 맞고 2단계를 켰으면 코드 단계 — 짙은 띠 · 주소 · 칸 · 안내', ( + WidgetTester tester, + ) async { + loginAnswers(twoFactor: true); + await h.pump(tester); + + await submitLogin(tester); + + expect(find.byType(LoginCodePage), findsOneWidget); + for (final String text in [ + ScreenTitles.loginCode, + LoginCodeTexts.subtitle, + LoginCodeTexts.codeLabel, + SupportLineTexts.authenticatorFallback, + LoginCodeTexts.note, + ]) { + expect(findCopy(text), findsOneWidget, reason: text); + } + expect(find.text(kTestEmail), findsOneWidget); + expect(otherAccount(), findsOneWidget); + // 코드 칸에 초점 — 숫자 자판 · one-time-code + final TextField code = tester.widget( + fieldInput(LoginCodeTexts.codeLabel), + ); + expect(code.focusNode?.hasFocus, isTrue); + expect(code.keyboardType, TextInputType.number); + expect(code.autofillHints, contains(AutofillHints.oneTimeCode)); + }); + + testWidgets('붙여 넣은 글은 숫자 6자리로 다듬는다', (WidgetTester tester) async { + loginAnswers(twoFactor: true); + await h.pump(tester); + await submitLogin(tester); + + await tester.enterText( + fieldInput(LoginCodeTexts.codeLabel), + '123 456 78', + ); + await tester.pump(); + + expect(fieldText(tester, LoginCodeTexts.codeLabel), '123456'); + }); + + testWidgets('6자리가 아니면 보내지 않고 칸 아래에 AUTH_007', (WidgetTester tester) async { + loginAnswers(twoFactor: true); + await h.pump(tester); + await submitLogin(tester); + + await submitCode(tester, '12345'); + + expect(logins(), hasLength(1)); + expect(findCopy(message('AUTH_007')), findsOneWidget); + }); + + testWidgets('AUTH_006 — 칸 아래 사전 문구 · 칸을 비우고 알리지 않는다', ( + WidgetTester tester, + ) async { + h.server.on('POST', ApiPaths.appLogin, (RequestOptions options) { + return bodyOf(options)['otpCode'] == null + ? codeStepResponse() + : FakeResponse.fail(400, 'AUTH_006'); + }); + await h.pump(tester); + await submitLogin(tester); + + await submitCode(tester, '111111'); + + expect(findCopy(message('AUTH_006')), findsOneWidget); + expect(fieldText(tester, LoginCodeTexts.codeLabel), isEmpty); + expect(h.notifier.shownCodes, isEmpty); + expect(find.byType(LoginCodePage), findsOneWidget); + // 비밀번호를 한 번 더 실어 보냈다(서버는 비밀번호를 들고 있지 않다) + expect(bodyOf(logins().last), { + 'email': kTestEmail, + 'password': 'temp-pw-1', + 'otpCode': '111111', + }); + }); + + testWidgets('AUTH_004(그사이 비밀번호가 바뀜) — 첫 단계로 돌아가 주소를 남기고 위쪽 상자', ( + WidgetTester tester, + ) async { + h.server.on('POST', ApiPaths.appLogin, (RequestOptions options) { + return bodyOf(options)['otpCode'] == null + ? codeStepResponse() + : FakeResponse.fail(401, 'AUTH_004'); + }); + await h.pump(tester); + await submitLogin(tester); + + await submitCode(tester, '123456'); + + expect(find.byType(LoginPage), findsOneWidget); + expect(findCopy(message('AUTH_004')), findsOneWidget); + expect(fieldText(tester, LoginTexts.emailLabel), kTestEmail); + expect(fieldText(tester, LoginTexts.passwordLabel), isEmpty); + expect(h.notifier.shownCodes, isEmpty); + }); + + testWidgets('10분 넘게 머물면 [확인]에 보내지 않고 첫 단계로 — 주소를 남기고 시간 지남 안내', ( + WidgetTester tester, + ) async { + loginAnswers(twoFactor: true); + await h.pump(tester); + await submitLogin(tester); + + h.clock.advance(const Duration(minutes: 10)); + await tester.pump(const Duration(minutes: 10)); + await submitCode(tester, '123456'); + + expect(logins(), hasLength(1)); + expect(find.byType(LoginPage), findsOneWidget); + expect(findCopy(LoginTexts.codeTimedOut), findsOneWidget); + expect(fieldText(tester, LoginTexts.emailLabel), kTestEmail); + }); + + testWidgets('[다른 계정으로 로그인]은 보내는 중에도 첫 단계로 — 늦은 결과는 보이지 않고 그 세션을 끝낸다', ( + WidgetTester tester, + ) async { + final Completer release = Completer(); + h.server.on('POST', ApiPaths.appLogin, (RequestOptions options) async { + if (bodyOf(options)['otpCode'] == null) return codeStepResponse(); + await release.future; + return signedInResponse(1, now: h.clock.now); + }); + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + await h.pump(tester); + await submitLogin(tester); + await tester.enterText(fieldInput(LoginCodeTexts.codeLabel), '123456'); + await tester.tap(button(LoginCodeTexts.submit)); + await tester.pump(); + + await tapAndSettle(tester, otherAccount()); + release.complete(); + await tester.pumpAndSettle(); + + expect(find.byType(LoginPage), findsOneWidget); + expect(fieldText(tester, LoginTexts.emailLabel), isEmpty); + expect(find.byType(NavigationBar), findsNothing); + expect(h.notifier.shownCodes, isEmpty); + expect(h.server.received('POST', ApiPaths.appLogout), hasLength(1)); + }); + + testWidgets( + 'REQ_001(시도 과다) — 위쪽 상자에 사전 문구, 코드는 그대로 · 알리지 않고 다음에 보낼 때 내린다', + (WidgetTester tester) async { + Completer? release; + h.server.on('POST', ApiPaths.appLogin, (RequestOptions options) async { + if (bodyOf(options)['otpCode'] == null) return codeStepResponse(); + final Completer? waiting = release; + if (waiting == null) return FakeResponse.fail(429, 'REQ_001'); + await waiting.future; + return signedInResponse(1, now: h.clock.now); + }); + await h.pump(tester); + await submitLogin(tester); + + await submitCode(tester, '123456'); + + expect(alertWith(message('REQ_001')), findsOneWidget); + expect(fieldText(tester, LoginCodeTexts.codeLabel), '123456'); + expect(find.byType(LoginCodePage), findsOneWidget); + expect(h.notifier.shownCodes, isEmpty); + + // 같은 코드로 다시 보낸다 — 보내는 순간 상자를 내린다 + release = Completer(); + await tester.tap(button(LoginCodeTexts.submit)); + await tester.pump(Duration.zero); + // 보내는 중(서버가 아직 답하지 않았다) — 상자는 이미 내렸다 + expect(logins(), hasLength(3)); + expect(alertWith(message('REQ_001')), findsNothing); + + release.complete(); + await tester.pumpAndSettle(); + expect(find.byType(NavigationBar), findsOneWidget); + }, + ); + + testWidgets('코드가 맞고 제한이 없으면 메일 화면으로 간다', (WidgetTester tester) async { + loginAnswers(twoFactor: true); + await h.pump(tester); + await submitLogin(tester); + + await submitCode(tester, '123456'); + + expect(find.byType(NavigationBar), findsOneWidget); + expect(find.byType(LoginCodePage), findsNothing); + }); + }); + + group('셋째 단계 — 새 비밀번호 정하기', () { + testWidgets('보드의 글 — 로그인 때 넣은 임시 비밀번호를 쥐고 있으면 그 칸이 없다', ( + WidgetTester tester, + ) async { + loginAnswers(restriction: 'passwordChange'); + await h.pump(tester); + + await submitLogin(tester); + + expect(find.byType(PasswordChangePage), findsOneWidget); + for (final String text in [ + ScreenTitles.passwordChange, + PasswordChangeTexts.subtitle, + PasswordChangeTexts.nextLabel, + PasswordChangeTexts.nextHint, + PasswordChangeTexts.confirmLabel, + PasswordChangeTexts.note, + ]) { + expect(findCopy(text), findsOneWidget, reason: text); + } + expect(button(PasswordChangeTexts.submit), findsOneWidget); + expect(findCopy(PasswordChangeTexts.temporaryLabel), findsNothing); + // 코드 단계를 거치지 않았으니 2단계 상태를 묻는다 — 꺼져 있으면(여기선 404) 코드 칸은 없다 + expect(h.server.received('GET', ApiPaths.twoFactor), hasLength(1)); + expect(findCopy(PasswordChangeTexts.codeLabel), findsNothing); + }); + + testWidgets('규칙에 맞지 않으면 보내지 않는다 — 안내 글이 빨강, 확인이 다르면 그 칸 아래', ( + WidgetTester tester, + ) async { + loginAnswers(restriction: 'passwordChange'); + await h.pump(tester); + await submitLogin(tester); + + await tester.enterText( + fieldInput(PasswordChangeTexts.nextLabel), + 'temp-pw-1', + ); + await tester.enterText( + fieldInput(PasswordChangeTexts.confirmLabel), + 'other', + ); + await tapAndSettle(tester, button(PasswordChangeTexts.submit)); + + expect(h.server.received('PUT', ApiPaths.password), isEmpty); + final Text hint = tester.widget( + findCopy(PasswordChangeTexts.nextHint), + ); + expect( + hint.style?.color, + Theme.of( + tester.element(findCopy(PasswordChangeTexts.nextHint)), + ).colorScheme.error, + ); + expect(findCopy(PasswordChangeTexts.mismatch), findsOneWidget); + }); + + testWidgets('바꾸면 새 토큰을 받아 2단계 켜기로 — 알림, 지금 비밀번호는 새 비밀번호다', ( + WidgetTester tester, + ) async { + loginAnswers(restriction: 'passwordChange'); + h.server.on('PUT', ApiPaths.password, (_) => FakeResponse.ok()); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => + refreshResponse(2, now: h.clock.now, restriction: 'twoFactorSetup'), + ); + h.server.on( + 'POST', + ApiPaths.twoFactorEnrollments, + (_) => enrollmentResponse(), + ); + h.server.on( + 'POST', + ApiPaths.twoFactorActivation, + (_) => twoFactorStatusResponse(enabled: true), + ); + await h.pump(tester); + await submitLogin(tester); + + await tester.enterText( + fieldInput(PasswordChangeTexts.nextLabel), + 'new-pw-1234', + ); + await tester.enterText( + fieldInput(PasswordChangeTexts.confirmLabel), + 'new-pw-1234', + ); + await tapAndSettle(tester, button(PasswordChangeTexts.submit)); + + expect( + bodyOf(h.server.received('PUT', ApiPaths.password).single), + { + 'currentPassword': 'temp-pw-1', + 'newPassword': 'new-pw-1234', + }, + ); + expect(find.byType(TwoFactorSetupPage), findsOneWidget); + expect(find.text(PasswordChangeTexts.done), findsOneWidget); + + // 2단계 켜기는 방금 정한 새 비밀번호를 지금 비밀번호로 쓴다(③ 칸 없음) + expect(findCopy(TwoFactorSetupTexts.passwordLabel), findsNothing); + await tester.enterText( + fieldInput(TwoFactorSetupTexts.codeLabel), + '123456', + ); + await tapAndSettle(tester, button(TwoFactorSetupTexts.submit)); + expect( + bodyOf(h.server.received('POST', ApiPaths.twoFactorActivation).single), + containsPair('currentPassword', 'new-pw-1234'), + ); + }); + + testWidgets('쥔 임시 비밀번호를 서버가 거절하면(AUTH_005) 임시 비밀번호 칸이 그 오류와 함께 보인다', ( + WidgetTester tester, + ) async { + loginAnswers(restriction: 'passwordChange'); + h.server.on( + 'PUT', + ApiPaths.password, + (_) => FakeResponse.fail(400, 'AUTH_005'), + ); + await h.pump(tester); + await submitLogin(tester); + + await tester.enterText( + fieldInput(PasswordChangeTexts.nextLabel), + 'new-pw-1234', + ); + await tester.enterText( + fieldInput(PasswordChangeTexts.confirmLabel), + 'new-pw-1234', + ); + await tapAndSettle(tester, button(PasswordChangeTexts.submit)); + + expect(findCopy(PasswordChangeTexts.temporaryLabel), findsOneWidget); + expect( + find.descendant( + of: find.byWidgetPredicate( + (Widget w) => + w is LoginTextField && + w.label == PasswordChangeTexts.temporaryLabel, + ), + matching: findCopy(message('AUTH_005')), + ), + findsOneWidget, + ); + expect(h.notifier.shownCodes, isEmpty); + + // 칸에 넣은 임시 비밀번호로 다시 보낸다 + h.server.on('PUT', ApiPaths.password, (_) => FakeResponse.ok()); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => refreshResponse(2, now: h.clock.now), + ); + await tester.enterText( + fieldInput(PasswordChangeTexts.temporaryLabel), + 'typed-temp', + ); + await tapAndSettle(tester, button(PasswordChangeTexts.submit)); + expect( + bodyOf(h.server.received('PUT', ApiPaths.password).last), + containsPair('currentPassword', 'typed-temp'), + ); + expect(find.byType(NavigationBar), findsOneWidget); + }); + + testWidgets('코드 단계를 거쳐 왔으면 코드 칸 — 다음 코드를 함께 보낸다', ( + WidgetTester tester, + ) async { + loginAnswers(restriction: 'passwordChange', twoFactor: true); + h.server.on('PUT', ApiPaths.password, (_) => FakeResponse.ok()); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => refreshResponse(2, now: h.clock.now), + ); + await h.pump(tester); + await submitLogin(tester); + await submitCode(tester, '123456'); + + expect(findCopy(PasswordChangeTexts.codeLabel), findsOneWidget); + expect(findCopy(PasswordChangeTexts.codeHint), findsOneWidget); + // 코드 단계를 거쳤으니 묻지 않는다 + expect(h.server.received('GET', ApiPaths.twoFactor), isEmpty); + await tester.enterText( + fieldInput(PasswordChangeTexts.nextLabel), + 'new-pw-1234', + ); + await tester.enterText( + fieldInput(PasswordChangeTexts.confirmLabel), + 'new-pw-1234', + ); + await tapAndSettle(tester, button(PasswordChangeTexts.submit)); + expect(findCopy(message('AUTH_007')), findsOneWidget); + expect(h.server.received('PUT', ApiPaths.password), isEmpty); + + await tester.enterText( + fieldInput(PasswordChangeTexts.codeLabel), + '654321', + ); + await tapAndSettle(tester, button(PasswordChangeTexts.submit)); + expect( + bodyOf(h.server.received('PUT', ApiPaths.password).single), + containsPair('otpCode', '654321'), + ); + }); + + testWidgets('서버가 코드를 요구하면(AUTH_007 — 그사이 다른 곳에서 켬) 코드 칸을 보인다', ( + WidgetTester tester, + ) async { + loginAnswers(restriction: 'passwordChange'); + h.server.on( + 'PUT', + ApiPaths.password, + (_) => FakeResponse.fail(400, 'AUTH_007'), + ); + await h.pump(tester); + await submitLogin(tester); + expect(findCopy(PasswordChangeTexts.codeLabel), findsNothing); + + await tester.enterText( + fieldInput(PasswordChangeTexts.nextLabel), + 'new-pw-1234', + ); + await tester.enterText( + fieldInput(PasswordChangeTexts.confirmLabel), + 'new-pw-1234', + ); + await tapAndSettle(tester, button(PasswordChangeTexts.submit)); + + expect(findCopy(PasswordChangeTexts.codeLabel), findsOneWidget); + expect(findCopy(message('AUTH_007')), findsOneWidget); + expect(h.notifier.shownCodes, isEmpty); + }); + + testWidgets('앱을 다시 열어 쥔 비밀번호가 없으면 임시 비밀번호 칸 — 2단계가 켜졌으면 코드 칸도', ( + WidgetTester tester, + ) async { + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => + refreshResponse(2, now: h.clock.now, restriction: 'passwordChange'), + ); + h.server.on( + 'GET', + ApiPaths.me, + (_) => FakeResponse.ok(sessionJson(restriction: 'passwordChange')), + ); + h.server.on( + 'GET', + ApiPaths.twoFactor, + (_) => twoFactorStatusResponse(enabled: true), + ); + + await h.pump(tester, refreshToken: 'refresh-1'); + + expect(find.byType(PasswordChangePage), findsOneWidget); + expect(findCopy(PasswordChangeTexts.temporaryLabel), findsOneWidget); + expect(findCopy(PasswordChangeTexts.temporaryHint), findsOneWidget); + expect(findCopy(PasswordChangeTexts.codeLabel), findsOneWidget); + expect( + bearerTokenOf(h.server.received('GET', ApiPaths.twoFactor).single), + 'access-2', + ); + }); + + testWidgets('쥔 임시 비밀번호는 10분 뒤 지워지고 그 칸이 나타난다', (WidgetTester tester) async { + loginAnswers(restriction: 'passwordChange'); + await h.pump(tester); + await submitLogin(tester); + expect(findCopy(PasswordChangeTexts.temporaryLabel), findsNothing); + + h.clock.advance(const Duration(minutes: 10)); + await tester.pump(const Duration(minutes: 10)); + await tester.pumpAndSettle(); + + expect(findCopy(PasswordChangeTexts.temporaryLabel), findsOneWidget); + }); + + testWidgets('바꿨지만 넘기지 못하면 위쪽 상자 + [다시 시도] — 칸은 막히고, 다시 시도로 넘어간다', ( + WidgetTester tester, + ) async { + loginAnswers(restriction: 'passwordChange'); + h.server.on('PUT', ApiPaths.password, (_) => FakeResponse.ok()); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.fail(503, 'MAIL_001'), + ); + h.server.on('GET', ApiPaths.me, (_) => FakeResponse.offline); + await h.pump(tester); + await submitLogin(tester); + await tester.enterText( + fieldInput(PasswordChangeTexts.nextLabel), + 'new-pw-1234', + ); + await tester.enterText( + fieldInput(PasswordChangeTexts.confirmLabel), + 'new-pw-1234', + ); + + await tapAndSettle(tester, button(PasswordChangeTexts.submit)); + + expect(findCopy(message('MAIL_001')), findsOneWidget); + expect( + find.widgetWithText(OutlinedButton, RestrictedStepTexts.retry), + findsOneWidget, + ); + // 이미 바꿨다 — 다시 보내지 않게 막는다 + final FilledButton submit = tester.widget( + button(PasswordChangeTexts.submit), + ); + expect(submit.onPressed, isNull); + expect( + tester + .widget(fieldInput(PasswordChangeTexts.nextLabel)) + .readOnly, + isTrue, + ); + + // 곧바로 눌러도 방금의 갱신 실패를 다시 쓰지 않고 새로 묻는다 + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => refreshResponse(2, now: h.clock.now), + ); + await tapAndSettle( + tester, + find.widgetWithText(OutlinedButton, RestrictedStepTexts.retry), + ); + + expect(find.byType(NavigationBar), findsOneWidget); + expect(h.server.received('PUT', ApiPaths.password), hasLength(1)); + }); + + testWidgets('REQ_001(시도 과다) — 위쪽 상자, 칸은 그대로 · 알리지 않고 다음에 보낼 때 내린다', ( + WidgetTester tester, + ) async { + loginAnswers(restriction: 'passwordChange'); + Completer? release; + h.server.on('PUT', ApiPaths.password, (_) async { + final Completer? waiting = release; + if (waiting == null) return FakeResponse.fail(429, 'REQ_001'); + await waiting.future; + return FakeResponse.ok(); + }); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => refreshResponse(2, now: h.clock.now), + ); + await h.pump(tester); + await submitLogin(tester); + await tester.enterText( + fieldInput(PasswordChangeTexts.nextLabel), + 'new-pw-1234', + ); + await tester.enterText( + fieldInput(PasswordChangeTexts.confirmLabel), + 'new-pw-1234', + ); + + await tapAndSettle(tester, button(PasswordChangeTexts.submit)); + + expect(alertWith(message('REQ_001')), findsOneWidget); + expect(fieldText(tester, PasswordChangeTexts.nextLabel), 'new-pw-1234'); + expect( + tester + .widget(button(PasswordChangeTexts.submit)) + .onPressed, + isNotNull, + ); + expect(h.notifier.shownCodes, isEmpty); + + release = Completer(); + await tester.tap(button(PasswordChangeTexts.submit)); + await tester.pump(Duration.zero); + expect(h.server.received('PUT', ApiPaths.password), hasLength(2)); + expect(alertWith(message('REQ_001')), findsNothing); + + release.complete(); + await tester.pumpAndSettle(); + expect(find.byType(NavigationBar), findsOneWidget); + }); + + testWidgets('보내는 동안 · 넘기는 동안 [다른 계정으로 로그인]은 누를 수 없다 — 끝나면 다시 누를 수 있다', ( + WidgetTester tester, + ) async { + loginAnswers(restriction: 'passwordChange'); + final Completer changed = Completer(); + final Completer refreshed = Completer(); + h.server.on('PUT', ApiPaths.password, (_) async { + await changed.future; + return FakeResponse.ok(); + }); + h.server.on('POST', ApiPaths.appRefresh, (_) async { + await refreshed.future; + return FakeResponse.fail(503, 'MAIL_001'); + }); + h.server.on('GET', ApiPaths.me, (_) => FakeResponse.offline); + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + await h.pump(tester); + await submitLogin(tester); + expect(otherAccountEnabled(tester), isTrue); + await tester.enterText( + fieldInput(PasswordChangeTexts.nextLabel), + 'new-pw-1234', + ); + await tester.enterText( + fieldInput(PasswordChangeTexts.confirmLabel), + 'new-pw-1234', + ); + + // 보내는 동안 — 서버가 이미 바꿨을 수 있다. + // (Dio 는 요청 차례를 0초 타이머로 넘긴다 — 시간을 주지 않는 pump() 로는 보내지 않는다) + await tester.tap(button(PasswordChangeTexts.submit)); + await tester.pump(Duration.zero); + expect(h.server.received('PUT', ApiPaths.password), hasLength(1)); + expect(otherAccountEnabled(tester), isFalse); + await tester.tap(otherAccount()); + await tester.pump(Duration.zero); + + // 넘기는 동안(새 토큰 받기) + changed.complete(); + await tester.pump(Duration.zero); + expect(h.server.received('POST', ApiPaths.appRefresh), hasLength(1)); + expect(otherAccountEnabled(tester), isFalse); + await tester.tap(otherAccount()); + await tester.pump(); + + refreshed.complete(); + await tester.pumpAndSettle(); + expect(find.byType(PasswordChangePage), findsOneWidget); + expect(h.server.received('POST', ApiPaths.appLogout), isEmpty); + // 넘기지 못해 [다시 시도]가 남았다 — 이제는 다른 계정으로 갈 수 있다 + expect(findCopy(message('MAIL_001')), findsOneWidget); + expect(otherAccountEnabled(tester), isTrue); + }); + + testWidgets('코드 칸의 안내와 오류를 함께 보인다(AUTH_006) — 오류만 나타날 때 읽히고, 둘 다 칸의 설명이다', ( + WidgetTester tester, + ) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + loginAnswers(restriction: 'passwordChange', twoFactor: true); + h.server.on( + 'PUT', + ApiPaths.password, + (_) => FakeResponse.fail(400, 'AUTH_006'), + ); + await h.pump(tester); + await submitLogin(tester); + await submitCode(tester, '123456'); + await tester.enterText( + fieldInput(PasswordChangeTexts.nextLabel), + 'new-pw-1234', + ); + await tester.enterText( + fieldInput(PasswordChangeTexts.confirmLabel), + 'new-pw-1234', + ); + await tester.enterText( + fieldInput(PasswordChangeTexts.codeLabel), + '654321', + ); + + await tapAndSettle(tester, button(PasswordChangeTexts.submit)); + + final Finder codeField = find.byWidgetPredicate( + (Widget w) => + w is LoginTextField && w.label == PasswordChangeTexts.codeLabel, + ); + expect( + find.descendant( + of: codeField, + matching: findCopy(PasswordChangeTexts.codeHint), + ), + findsOneWidget, + ); + expect( + find.descendant(of: codeField, matching: findCopy(message('AUTH_006'))), + findsOneWidget, + ); + expect( + tester.getSemantics(findCopy(message('AUTH_006'))), + isSemantics(label: message('AUTH_006'), isLiveRegion: true), + ); + expect( + tester.getSemantics(findCopy(PasswordChangeTexts.codeHint)), + isSemantics(label: PasswordChangeTexts.codeHint, isLiveRegion: false), + ); + final String hint = tester + .getSemantics(fieldEditable(PasswordChangeTexts.codeLabel)) + .hint; + expect(hint, contains(PasswordChangeTexts.codeHint)); + expect(hint, contains(message('AUTH_006'))); + expect(h.notifier.shownCodes, isEmpty); + semantics.dispose(); + }); + }); + + group('넷째 단계 — 2단계 인증 켜기', () { + Future openSetup(WidgetTester tester) async { + loginAnswers(restriction: 'twoFactorSetup'); + h.server.on( + 'POST', + ApiPaths.twoFactorEnrollments, + (_) => enrollmentResponse(), + ); + await h.pump(tester); + await submitLogin(tester); + expect(find.byType(TwoFactorSetupPage), findsOneWidget); + } + + testWidgets('보드의 글 — 키를 받아 4자씩 두 줄로 보이고, 저장될 이름을 보인다', ( + WidgetTester tester, + ) async { + await openSetup(tester); + + for (final String text in [ + ScreenTitles.twoFactorSetup, + TwoFactorSetupTexts.subtitle, + TwoFactorSetupTexts.addTitle, + TwoFactorSetupTexts.addHint, + TwoFactorSetupTexts.keyLabel, + TwoFactorSetupTexts.savedAs(kTestEmail), + TwoFactorSetupTexts.codeLabel, + TwoFactorSetupTexts.codeHint, + SupportLineTexts.authenticatorFallback, + TwoFactorSetupTexts.note, + ]) { + expect(findCopy(text), findsOneWidget, reason: text); + } + expect( + find.text('K7QF 2M4X HB6R TZ3N\nPW5C YL7D JQ2V MGAS'), + findsOneWidget, + ); + expect( + find.widgetWithText(OutlinedButton, TwoFactorSetupTexts.addButton), + findsOneWidget, + ); + // 같은 휴대폰이라 키를 띄우자마자 칸에 초점을 주지 않는다([인증 앱에 추가]가 자판에 가리지 않게) + expect( + tester + .widget(fieldInput(TwoFactorSetupTexts.codeLabel)) + .focusNode + ?.hasFocus, + isFalse, + ); + }); + + testWidgets('[인증 앱에 추가]는 otpauth 주소로 인증 앱을 연다 — 찾지 못하면 안내 글', ( + WidgetTester tester, + ) async { + await openSetup(tester); + + await tapAndSettle( + tester, + find.widgetWithText(OutlinedButton, TwoFactorSetupTexts.addButton), + ); + expect(h.launcher.opened, [kTestOtpauthUrl]); + expect(findCopy(TwoFactorSetupTexts.addFailed), findsNothing); + + h.launcher.opens = false; + await tapAndSettle( + tester, + find.widgetWithText(OutlinedButton, TwoFactorSetupTexts.addButton), + ); + expect(findCopy(TwoFactorSetupTexts.addFailed), findsOneWidget); + }); + + testWidgets('[복사]는 키를 띄어쓰기 없이 클립보드에 두고 알린다', (WidgetTester tester) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + await openSetup(tester); + final Finder copy = find.widgetWithText( + TextButton, + TwoFactorSetupTexts.copy, + ); + expect( + tester.getSemantics(copy), + isSemantics(label: TwoFactorSetupTexts.copyLabel, isButton: true), + ); + + await tapAndSettle(tester, copy); + + expect(h.clipboard, [kTestSecret]); + expect(find.text(TwoFactorSetupTexts.copied), findsOneWidget); + semantics.dispose(); + }); + + testWidgets('켜면 새 토큰을 받아 메일로 — 알림, 쥔 비밀번호로 보냈다', ( + WidgetTester tester, + ) async { + await openSetup(tester); + h.server.on( + 'POST', + ApiPaths.twoFactorActivation, + (_) => twoFactorStatusResponse(enabled: true), + ); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => refreshResponse(2, now: h.clock.now), + ); + + await tester.enterText( + fieldInput(TwoFactorSetupTexts.codeLabel), + '123456', + ); + await tapAndSettle(tester, button(TwoFactorSetupTexts.submit)); + + expect( + bodyOf(h.server.received('POST', ApiPaths.twoFactorActivation).single), + { + 'enrollmentId': 'enroll-1', + 'otpCode': '123456', + 'currentPassword': 'temp-pw-1', + }, + ); + expect(find.byType(NavigationBar), findsOneWidget); + expect(find.text(TwoFactorSetupTexts.done), findsOneWidget); + }); + + testWidgets('AUTH_006 — 코드 칸 아래 · 칸을 비운다, 키는 그대로(다시 받지 않는다)', ( + WidgetTester tester, + ) async { + await openSetup(tester); + h.server.on( + 'POST', + ApiPaths.twoFactorActivation, + (_) => FakeResponse.fail(400, 'AUTH_006'), + ); + + await tester.enterText( + fieldInput(TwoFactorSetupTexts.codeLabel), + '000000', + ); + await tapAndSettle(tester, button(TwoFactorSetupTexts.submit)); + + expect(findCopy(message('AUTH_006')), findsOneWidget); + expect(fieldText(tester, TwoFactorSetupTexts.codeLabel), isEmpty); + expect( + h.server.received('POST', ApiPaths.twoFactorEnrollments), + hasLength(1), + ); + expect(h.notifier.shownCodes, isEmpty); + }); + + testWidgets('AUTH_008(시간 지남 · 그사이 바뀜) — 위쪽 상자 + 새 키', ( + WidgetTester tester, + ) async { + await openSetup(tester); + h.server.on( + 'POST', + ApiPaths.twoFactorActivation, + (_) => FakeResponse.fail(409, 'AUTH_008'), + ); + h.server.on( + 'GET', + ApiPaths.twoFactor, + (_) => twoFactorStatusResponse(enabled: false), + ); + h.server.on( + 'POST', + ApiPaths.twoFactorEnrollments, + (_) => enrollmentResponse(id: 'enroll-2'), + ); + + await tester.enterText( + fieldInput(TwoFactorSetupTexts.codeLabel), + '123456', + ); + await tapAndSettle(tester, button(TwoFactorSetupTexts.submit)); + + expect(findCopy(message('AUTH_008')), findsOneWidget); + expect( + h.server.received('POST', ApiPaths.twoFactorEnrollments), + hasLength(2), + ); + expect(fieldText(tester, TwoFactorSetupTexts.codeLabel), isEmpty); + + // 새 등록으로 켠다 + h.server.on( + 'POST', + ApiPaths.twoFactorActivation, + (_) => twoFactorStatusResponse(enabled: true), + ); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => refreshResponse(2, now: h.clock.now), + ); + await tester.enterText( + fieldInput(TwoFactorSetupTexts.codeLabel), + '123456', + ); + await tapAndSettle(tester, button(TwoFactorSetupTexts.submit)); + expect( + bodyOf(h.server.received('POST', ApiPaths.twoFactorActivation).last), + containsPair('enrollmentId', 'enroll-2'), + ); + }); + + testWidgets('AUTH_008 인데 그사이 다른 곳에서 켰으면 이 단계를 건너뛴다', ( + WidgetTester tester, + ) async { + await openSetup(tester); + h.server.on( + 'POST', + ApiPaths.twoFactorActivation, + (_) => FakeResponse.fail(409, 'AUTH_008'), + ); + h.server.on( + 'GET', + ApiPaths.twoFactor, + (_) => twoFactorStatusResponse(enabled: true), + ); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => refreshResponse(2, now: h.clock.now), + ); + + await tester.enterText( + fieldInput(TwoFactorSetupTexts.codeLabel), + '123456', + ); + await tapAndSettle(tester, button(TwoFactorSetupTexts.submit)); + + expect(find.byType(NavigationBar), findsOneWidget); + }); + + testWidgets('켰지만 넘기지 못하면 위쪽 상자와 [다시 시도]만 — 키 · 칸 · 지금 비밀번호는 다시 보이지 않는다', ( + WidgetTester tester, + ) async { + await openSetup(tester); + h.server.on( + 'POST', + ApiPaths.twoFactorActivation, + (_) => twoFactorStatusResponse(enabled: true), + ); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.fail(503, 'MAIL_001'), + ); + h.server.on('GET', ApiPaths.me, (_) => FakeResponse.offline); + + await tester.enterText( + fieldInput(TwoFactorSetupTexts.codeLabel), + '123456', + ); + await tapAndSettle(tester, button(TwoFactorSetupTexts.submit)); + + expect(findCopy(message('MAIL_001')), findsOneWidget); + expect(findCopy(TwoFactorSetupTexts.addTitle), findsNothing); + expect(findCopy(TwoFactorSetupTexts.passwordLabel), findsNothing); + expect(button(TwoFactorSetupTexts.submit), findsNothing); + + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => refreshResponse(2, now: h.clock.now), + ); + await tapAndSettle( + tester, + find.widgetWithText(OutlinedButton, RestrictedStepTexts.retry), + ); + expect(find.byType(NavigationBar), findsOneWidget); + expect(find.text(TwoFactorSetupTexts.done), findsOneWidget); + expect( + h.server.received('POST', ApiPaths.twoFactorActivation), + hasLength(1), + ); + }); + + testWidgets('키를 받지 못하면 사전 문구 + [다시 시도] — 켜기는 누를 수 없다', ( + WidgetTester tester, + ) async { + loginAnswers(restriction: 'twoFactorSetup'); + h.server.on( + 'POST', + ApiPaths.twoFactorEnrollments, + (_) => FakeResponse.fail(503, 'MAIL_001'), + ); + await h.pump(tester); + await submitLogin(tester); + + expect(findCopy(message('MAIL_001')), findsOneWidget); + expect( + tester + .widget(button(TwoFactorSetupTexts.submit)) + .onPressed, + isNull, + ); + // 키 자리의 실패는 화면이 보인다 — 알리지 않는다 + expect(h.notifier.shownCodes, isEmpty); + + h.server.on( + 'POST', + ApiPaths.twoFactorEnrollments, + (_) => enrollmentResponse(), + ); + await tapAndSettle( + tester, + find.widgetWithText(OutlinedButton, TwoFactorSetupTexts.retry), + ); + expect( + find.text('K7QF 2M4X HB6R TZ3N\nPW5C YL7D JQ2V MGAS'), + findsOneWidget, + ); + }); + + testWidgets('쥔 비밀번호가 없으면(앱을 다시 열었다) ③ 지금 비밀번호 — 비우고 보내면 안내 글이 빨강', ( + WidgetTester tester, + ) async { + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => + refreshResponse(2, now: h.clock.now, restriction: 'twoFactorSetup'), + ); + h.server.on( + 'GET', + ApiPaths.me, + (_) => FakeResponse.ok(sessionJson(restriction: 'twoFactorSetup')), + ); + h.server.on( + 'POST', + ApiPaths.twoFactorEnrollments, + (_) => enrollmentResponse(), + ); + await h.pump(tester, refreshToken: 'refresh-1'); + expect(findCopy(TwoFactorSetupTexts.passwordLabel), findsOneWidget); + + await tester.enterText( + fieldInput(TwoFactorSetupTexts.codeLabel), + '123456', + ); + await tapAndSettle(tester, button(TwoFactorSetupTexts.submit)); + + expect(h.server.received('POST', ApiPaths.twoFactorActivation), isEmpty); + final Text hint = tester.widget( + findCopy(TwoFactorSetupTexts.passwordHint), + ); + expect( + hint.style?.color, + Theme.of( + tester.element(findCopy(TwoFactorSetupTexts.passwordHint)), + ).colorScheme.error, + ); + }); + + testWidgets('REQ_001(시도 과다) — 위쪽 상자, 코드 · 키는 그대로 · 알리지 않고 다음에 보낼 때 내린다', ( + WidgetTester tester, + ) async { + await openSetup(tester); + Completer? release; + h.server.on('POST', ApiPaths.twoFactorActivation, (_) async { + final Completer? waiting = release; + if (waiting == null) return FakeResponse.fail(429, 'REQ_001'); + await waiting.future; + return twoFactorStatusResponse(enabled: true); + }); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => refreshResponse(2, now: h.clock.now), + ); + await tester.enterText( + fieldInput(TwoFactorSetupTexts.codeLabel), + '123456', + ); + + await tapAndSettle(tester, button(TwoFactorSetupTexts.submit)); + + expect(alertWith(message('REQ_001')), findsOneWidget); + expect(fieldText(tester, TwoFactorSetupTexts.codeLabel), '123456'); + expect( + find.text('K7QF 2M4X HB6R TZ3N\nPW5C YL7D JQ2V MGAS'), + findsOneWidget, + ); + expect( + h.server.received('POST', ApiPaths.twoFactorEnrollments), + hasLength(1), + ); + expect(h.notifier.shownCodes, isEmpty); + + release = Completer(); + await tester.tap(button(TwoFactorSetupTexts.submit)); + await tester.pump(Duration.zero); + expect( + h.server.received('POST', ApiPaths.twoFactorActivation), + hasLength(2), + ); + expect(alertWith(message('REQ_001')), findsNothing); + + release.complete(); + await tester.pumpAndSettle(); + expect(find.byType(NavigationBar), findsOneWidget); + }); + + testWidgets('서버의 VAL_001 은 전역 알림 — 맞는 코드는 지우지 않는다', ( + WidgetTester tester, + ) async { + await openSetup(tester); + h.server.on( + 'POST', + ApiPaths.twoFactorActivation, + (_) => FakeResponse.fail(400, 'VAL_001'), + ); + await tester.enterText( + fieldInput(TwoFactorSetupTexts.codeLabel), + '123456', + ); + + await tapAndSettle(tester, button(TwoFactorSetupTexts.submit)); + + expect(h.notifier.shownCodes, ['VAL_001']); + expect(fieldText(tester, TwoFactorSetupTexts.codeLabel), '123456'); + expect(findCopy(message('VAL_001')), findsNothing); + expect(find.byType(TwoFactorSetupPage), findsOneWidget); + }); + + testWidgets('켜는 동안 [다른 계정으로 로그인]은 누를 수 없다 — 끝나면 다시 누를 수 있다', ( + WidgetTester tester, + ) async { + await openSetup(tester); + final Completer release = Completer(); + h.server.on('POST', ApiPaths.twoFactorActivation, (_) async { + await release.future; + return FakeResponse.fail(400, 'AUTH_006'); + }); + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + expect(otherAccountEnabled(tester), isTrue); + await tester.enterText( + fieldInput(TwoFactorSetupTexts.codeLabel), + '123456', + ); + + await tester.tap(button(TwoFactorSetupTexts.submit)); + await tester.pump(Duration.zero); + expect( + h.server.received('POST', ApiPaths.twoFactorActivation), + hasLength(1), + ); + expect(otherAccountEnabled(tester), isFalse); + await tester.tap(otherAccount()); + await tester.pump(); + + release.complete(); + await tester.pumpAndSettle(); + expect(find.byType(TwoFactorSetupPage), findsOneWidget); + expect(h.server.received('POST', ApiPaths.appLogout), isEmpty); + expect(findCopy(message('AUTH_006')), findsOneWidget); + expect(otherAccountEnabled(tester), isTrue); + }); + + testWidgets('[복사]가 실패하면 안내 글로 알린다 — 키를 보고 직접 넣을 수 있다', ( + WidgetTester tester, + ) async { + await openSetup(tester); + h.clipboardFails = true; + + await tapAndSettle( + tester, + find.widgetWithText(TextButton, TwoFactorSetupTexts.copy), + ); + + expect(h.clipboard, isEmpty); + expect(find.text(TwoFactorSetupTexts.copyFailed), findsOneWidget); + expect(find.text(TwoFactorSetupTexts.copied), findsNothing); + }); + + testWidgets('키를 받을 때 AUTH_008 인데 그사이 다른 곳에서 켰으면 이 단계를 건너뛴다', ( + WidgetTester tester, + ) async { + loginAnswers(restriction: 'twoFactorSetup'); + h.server.on( + 'POST', + ApiPaths.twoFactorEnrollments, + (_) => FakeResponse.fail(409, 'AUTH_008'), + ); + h.server.on( + 'GET', + ApiPaths.twoFactor, + (_) => twoFactorStatusResponse(enabled: true), + ); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => refreshResponse(2, now: h.clock.now), + ); + await h.pump(tester); + + await submitLogin(tester); + + expect(find.byType(NavigationBar), findsOneWidget); + expect(h.server.received('POST', ApiPaths.twoFactorActivation), isEmpty); + expect(h.notifier.shownCodes, isEmpty); + }); + + testWidgets('켜는 사이 다른 곳에서 켜 이 세션이 끊겼으면(401 · 갱신 401) 로그인 화면 + AUTH_002', ( + WidgetTester tester, + ) async { + await openSetup(tester); + h.server.on( + 'POST', + ApiPaths.twoFactorActivation, + (_) => FakeResponse.fail(401, 'AUTH_001'), + ); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.fail(401, 'AUTH_001'), + ); + await tester.enterText( + fieldInput(TwoFactorSetupTexts.codeLabel), + '123456', + ); + + await tapAndSettle(tester, button(TwoFactorSetupTexts.submit)); + + expect(find.byType(LoginPage), findsOneWidget); + expect(alertWith(message('AUTH_002')), findsOneWidget); + expect(h.notifier.shownCodes, isEmpty); + expect(find.byType(SnackBar), findsNothing); + }); + + testWidgets( + 'AUTH_008 뒤 상태를 묻는 사이 세션이 끊겼으면(다른 곳에서 켬 — 401 · 갱신 401) 로그인 화면 + AUTH_002', + (WidgetTester tester) async { + await openSetup(tester); + h.server.on( + 'POST', + ApiPaths.twoFactorActivation, + (_) => FakeResponse.fail(409, 'AUTH_008'), + ); + h.server.on( + 'GET', + ApiPaths.twoFactor, + (_) => FakeResponse.fail(401, 'AUTH_001'), + ); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.fail(401, 'AUTH_001'), + ); + await tester.enterText( + fieldInput(TwoFactorSetupTexts.codeLabel), + '123456', + ); + + await tapAndSettle(tester, button(TwoFactorSetupTexts.submit)); + + expect(find.byType(LoginPage), findsOneWidget); + expect(alertWith(message('AUTH_002')), findsOneWidget); + // 끊긴 세션으로 새 키를 받으러 가지 않았다 + expect( + h.server.received('POST', ApiPaths.twoFactorEnrollments), + hasLength(1), + ); + expect(h.notifier.shownCodes, isEmpty); + }, + ); + }); + + group('모르는 제한 · 화면 낭독기 · 비밀', () { + testWidgets('모르는 제한 — AUTH_010 사전 문구와 [다른 계정으로 로그인]만', ( + WidgetTester tester, + ) async { + loginAnswers(restriction: 'somethingNew'); + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + await h.pump(tester); + + await submitLogin(tester); + + expect(find.byType(RestrictedLoginPage), findsOneWidget); + expect(findCopy(message('AUTH_010')), findsOneWidget); + expect(find.text(kTestEmail), findsOneWidget); + + await tapAndSettle(tester, otherAccount()); + expect(find.byType(LoginPage), findsOneWidget); + expect(h.server.received('POST', ApiPaths.appLogout), hasLength(1)); + }); + + testWidgets('단계의 제목은 머리글이자 화면 이름 — 단계가 바뀌면 낭독기가 새 제목을 읽는다', ( + WidgetTester tester, + ) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + loginAnswers(twoFactor: true, restriction: 'passwordChange'); + await h.pump(tester); + expect( + tester.getSemantics(heading(ScreenTitles.login)), + isSemantics( + label: ScreenTitles.login, + isHeader: true, + namesRoute: true, + ), + ); + + await submitLogin(tester); + expect( + tester.getSemantics(heading(ScreenTitles.loginCode)), + isSemantics( + label: ScreenTitles.loginCode, + isHeader: true, + namesRoute: true, + ), + ); + + await submitCode(tester, '123456'); + expect( + tester.getSemantics(heading(ScreenTitles.passwordChange)), + isSemantics( + label: ScreenTitles.passwordChange, + isHeader: true, + namesRoute: true, + ), + ); + semantics.dispose(); + }); + + testWidgets('칸 이름은 칸에 붙어 읽히고, 칸 아래 오류는 나타날 때 읽힌다(live region)', ( + WidgetTester tester, + ) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + loginAnswers(twoFactor: true); + await h.pump(tester); + expect( + tester + .getSemantics( + find.descendant( + of: fieldInput(LoginTexts.emailLabel), + matching: find.byType(EditableText), + ), + ) + .label, + startsWith(LoginTexts.emailLabel), + ); + + await submitLogin(tester); + await submitCode(tester, '12'); + + expect( + tester.getSemantics(findCopy(message('AUTH_007'))), + isSemantics(label: message('AUTH_007'), isLiveRegion: true), + ); + // 칸에 다시 와도 오류가 함께 읽힌다(칸의 설명 — 웹 aria-describedby) + expect( + tester.getSemantics(fieldEditable(LoginCodeTexts.codeLabel)).hint, + contains(message('AUTH_007')), + ); + semantics.dispose(); + }); + + testWidgets('비밀(비밀번호 · 코드 · 키 · otpauth 주소)은 어떤 프로바이더 상태에도 두지 않는다', ( + WidgetTester tester, + ) async { + final _ValueRecorder recorder = _ValueRecorder(); + loginAnswers(twoFactor: true, restriction: 'passwordChange'); + h.server.on('PUT', ApiPaths.password, (_) => FakeResponse.ok()); + int refreshes = 0; + h.server.on('POST', ApiPaths.appRefresh, (_) { + refreshes++; + return refreshResponse( + refreshes + 1, + now: h.clock.now, + restriction: refreshes == 1 ? 'twoFactorSetup' : null, + ); + }); + h.server.on( + 'POST', + ApiPaths.twoFactorEnrollments, + (_) => enrollmentResponse(), + ); + h.server.on( + 'POST', + ApiPaths.twoFactorActivation, + (_) => twoFactorStatusResponse(enabled: true), + ); + await h.pump(tester, observers: [recorder]); + + await submitLogin(tester); + await submitCode(tester, '123456'); + await tester.enterText( + fieldInput(PasswordChangeTexts.nextLabel), + 'new-pw-1234', + ); + await tester.enterText( + fieldInput(PasswordChangeTexts.confirmLabel), + 'new-pw-1234', + ); + await tester.enterText( + fieldInput(PasswordChangeTexts.codeLabel), + '654321', + ); + await tapAndSettle(tester, button(PasswordChangeTexts.submit)); + await tapAndSettle( + tester, + find.widgetWithText(TextButton, TwoFactorSetupTexts.copy), + ); + await tester.enterText( + fieldInput(TwoFactorSetupTexts.codeLabel), + '112233', + ); + await tapAndSettle(tester, button(TwoFactorSetupTexts.submit)); + expect(find.byType(NavigationBar), findsOneWidget); + + expect(recorder.records, isNotEmpty); + for (final ({String provider, Object? value}) record + in recorder.records) { + final Object? recorded = record.value; + // 이름으로도 본다 — AsyncData · List 처럼 감싸여도 잡는다 + expect( + recorded.runtimeType.toString(), + isNot(contains('TwoFactorEnrollment')), + reason: record.provider, + ); + // AsyncValue 는 속의 값 · 오류를 꺼내 본다(비밀이 AsyncData 안에 숨지 않게) + final Object? value = recorded is AsyncValue + ? recorded.value + : recorded; + final Object? error = recorded is AsyncValue + ? recorded.error + : null; + expect( + value.runtimeType.toString(), + isNot(contains('TwoFactorEnrollment')), + reason: record.provider, + ); + expect( + _allowedProviderValue(value), + isTrue, + reason: '${record.provider} → ${value.runtimeType}', + ); + for (final Object? shown in [value, error]) { + final String text = shown.toString(); + for (final String secret in [ + 'temp-pw-1', + 'new-pw-1234', + '123456', + '654321', + '112233', + kTestSecret, + 'otpauth', + ]) { + expect(text, isNot(contains(secret)), reason: record.provider); + } + } + } + }); + }); +} + +/// 프로바이더에 들어가도 되는 값의 모양 — 지금 앱의 프로바이더 값 전부. 새 모양이 생기면 이 시험이 알려 준다 +/// (비밀 — 비밀번호 · 코드 · 2단계 키 · otpauth 주소 — 을 담을 수 있는 모양인지 사람이 보고 더한다) +bool _allowedProviderValue(Object? value) => + value == null || + // 문의 연락처 · User-Agent · 앱 판 — 글은 위에서 비밀이 없는지 본다 + value is String || + value is SessionState || + // 시계 + value is Clock || + value is ApiConfig || + value is SessionEventBus || + value is GlobalKey || + value is ErrorNotifier || + value is HttpClientAdapter || + value is DioClient || + value is Dio || + value is TokenRefresher || + value is TokenStore || + value is PushRegistrar || + value is List || + value is GoRouter || + value is AuthRepository || + value is TwoFactorRepository || + value is SupportContactRepository || + value is AuthenticatorLauncher; + +/// 프로바이더 값 · 실패를 모두 적어 둔다 — 비밀이 상태에 들어가지 않는지 본다 +final class _ValueRecorder extends ProviderObserver { + final List<({String provider, Object? value})> records = + <({String provider, Object? value})>[]; + + void _add(ProviderObserverContext context, Object? value) { + records.add((provider: '${context.provider}', value: value)); + } + + @override + void didAddProvider(ProviderObserverContext context, Object? value) { + _add(context, value); + } + + @override + void didUpdateProvider( + ProviderObserverContext context, + Object? previousValue, + Object? newValue, + ) { + _add(context, newValue); + } + + @override + void providerDidFail( + ProviderObserverContext context, + Object error, + StackTrace stackTrace, + ) { + _add(context, AsyncError(error, stackTrace)); + } +} diff --git a/flutter/test/features/auth/login_secret_test.dart b/flutter/test/features/auth/login_secret_test.dart new file mode 100644 index 0000000..e71e51b --- /dev/null +++ b/flutter/test/features/auth/login_secret_test.dart @@ -0,0 +1,47 @@ +import 'package:arasystem_app/features/auth/presentation/providers/login_secret.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/fakes.dart'; + +void main() { + group('로그인 때 넣은 비밀번호 쥐기', () { + test('쥐고 있는지를 알린다 — 값(비밀번호)이 아니라 참 · 거짓만', () { + final ManualClock clock = ManualClock(); + final LoginSecret secret = LoginSecret(clock: clock.call); + final List seen = []; + secret.held.addListener(() => seen.add(secret.held.value)); + + secret.hold('ara.kim@arasystem.it.kr', 'temp-pw'); + secret.clear(); + + expect(seen, [true, false]); + expect(secret.read(), isNull); + }); + + test('10분이 지나면 읽을 때 시계로 다시 보고 지운다 — 쥐고 있지 않다고 알린다', () { + final ManualClock clock = ManualClock(); + final LoginSecret secret = LoginSecret(clock: clock.call); + secret.hold('ara.kim@arasystem.it.kr', 'temp-pw'); + + clock.advance(const Duration(minutes: 9, seconds: 59)); + expect(secret.read()?.password, 'temp-pw'); + + clock.advance(const Duration(seconds: 1)); + expect(secret.read(), isNull); + expect(secret.held.value, isFalse); + expect(secret.isHeld, isFalse); + }); + + test('서버가 다시 확인하면(renew) 10분을 그때부터 다시 센다', () { + final ManualClock clock = ManualClock(); + final LoginSecret secret = LoginSecret(clock: clock.call); + secret.hold('ara.kim@arasystem.it.kr', 'temp-pw'); + + clock.advance(const Duration(minutes: 8)); + secret.renew(); + clock.advance(const Duration(minutes: 8)); + + expect(secret.read()?.password, 'temp-pw'); + }); + }); +} diff --git a/flutter/test/features/auth/restricted_steps_test.dart b/flutter/test/features/auth/restricted_steps_test.dart new file mode 100644 index 0000000..8e39e66 --- /dev/null +++ b/flutter/test/features/auth/restricted_steps_test.dart @@ -0,0 +1,452 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/features/auth/data/models/auth_session_info.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/fakes.dart'; +import '../../support/login_fakes.dart'; +import '../../support/provider_harness.dart'; + +/// 세션 노티파이어의 코드 단계 · 제한 단계(새 비밀번호 · 2단계 켜기 · 다음으로 넘기기) — 계약 5 · 6절 +void main() { + late ProviderHarness h; + late ProviderContainer container; + + Future start() async { + await h.prepare(); + container = h.container(); + addTearDown(container.dispose); + container.listen(sessionProvider, (_, _) {}); + await container.read(sessionProvider.future); + } + + SessionNotifier notifier() => container.read(sessionProvider.notifier); + + SessionState? current() => container.read(sessionProvider).value; + + SessionRestriction? restrictionNow() { + final SessionState? state = current(); + return state is SignedIn ? state.info.restriction : null; + } + + Map bodyOf(RequestOptions options) => + options.data! as Map; + + /// 제한된 세션으로 로그인한다 — 코드 단계를 거치면 [viaCode] + Future signInRestricted( + String restriction, { + bool viaCode = false, + }) async { + h.server.on('POST', ApiPaths.appLogin, (RequestOptions options) { + if (viaCode && bodyOf(options)['otpCode'] == null) { + return codeStepResponse(); + } + return signedInResponse(1, now: h.clock.now, restriction: restriction); + }); + await notifier().signIn(email: kTestEmail, password: 'temp-pw-1'); + if (viaCode) await notifier().submitCode('123456'); + expect(current(), isA()); + } + + setUp(() => h = ProviderHarness()); + + group('코드 단계', () { + test('코드를 실었는데도 코드를 묻으면 AUTH_006 으로 던지고 코드 단계에 남는다', () async { + await start(); + h.server.on('POST', ApiPaths.appLogin, (_) => codeStepResponse()); + await notifier().signIn(email: kTestEmail, password: 'pw'); + + final Object error = await errorOf(notifier().submitCode('123456')); + + expect(apiErrorOf(error).code, 'AUTH_006'); + expect(current(), const CodeRequired(kTestEmail)); + expect(notifier().heldLogin?.password, 'pw'); + }); + + test('그사이 비밀번호가 바뀌었으면(AUTH_004) 주소를 남긴 첫 단계로 돌아간다', () async { + await start(); + h.server.on('POST', ApiPaths.appLogin, (RequestOptions options) { + return bodyOf(options)['otpCode'] == null + ? codeStepResponse() + : FakeResponse.fail(401, 'AUTH_004'); + }); + await notifier().signIn(email: kTestEmail, password: 'pw'); + + final Object error = await errorOf(notifier().submitCode('123456')); + + expect(apiErrorOf(error).code, 'AUTH_004'); + expect( + current(), + const SignedOut( + reason: SignOutReason.passwordRejected, + email: kTestEmail, + ), + ); + expect(notifier().heldLogin, isNull); + // 칸에 보이는 실패라 알리지 않는다 + expect(h.notifier.shownCodes, isEmpty); + }); + + test('코드 단계를 거쳐 로그인했는지 기억한다 — 새 로그인 · 다른 계정이면 지운다', () async { + await start(); + await signInRestricted('passwordChange', viaCode: true); + expect(notifier().passedCodeStep, isTrue); + + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + await notifier().switchAccount(); + expect(notifier().passedCodeStep, isFalse); + + await signInRestricted('passwordChange'); + expect(notifier().passedCodeStep, isFalse); + }); + }); + + group('새 비밀번호 단계 (changePassword)', () { + test('쥔 임시 비밀번호를 지금 비밀번호로 보내고, 바뀌면 새 비밀번호를 쥔다', () async { + await start(); + await signInRestricted('passwordChange', viaCode: true); + h.server.on('PUT', ApiPaths.password, (_) => FakeResponse.ok()); + + final RestrictedStepResult result = await notifier().changePassword( + newPassword: 'new-pw-1234', + otpCode: '654321', + ); + + expect(result, RestrictedStepResult.done); + final RequestOptions sent = h.server + .received('PUT', ApiPaths.password) + .single; + expect(bodyOf(sent), { + 'currentPassword': 'temp-pw-1', + 'newPassword': 'new-pw-1234', + 'otpCode': '654321', + }); + expect(bearerTokenOf(sent), 'access-1'); + // 서버가 엔진을 두 번 부른다 — 받기 한도 30초 + expect(sent.receiveTimeout, const Duration(seconds: 30)); + // 다음 단계(2단계 켜기)의 지금 비밀번호 + expect(notifier().heldLogin?.password, 'new-pw-1234'); + expect(notifier().heldLoginListenable.value, isTrue); + }); + + test('쥔 비밀번호를 서버가 거절하면(AUTH_005) 지운다 — 실패 예외에는 본문이 없고 알리지 않는다', () async { + await start(); + await signInRestricted('passwordChange'); + h.server.on( + 'PUT', + ApiPaths.password, + (_) => FakeResponse.fail(400, 'AUTH_005'), + ); + + final Object error = await errorOf( + notifier().changePassword(newPassword: 'new-pw-1234'), + ); + + expect(apiErrorOf(error).code, 'AUTH_005'); + expect((error as DioException).requestOptions.data, isNull); + expect(notifier().heldLogin, isNull); + expect(notifier().heldLoginListenable.value, isFalse); + expect(h.notifier.shownCodes, isEmpty); + }); + + test('쥔 비밀번호가 없고 칸의 값도 주지 않으면 보내지 않는다(needsPassword)', () async { + await start(); + await signInRestricted('passwordChange'); + h.clock.advance(const Duration(minutes: 10)); + + final RestrictedStepResult result = await notifier().changePassword( + newPassword: 'new-pw-1234', + ); + + expect(result, RestrictedStepResult.needsPassword); + expect(h.server.received('PUT', ApiPaths.password), isEmpty); + }); + + test('칸에 넣은 임시 비밀번호로 보낼 수 있다(앱을 다시 열었다)', () async { + await start(); + await signInRestricted('passwordChange'); + h.clock.advance(const Duration(minutes: 10)); + h.server.on('PUT', ApiPaths.password, (_) => FakeResponse.ok()); + + final RestrictedStepResult result = await notifier().changePassword( + newPassword: 'new-pw-1234', + currentPassword: 'typed-temp', + ); + + expect(result, RestrictedStepResult.done); + expect( + bodyOf(h.server.received('PUT', ApiPaths.password).single), + { + 'currentPassword': 'typed-temp', + 'newPassword': 'new-pw-1234', + }, + ); + }); + + test('보내는 사이 [다른 계정으로 로그인]하면 결과를 쓰지 않는다(stale) — 새 비밀번호를 쥐지 않는다', () async { + await start(); + await signInRestricted('passwordChange'); + final Completer release = Completer(); + h.server.on('PUT', ApiPaths.password, (_) async { + await release.future; + return FakeResponse.ok(); + }); + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + + final Future changing = notifier().changePassword( + newPassword: 'new-pw-1234', + ); + await pumpEventsUntil( + () => h.server.received('PUT', ApiPaths.password).isNotEmpty, + reason: '비밀번호 바꾸기가 서버에 닿기', + ); + await notifier().switchAccount(); + release.complete(); + + expect(await changing, RestrictedStepResult.stale); + expect(notifier().heldLogin, isNull); + expect(current(), const SignedOut(reason: SignOutReason.switchAccount)); + }); + }); + + group('2단계 켜기 (enableTwoFactor)', () { + test('등록 id · 코드 · 쥔 비밀번호를 보내고, 켜면 쥔 비밀번호를 지운다', () async { + await start(); + await signInRestricted('twoFactorSetup'); + h.server.on( + 'POST', + ApiPaths.twoFactorActivation, + (_) => twoFactorStatusResponse(enabled: true), + ); + + final RestrictedStepResult result = await notifier().enableTwoFactor( + enrollmentId: 'enroll-1', + otpCode: '123456', + ); + + expect(result, RestrictedStepResult.done); + final RequestOptions sent = h.server + .received('POST', ApiPaths.twoFactorActivation) + .single; + expect(bodyOf(sent), { + 'enrollmentId': 'enroll-1', + 'otpCode': '123456', + 'currentPassword': 'temp-pw-1', + }); + expect(sent.receiveTimeout, const Duration(seconds: 30)); + expect(notifier().heldLogin, isNull); + }); + + test('쥔 비밀번호를 서버가 거절하면(AUTH_005) 지운다 — 화면이 지금 비밀번호 칸을 보인다', () async { + await start(); + await signInRestricted('twoFactorSetup'); + h.server.on( + 'POST', + ApiPaths.twoFactorActivation, + (_) => FakeResponse.fail(400, 'AUTH_005'), + ); + + final Object error = await errorOf( + notifier().enableTwoFactor(enrollmentId: 'enroll-1', otpCode: '123456'), + ); + + expect(apiErrorOf(error).code, 'AUTH_005'); + expect((error as DioException).requestOptions.data, isNull); + expect(notifier().heldLoginListenable.value, isFalse); + expect(h.notifier.shownCodes, isEmpty); + }); + + test('코드가 틀리면(AUTH_006) 쥔 비밀번호는 그대로다(코드만 다시 넣는다)', () async { + await start(); + await signInRestricted('twoFactorSetup'); + h.server.on( + 'POST', + ApiPaths.twoFactorActivation, + (_) => FakeResponse.fail(400, 'AUTH_006'), + ); + + await errorOf( + notifier().enableTwoFactor(enrollmentId: 'enroll-1', otpCode: '000000'), + ); + + expect(notifier().heldLogin?.password, 'temp-pw-1'); + }); + }); + + group('모르는 제한 — 로그인 때 넣은 비밀번호를 쥐지 않는다(웹 onSignedIn 과 같다)', () { + test('모르는 제한으로 로그인하면 쓸 단계가 없어 쥐지 않는다', () async { + await start(); + + await signInRestricted('somethingNew'); + + expect(restrictionNow(), SessionRestriction.unknown); + expect(notifier().heldLogin, isNull); + expect(notifier().heldLoginListenable.value, isFalse); + }); + + test('갱신 응답으로 제한이 모르는 제한이 되면 쥔 비밀번호를 지운다', () async { + await start(); + await signInRestricted('passwordChange'); + expect(notifier().heldLogin, isNotNull); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => + refreshResponse(2, now: h.clock.now, restriction: 'somethingNew'), + ); + + await container.read(authRepositoryProvider).refresh(); + await pumpEventsUntil( + () => restrictionNow() == SessionRestriction.unknown, + reason: '갱신 응답의 제한 반영', + ); + + expect(notifier().heldLogin, isNull); + expect(notifier().heldLoginListenable.value, isFalse); + }); + + test('다시 맞추기(AUTH_010)로 모르는 제한이 되면 쥔 비밀번호를 지운다', () async { + await start(); + await signInRestricted('twoFactorSetup'); + h.server.on( + 'GET', + '/mailboxes', + (_) => FakeResponse.fail(403, 'AUTH_010'), + ); + h.server.on( + 'GET', + ApiPaths.me, + (_) => FakeResponse.ok(sessionJson(restriction: 'somethingNew')), + ); + + await errorOf(container.read(dioProvider).get('/mailboxes')); + + expect(restrictionNow(), SessionRestriction.unknown); + expect(notifier().heldLogin, isNull); + }); + + test('제한이 새 비밀번호 → 2단계 켜기로 바뀌면 쥔 비밀번호는 그대로다(다음 단계가 쓴다)', () async { + await start(); + await signInRestricted('passwordChange'); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => + refreshResponse(2, now: h.clock.now, restriction: 'twoFactorSetup'), + ); + + await container.read(authRepositoryProvider).refresh(); + await pumpEventsUntil( + () => restrictionNow() == SessionRestriction.twoFactorSetup, + reason: '갱신 응답의 제한 반영', + ); + + expect(notifier().heldLogin?.password, 'temp-pw-1'); + }); + }); + + group('다음 단계로 넘기기 (finishRestrictedStep)', () { + test('새 토큰의 세션 칸으로 다음 제한(2단계 켜기)으로 옮긴다', () async { + await start(); + await signInRestricted('passwordChange'); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => + refreshResponse(2, now: h.clock.now, restriction: 'twoFactorSetup'), + ); + + expect( + await notifier().finishRestrictedStep(), + RestrictedStepResult.done, + ); + + expect(restrictionNow(), SessionRestriction.twoFactorSetup); + expect(h.store.accessToken, 'access-2'); + // 2단계 켜기가 지금 비밀번호로 쓴다 + expect(notifier().heldLogin, isNotNull); + expect(h.push.registered, 0); + }); + + test('제한이 풀리면 메일로 — 쥔 비밀번호를 지우고 알림 기기를 등록한다', () async { + await start(); + await signInRestricted('twoFactorSetup'); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => refreshResponse(2, now: h.clock.now), + ); + + expect( + await notifier().finishRestrictedStep(), + RestrictedStepResult.done, + ); + + expect(current(), isA()); + expect(restrictionNow(), isNull); + expect(notifier().heldLogin, isNull); + expect(h.push.registered, 1); + }); + + test('갱신이 장애면 세션 확인(me)의 지금 값으로 정한다', () async { + await start(); + await signInRestricted('twoFactorSetup'); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.fail(503, 'MAIL_001'), + ); + h.server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson())); + + expect( + await notifier().finishRestrictedStep(), + RestrictedStepResult.done, + ); + + expect(restrictionNow(), isNull); + expect(current(), isA()); + expect(h.notifier.shownCodes, isEmpty); + }); + + test('갱신도 세션 확인도 못 하면 갱신의 실패로 던지고 그 단계에 머문다', () async { + await start(); + await signInRestricted('twoFactorSetup'); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.fail(503, 'MAIL_001'), + ); + h.server.on('GET', ApiPaths.me, (_) => FakeResponse.offline); + + final Object error = await errorOf(notifier().finishRestrictedStep()); + + expect(apiErrorOf(error).code, 'MAIL_001'); + expect(restrictionNow(), SessionRestriction.twoFactorSetup); + // 화면이 위쪽 상자에 보인다 — 알리지 않는다 + expect(h.notifier.shownCodes, isEmpty); + }); + + test('갱신이 세션을 끝냈으면(401) stale — 로그인 화면으로 옮긴다', () async { + await start(); + await signInRestricted('twoFactorSetup'); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.fail(401, 'AUTH_001'), + ); + + expect( + await notifier().finishRestrictedStep(), + RestrictedStepResult.stale, + ); + await pumpEventsUntil(() => current() is SignedOut, reason: '세션 끝'); + expect(current(), const SignedOut(reason: SignOutReason.expired)); + }); + }); +} diff --git a/flutter/test/features/auth/session_provider_test.dart b/flutter/test/features/auth/session_provider_test.dart index 9f0a25f..4e7f5f8 100644 --- a/flutter/test/features/auth/session_provider_test.dart +++ b/flutter/test/features/auth/session_provider_test.dart @@ -180,7 +180,14 @@ void main() { h.clock.advance(const Duration(minutes: 10)); await notifier().submitCode('123456'); - expect(current(), const SignedOut(reason: SignOutReason.codeTimedOut)); + // 첫 단계가 주소를 남기고 위쪽 상자에 까닭을 보인다 + expect( + current(), + const SignedOut( + reason: SignOutReason.codeTimedOut, + email: 'ara.kim@arasystem.it.kr', + ), + ); expect(h.server.received('POST', ApiPaths.appLogin), hasLength(1)); }); @@ -350,7 +357,8 @@ void main() { await pumpEventsUntil(() => current() is SignedOut, reason: '세션 끝 알림'); expect(current(), const SignedOut(reason: SignOutReason.expired)); - expect(h.notifier.shownCodes, ['AUTH_002']); + // 로그인 화면의 위쪽 상자가 알린다 — 스낵바는 띄우지 않는다 + expect(h.notifier.shownCodes, isEmpty); }); test( diff --git a/flutter/test/features/auth/two_factor_parts_test.dart b/flutter/test/features/auth/two_factor_parts_test.dart new file mode 100644 index 0000000..5668382 --- /dev/null +++ b/flutter/test/features/auth/two_factor_parts_test.dart @@ -0,0 +1,159 @@ +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/features/auth/data/authenticator_launcher.dart'; +import 'package:arasystem_app/features/auth/data/models/two_factor_enrollment.dart'; +import 'package:arasystem_app/features/auth/data/models/two_factor_status.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../support/fakes.dart'; +import '../../support/login_fakes.dart'; +import '../../support/provider_harness.dart'; + +void main() { + group('2단계 등록 모델', () { + test('응답을 읽고, toString 은 키 · otpauth 주소를 가린다', () { + final TwoFactorEnrollment? enrollment = + TwoFactorEnrollment.tryParse({ + 'enrollmentId': 'enroll-1', + 'secret': kTestSecret, + 'otpauthUrl': kTestOtpauthUrl, + 'expiresAt': '2026-11-05T03:10:00.000Z', + }); + + expect(enrollment?.enrollmentId, 'enroll-1'); + expect(enrollment?.secret, kTestSecret); + expect(enrollment?.expiresAt, DateTime.utc(2026, 11, 5, 3, 10)); + expect(enrollment.toString(), isNot(contains(kTestSecret))); + expect(enrollment.toString(), isNot(contains('otpauth'))); + }); + + test('칸이 비었거나 otpauth 주소가 아니면 읽지 않는다', () { + expect(TwoFactorEnrollment.tryParse(null), isNull); + expect( + TwoFactorEnrollment.tryParse({ + 'enrollmentId': 'enroll-1', + 'secret': kTestSecret, + 'otpauthUrl': 'https://evil.example/otp', + }), + isNull, + ); + expect( + TwoFactorEnrollment.tryParse({ + 'enrollmentId': '', + 'secret': kTestSecret, + 'otpauthUrl': kTestOtpauthUrl, + }), + isNull, + ); + }); + + test('상태는 isEnabled 만 본다 — 없으면 읽지 않는다', () { + expect( + TwoFactorStatus.tryParse({ + 'isEnabled': true, + })?.isEnabled, + isTrue, + ); + expect(TwoFactorStatus.tryParse({}), isNull); + }); + }); + + group('[인증 앱에 추가]', () { + test('otpauth 주소를 브라우저가 아닌 앱으로 연다', () async { + final List<(Uri, LaunchMode)> calls = <(Uri, LaunchMode)>[]; + final AuthenticatorLauncher launcher = AuthenticatorLauncher( + launch: (Uri url, LaunchMode mode) async { + calls.add((url, mode)); + return true; + }, + ); + + expect(await launcher.open(kTestOtpauthUrl), isTrue); + expect(calls.single.$1.toString(), kTestOtpauthUrl); + expect(calls.single.$2, LaunchMode.externalNonBrowserApplication); + }); + + test('받을 앱이 없어 플러그인이 던지면(문장에 키가 든다) 삼키고 false', () async { + final AuthenticatorLauncher launcher = AuthenticatorLauncher( + launch: (Uri url, LaunchMode mode) async => throw PlatformException( + code: 'ACTIVITY_NOT_FOUND', + message: 'No Activity found to handle intent { $url }', + ), + ); + + expect(await launcher.open(kTestOtpauthUrl), isFalse); + }); + + test('otpauth 주소가 아니면 열지 않는다', () async { + int calls = 0; + final AuthenticatorLauncher launcher = AuthenticatorLauncher( + launch: (Uri url, LaunchMode mode) async { + calls++; + return true; + }, + ); + + expect(await launcher.open('https://evil.example/otp'), isFalse); + expect(calls, 0); + }); + }); + + group('문의 연락처', () { + test('한 번 받으면 다시 묻지 않고, 다듬은 한 줄을 둔다', () async { + final ProviderHarness h = ProviderHarness(); + await h.prepare(); + final ProviderContainer container = h.container(); + addTearDown(container.dispose); + h.server.on( + 'GET', + ApiPaths.supportContact, + (_) => FakeResponse.ok({ + 'text': ' IT 담당 홍길동\n02-000-0000 ', + }), + ); + final SupportContactNotifier notifier = container.read( + supportContactProvider.notifier, + ); + + await Future.wait(>[ + notifier.ensureLoaded(), + notifier.ensureLoaded(), + ]); + await notifier.ensureLoaded(); + + expect(container.read(supportContactProvider), 'IT 담당 홍길동 02-000-0000'); + expect(h.server.received('GET', ApiPaths.supportContact), hasLength(1)); + // 로그인 없이 부른다 — 토큰을 싣지 않는다 + expect( + bearerTokenOf(h.server.received('GET', ApiPaths.supportContact).single), + isNull, + ); + }); + + test('받지 못하면 알리지 않고 null — 다음에 다시 묻는다', () async { + final ProviderHarness h = ProviderHarness(); + await h.prepare(); + final ProviderContainer container = h.container(); + addTearDown(container.dispose); + h.server.on('GET', ApiPaths.supportContact, (_) => FakeResponse.offline); + final SupportContactNotifier notifier = container.read( + supportContactProvider.notifier, + ); + + await notifier.ensureLoaded(); + expect(container.read(supportContactProvider), isNull); + expect(h.notifier.shownCodes, isEmpty); + + h.server.on( + 'GET', + ApiPaths.supportContact, + (_) => FakeResponse.ok({'text': '사내 메신저 @it'}), + ); + await notifier.ensureLoaded(); + expect(container.read(supportContactProvider), '사내 메신저 @it'); + }); + }); +} diff --git a/flutter/test/support/login_fakes.dart b/flutter/test/support/login_fakes.dart new file mode 100644 index 0000000..d4ece5a --- /dev/null +++ b/flutter/test/support/login_fakes.dart @@ -0,0 +1,183 @@ +import 'package:arasystem_app/app.dart'; +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/utils/provider_retry.dart'; +import 'package:arasystem_app/features/auth/data/authenticator_launcher.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_fields.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'fakes.dart'; +import 'provider_harness.dart'; + +/// 시험 직원의 주소 +const String kTestEmail = 'ara.kim@arasystem.it.kr'; + +/// 시험 2단계 키(32자 base32)와 otpauth 주소 +const String kTestSecret = 'K7QF2M4XHB6RTZ3NPW5CYL7DJQ2VMGAS'; +const String kTestOtpauthUrl = + 'otpauth://totp/ARA%20SYSTEM:ara.kim%40arasystem.it.kr?secret=$kTestSecret&issuer=ARA%20SYSTEM&algorithm=SHA1&digits=6&period=30'; + +/// 코드 단계로 답하는 로그인 (계약 5절 — `email` · `name` 은 null, 토큰 없음) +FakeResponse codeStepResponse() => FakeResponse.ok({ + 'status': 'twoFactorRequired', + 'email': null, + 'name': null, + 'restriction': null, + 'twoFactorDeadline': null, + 'tokens': null, +}); + +/// 로그인을 마친 응답 ([n] 번째 토큰) +FakeResponse signedInResponse( + int n, { + required DateTime now, + String? restriction, +}) => FakeResponse.ok({ + 'status': 'signedIn', + ...sessionJson(restriction: restriction), + 'tokens': tokensJson(n, now: now), +}); + +/// 갱신 응답 ([n] 번째 토큰 · 세션 칸) +FakeResponse refreshResponse( + int n, { + required DateTime now, + String? restriction, +}) => FakeResponse.ok({ + ...sessionJson(restriction: restriction), + 'tokens': tokensJson(n, now: now), +}); + +/// 2단계 등록 응답 ([id] 번째 등록 — 키는 같다) +FakeResponse enrollmentResponse({String id = 'enroll-1'}) => + FakeResponse(201, { + 'success': true, + 'data': { + 'enrollmentId': id, + 'secret': kTestSecret, + 'otpauthUrl': kTestOtpauthUrl, + 'expiresAt': '2026-11-05T03:10:00.000Z', + }, + }); + +/// 2단계 상태 응답 +FakeResponse twoFactorStatusResponse({required bool enabled}) => + FakeResponse.ok({ + 'isEnabled': enabled, + 'enabledAt': null, + 'isRequired': true, + 'deadline': null, + }); + +/// 보이는 글로 찾는다 — 낱말 가운데서 줄을 바꾸지 않는 글(`KeepAllText`)은 원래 글(화면 낭독기 글)로 찾는다 +Finder findCopy(String text) => find.byWidgetPredicate( + (Widget widget) => + widget is Text && (widget.data == text || widget.semanticsLabel == text), +); + +/// 칸 이름으로 그 칸의 입력을 찾는다 +Finder fieldInput(String label) => find.descendant( + of: find.byWidgetPredicate( + (Widget widget) => widget is LoginTextField && widget.label == label, + ), + matching: find.byType(TextField), +); + +/// 칸 이름으로 그 칸의 컨트롤러 글을 읽는다 +String fieldText(WidgetTester tester, String label) => + tester.widget(fieldInput(label)).controller!.text; + +/// [인증 앱에 추가]를 적어 두는 가짜 — [opens] 가 false 면 받을 앱이 없는 것처럼 답한다 +class FakeAuthenticatorLauncher extends AuthenticatorLauncher { + FakeAuthenticatorLauncher() : super(launch: _never); + + bool opens = true; + final List opened = []; + + static Future _never(Uri url, LaunchMode mode) async => false; + + @override + Future open(String otpauthUrl) async { + opened.add(otpauthUrl); + return opens; + } +} + +/// 로그인 틀 시험대 — 가짜 서버 · 메모리 저장소 · 손 시계 · 가짜 인증 앱 열기로 앱 전체를 띄운다 +class LoginHarness { + LoginHarness(); + + final ProviderHarness base = ProviderHarness(); + final FakeAuthenticatorLauncher launcher = FakeAuthenticatorLauncher(); + + FakeServer get server => base.server; + ManualClock get clock => base.clock; + RecordingNotifier get notifier => base.notifier; + + /// 클립보드에 쓴 글 (가짜 플랫폼 통로) + final List clipboard = []; + + /// 켜면 클립보드 쓰기가 실패한다(플랫폼 예외) + bool clipboardFails = false; + + /// 문의 연락처를 답한다 — 부르지 않으면 가짜 서버가 404 로 답해 화면은 지금 글 그대로다 + void supportContact(String? text) { + server.on( + 'GET', + ApiPaths.supportContact, + (_) => FakeResponse.ok({'text': text}), + ); + } + + /// 앱을 띄운다 — [refreshToken] 이 있으면 앱을 다시 연 것처럼 세션을 되살린다. + /// 화면은 휴대폰 폭(390)에 세로로 길게 — 아래쪽 단추까지 스크롤 없이 누른다 + Future pump( + WidgetTester tester, { + String? refreshToken, + List observers = const [], + }) async { + tester.view.physicalSize = const Size(390 * 3, 1500 * 3); + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + await base.prepare(refreshToken: refreshToken); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (MethodCall call) async { + if (call.method == 'Clipboard.setData') { + if (clipboardFails) { + throw PlatformException(code: 'test', message: '클립보드 쓰기 실패(시험)'); + } + final Object? arguments = call.arguments; + if (arguments is Map) { + clipboard.add(arguments['text']! as String); + } + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + await tester.pumpWidget( + ProviderScope( + retry: noAutomaticRetry, + observers: observers, + overrides: [ + ...base.overrides, + authenticatorLauncherProvider.overrideWithValue(launcher), + ], + child: const App(), + ), + ); + await tester.pumpAndSettle(); + return ProviderScope.containerOf(tester.element(find.byType(App))); + } +} diff --git a/flutter/windows/flutter/generated_plugin_registrant.cc b/flutter/windows/flutter/generated_plugin_registrant.cc index 0c50753..2048c45 100644 --- a/flutter/windows/flutter/generated_plugin_registrant.cc +++ b/flutter/windows/flutter/generated_plugin_registrant.cc @@ -7,8 +7,11 @@ #include "generated_plugin_registrant.h" #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/flutter/windows/flutter/generated_plugins.cmake b/flutter/windows/flutter/generated_plugins.cmake index d0b33f8..e17c858 100644 --- a/flutter/windows/flutter/generated_plugins.cmake +++ b/flutter/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_windows + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST