diff --git a/flutter/assets/brand/logo-ara-white.png b/flutter/assets/brand/logo-ara-white.png new file mode 100644 index 0000000..6766d3a Binary files /dev/null and b/flutter/assets/brand/logo-ara-white.png differ diff --git a/flutter/assets/brand/logo-arasystem-white.png b/flutter/assets/brand/logo-arasystem-white.png new file mode 100644 index 0000000..cab7539 Binary files /dev/null and b/flutter/assets/brand/logo-arasystem-white.png differ 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