feat(flutter): 로그인 네 단계 화면을 만든다 (앱 1단계 — App1Login)

주소 · 비밀번호 → 인증 앱 코드 → 새 비밀번호 정하기(임시 비밀번호) → 2단계 켜기(필수일 때)를
세션 노티파이어의 상태로 고른다. 첫 단계만 큰 브랜드 띠, 그다음은 짙은 띠에 [다른 계정으로 로그인].

- 2단계 켜기는 같은 휴대폰이라 QR 대신 [인증 앱에 추가](otpauth — url_launcher 6.3.3,
  canLaunchUrl 은 주소를 로그에 남겨 쓰지 않는다) · 키 [복사] · 코드 칸
- 실패는 칸 아래 · 위쪽 상자 · 전역 알림으로 웹 로그인 틀과 같게(REQ_001 은 코드 · 제한 단계에서
  상자), 칸의 안내와 오류를 함께, 세션 끝은 로그인 화면 상자만
- 비밀번호 · 코드 · 2단계 키는 화면 메모리와 노티파이어의 10분 기억에만(두 제한 단계에서만 쥔다),
  비밀 요청의 실패 예외는 본문을 비운다 — 시험이 모든 프로바이더 값에 비밀이 없음을 본다
- 제한 단계를 보내는 동안 [다른 계정으로 로그인]을 막는다, 주소 칸만 자판 추천(@ 자판)
- 한글 낱말 단위 줄바꿈(KeepAllText), 시험 227건

리뷰 반영: 모르는 제한에서 비밀번호를 쥐지 않음, REQ_001 자리, 안내 · 오류 함께, 주소 칸 자판,
계약 테스트 목록(AUTH_005 · AUTH_008), AUTH_002 를 두 번 알리지 않음.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 15:57:39 +09:00
parent dd02b9621a
commit c14580e3a4
54 changed files with 6597 additions and 73 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

+4
View File
@@ -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({
+16
View File
@@ -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<String> noRefresh = <String>{
appLogin,
+4 -1
View File
@@ -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);
}
@@ -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';
}
@@ -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';
+164
View File
@@ -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단계 인증을 켰습니다. 메일 프로그램은 앱 비밀번호로 연결합니다.';
}
+9 -13
View File
@@ -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단계 인증을 켜 주세요';
}
+20 -7
View File
@@ -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<List<AppModule>> appModulesProvider = Provider<List<AppModule>>(
/// 라우트는 모듈이 내놓은 묶음(`features/<모듈>/<모듈>_routes.dart`)을 module_registry 가 모은 것이다.
/// - 모르는 주소 · 꺼진 모듈의 주소는 메일 첫 화면으로 간다(redirect · onException).
///
/// 로그인 단계 화면은 이름만 보이는 자리 화면이다 — 캔버스(로그인 4단계)대로 만들면
/// `features/auth/presentation/pages/` 의 화면으로 바꾼다(경로 · redirect 는 그대로 둔다).
/// 로그인 틀 네 단계(앱 보드 `App1Login`)와 모르는 제한은 `features/auth/presentation/pages/` 의 화면이다.
/// 시작 화면(되살리는 중 · 되살리기 실패)은 아직 자리 화면이다(보드 없음).
final Provider<GoRouter> routerProvider = Provider<GoRouter>((Ref ref) {
final List<AppModule> modules = ref.watch(appModulesProvider);
final List<AppModule> enabled = enabledAppModules(modules);
@@ -59,11 +64,11 @@ final Provider<GoRouter> routerProvider = Provider<GoRouter>((Ref ref) {
router.go(homeLocationOf(modules)),
routes: <RouteBase>[
_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();
+27
View File
@@ -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;
}
+32
View File
@@ -159,7 +159,11 @@ class AppExtraColors extends ThemeExtension<AppExtraColors> {
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<AppExtraColors> {
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<AppExtraColors> {
/// 메일 본문
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> {
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<AppExtraColors> {
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<AppExtraColors> {
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)!,
@@ -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;
+40
View File
@@ -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<String> keyLines(String secret) {
final List<String> groups = <String>[
for (int start = 0; start < secret.length; start += kKeyGroupSize)
secret.substring(start, math.min(start + kKeyGroupSize, secret.length)),
];
return <String>[
for (int start = 0; start < groups.length; start += kKeyGroupsPerLine)
groups
.sublist(start, math.min(start + kKeyGroupsPerLine, groups.length))
.join(' '),
];
}
+34
View File
@@ -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);
+33
View File
@@ -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);
@@ -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,
);
}
@@ -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<bool> Function(Uri url, LaunchMode mode);
Future<bool> _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 판이 묻는 주소를 로그에 남긴다(키가 들어 있다).
/// 그래서 매니페스트 `<queries>` 도 필요 없다: `launchUrl` 은 startActivity 를 바로 부르고 받을 앱이 없으면
/// ActivityNotFoundException 을 false 로 바꾼다(앱 보기 제한은 resolveActivity 류에만 걸린다).
/// - 못 열면 플러그인이 던지는 `PlatformException` 의 문장에 주소째(키째) 들어 있다 — 잡아서 버린다(로그 · 상태에 남기지 않는다).
class AuthenticatorLauncher {
const AuthenticatorLauncher({UrlLaunch launch = _launchExternal})
: _launch = launch;
final UrlLaunch _launch;
/// 인증 앱을 열었으면 true. otpauth 주소가 아니거나 받을 앱이 없거나 열지 못했으면 false
Future<bool> 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;
}
}
}
@@ -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<String, Object?>) 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://';
@@ -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<String, Object?>) return null;
final Object? enabled = json['isEnabled'];
if (enabled is! bool) return null;
return TwoFactorStatus(isEnabled: enabled);
}
}
@@ -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<String> kLoginInlineCodes = <String>{
'VAL_001',
};
/// 코드 단계(코드를 실은 로그인)가 직접 보이는 실패 — 첫 단계 묶음에 시도 과다(`REQ_001` — 위쪽 상자)를 더한다.
/// 웹 코드 단계와 같다(웹 디자인 README 12단계 "로그인 — 코드 단계"). 첫 단계의 `REQ_001` 은 웹처럼 전역 알림이다
const Set<String> kLoginCodeInlineCodes = <String>{
...kLoginInlineCodes,
'REQ_001',
};
/// 새 비밀번호 단계가 직접 보이는 실패 — 알림을 띄우지 않는다
/// (`AUTH_005` 임시 비밀번호 칸 · `VAL_001` 새 비밀번호 칸 · `AUTH_006` · `AUTH_007` 코드 칸 · `REQ_001` 위쪽 상자 — 계약 1 · 5 · 6절, 웹과 같다)
const Set<String> kPasswordChangeInlineCodes = <String>{
'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<String, Object?>) {
@@ -100,6 +120,28 @@ class AuthRepository {
return info;
}
/// 비밀번호를 바꾼다 — 새 비밀번호 단계(계약 1절 `PUT /api/auth/password` · 5절 `otpCode` · 6절 제한 풀기).
///
/// [otpCode] 는 2단계 인증을 켠 직원만. 비밀번호 · 코드가 본문에 든다 — 실패 예외는 본문을 비운 채 온다.
/// 서버가 엔진을 두 번 부르고 세션을 끊어 받기 한도를 [kSecuritySettingTimeout] 으로 늘린다(웹과 같은 30초).
Future<void> changePassword({
required String currentPassword,
required String newPassword,
String? otpCode,
}) async {
await _dio.put<Object?>(
ApiPaths.password,
data: <String, Object?>{
'currentPassword': currentPassword,
'newPassword': newPassword,
'otpCode': ?otpCode,
},
options: ApiOptions.secret(
silent: kPasswordChangeInlineCodes,
).copyWith(receiveTimeout: kSecuritySettingTimeout),
);
}
/// 토큰을 새로 받는다 — 진행 중인 갱신이 있으면 함께 기다린다(한 번에 하나).
/// 제한을 푼 직후(계약 6절 — 곧바로 갱신해 새 토큰) · 옛 토큰의 `AUTH_010` 에서 부른다.
Future<RefreshOutcome> refresh() => _refresher.refresh();
@@ -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<String?> fetch() async {
final Response<Object?> response = await _dio.get<Object?>(
ApiPaths.supportContact,
options: ApiOptions.silent(),
);
final Object? data = response.data;
return data is Map<String, Object?>
? supportContactTextOf(data['text'])
: null;
}
}
@@ -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<String> kTwoFactorSetupInlineCodes = <String>{
'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<TwoFactorStatus> status() async {
final Response<Object?> response = await _dio.get<Object?>(
ApiPaths.twoFactor,
options: ApiOptions.silent(),
);
final TwoFactorStatus? status = TwoFactorStatus.tryParse(response.data);
if (status == null) throw _malformed(response);
return status;
}
/// 등록을 시작한다 — 새 키(10분 안에 켠다). 직원마다 기다리는 등록은 하나라 앞의 것은 서버가 버린다.
///
/// 응답에 키가 들어 있어 비밀 요청으로 보낸다(실패 예외의 응답 본문을 비운다). 알림은 띄우지 않는다 —
/// 화면이 키 자리에 사전 문구와 [다시 시도]를 보인다.
Future<TwoFactorEnrollment> startEnrollment() async {
final Response<Object?> response = await _dio.post<Object?>(
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<void> activate({
required String enrollmentId,
required String otpCode,
required String currentPassword,
}) async {
await _dio.post<Object?>(
ApiPaths.twoFactorActivation,
data: <String, Object?>{
'enrollmentId': enrollmentId,
'otpCode': otpCode,
'currentPassword': currentPassword,
},
options: ApiOptions.secret(
silent: kTwoFactorSetupInlineCodes,
).copyWith(receiveTimeout: kSecuritySettingTimeout),
);
}
/// 모양이 틀린 응답 — 응답을 싣지 않고 Bearer 를 지운 실패
DioException _malformed(Response<Object?> response) => withoutSecrets(
apiFailure(response.requestOptions, kMalformedResponseCode),
);
}
@@ -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);
}
@@ -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<LoginCodePage> createState() => _LoginCodePageState();
}
class _LoginCodePageState extends ConsumerState<LoginCodePage> {
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<void> _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: <Widget>[
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),
],
);
}
}
@@ -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<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage> {
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<void> _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: <Widget>[
const LoginHeading(
title: ScreenTitles.login,
subtitle: LoginTexts.subtitle,
),
if (notice != null) LoginAlertBox(message: notice),
AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: AppSpacing.s16,
children: <Widget>[
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 <String>[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 <String>[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),
),
],
);
}
}
@@ -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<PasswordChangePage> createState() => _PasswordChangePageState();
}
class _PasswordChangePageState extends ConsumerState<PasswordChangePage> {
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<bool> _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 <TextEditingController>[
_temporary,
_next,
_confirm,
_code,
]) {
controller.dispose();
}
for (final FocusNode node in <FocusNode>[
_temporaryFocus,
_nextFocus,
_confirmFocus,
_codeFocus,
]) {
node.dispose();
}
super.dispose();
}
/// 쥔 임시 비밀번호가 사라졌다(10분 · 서버 거절) — 칸을 보인다
void _onHeldChanged() {
if (mounted) setState(() {});
}
Future<void> _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<void> _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<void> _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: <Widget>[
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: <Widget>[
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 <String>[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 <String>[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 <String>[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),
],
);
}
}
@@ -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: <Widget>[
if (email != null) LoginAccountChip(email: email),
LoginAlertBox(
message: ErrorLexicon.getMessage(kRestrictedSessionCode),
namesRoute: true,
),
],
);
}
}
@@ -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<TwoFactorSetupPage> createState() => _TwoFactorSetupPageState();
}
class _TwoFactorSetupPageState extends ConsumerState<TwoFactorSetupPage> {
final TextEditingController _code = TextEditingController();
final TextEditingController _password = TextEditingController();
final FocusNode _codeFocus = FocusNode();
final FocusNode _passwordFocus = FocusNode();
/// 로그인 때 넣은 비밀번호(또는 방금 정한 새 비밀번호)를 세션 노티파이어가 쥐고 있나 — 없으면 ③ 칸
late final ValueListenable<bool> _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<void> _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<bool> _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<void> _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<void> _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<void> _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<void> _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<void> _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: <Widget>[
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) ...<Widget>[
_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: <Widget>[
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 <String>[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<AppExtraColors>()!;
final String? error = loadError;
final bool ready = enrollment != null && enabled;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: AppSpacing.s10,
children: <Widget>[
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: AppSpacing.s4,
children: <Widget>[
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 ...<Widget>[
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: <Widget>[
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: <Widget>[
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,
),
),
],
),
);
}
}
@@ -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<TwoFactorRepository> twoFactorRepositoryProvider =
Provider<TwoFactorRepository>(
(Ref ref) => TwoFactorRepository(ref.watch(dioProvider)),
);
/// 문의 연락처 API
final Provider<SupportContactRepository> supportContactRepositoryProvider =
Provider<SupportContactRepository>(
(Ref ref) => SupportContactRepository(ref.watch(dioProvider)),
);
/// [인증 앱에 추가] — 시험은 가짜로 덮어쓴다
final Provider<AuthenticatorLauncher> authenticatorLauncherProvider =
Provider<AuthenticatorLauncher>((Ref ref) => const AuthenticatorLauncher());
/// 문의 연락처 한 줄 — 로그인 첫 화면 · 코드 단계 · 2단계 켜기의 문의 줄. 정하지 않았거나 받지 못했으면 null(화면은 지금 글).
///
/// 화면이 [SupportContactNotifier.ensureLoaded] 를 부른다. 한 번 받으면 앱이 떠 있는 동안 다시 묻지 않고(운영 도구로 바꾸면 다음 실행에 반영),
/// 받지 못했으면 다음에 화면을 열 때 다시 묻는다(웹 `support-contact.store` 와 같다). 회사 설정값이라 로그아웃 때 지우지 않는다.
final NotifierProvider<SupportContactNotifier, String?> supportContactProvider =
NotifierProvider<SupportContactNotifier, String?>(
SupportContactNotifier.new,
retry: noAutomaticRetry,
);
class SupportContactNotifier extends Notifier<String?> {
Future<void>? _loading;
bool _loaded = false;
@override
String? build() => null;
/// 아직 받지 않았으면 받는다 — 몇 번을 불러도 요청은 하나
Future<void> ensureLoaded() {
if (_loaded) return Future<void>.value();
return _loading ??= _load();
}
Future<void> _load() async {
try {
final String? text = await ref
.read(supportContactRepositoryProvider)
.fetch();
if (!ref.mounted) return;
state = text;
_loaded = true;
} on DioException {
// 받지 못했다 — 지금 글 그대로(다음에 화면을 열 때 다시 묻는다)
} finally {
_loading = null;
}
}
}
@@ -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<bool> _held = ValueNotifier<bool>(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<bool> get held => _held;
void clear() {
_forget();
_held.value = false;
}
void _forget() {
_timer?.cancel();
_timer = null;
_email = null;
@@ -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<SessionNotifier, SessionState> 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<SessionState> {
LoginSecret? _secretHolder;
Future<RestrictionCheck>? _rechecking;
int _epoch = 0;
bool _passedCodeStep = false;
/// 답을 기다리는 제한 다시 맞추기 요청 — 세션이 바뀌면(로그아웃 · 세션 끝) stale 로 답해 인터셉터를 놓아준다
final List<SessionRecheckNeeded> _pendingRechecks = <SessionRecheckNeeded>[];
@@ -59,6 +76,8 @@ class SessionNotifier extends AsyncNotifier<SessionState> {
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<SessionState> {
return _restore();
}
/// 로그인 때 넣은 주소 · 비밀번호 — 코드 단계 · 새 비밀번호 단계가 쓴다(10분이 지났거나 제한이 풀렸으면 null)
/// 로그인 때 넣은 주소 · 비밀번호 — 코드 단계 · 제한 단계가 쓴다(10분이 지났거나 제한이 풀렸으면 null)
({String email, String password})? get heldLogin => _secret.read();
/// 로그인 때 넣은 비밀번호를 쥐고 있는지 — 제한 단계 화면이 듣고, 없으면 임시 · 지금 비밀번호 칸을 보인다(값은 참 · 거짓뿐)
ValueListenable<bool> 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<SessionState> {
/// 비밀번호 단계
Future<void> 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<SessionState> {
/// 코드 단계 — 쥐고 있는 비밀번호와 함께 보낸다(계약 5절).
///
/// 10분이 지났으면 보내지 않고 첫 단계로 돌아간다. `AUTH_004`(그사이 비밀번호가 바뀜)도 첫 단계로 돌아간 뒤 던진다.
/// - 10분이 지났으면 보내지 않고 첫 단계로 돌아간다(주소는 남긴다).
/// - `AUTH_004`(그사이 비밀번호가 바뀜)도 첫 단계로 돌아간 뒤 던진다 — 첫 단계가 위쪽 상자에 보인다(주소는 남긴다).
/// - 코드를 실었는데도 서버가 코드를 묻으면(`twoFactorRequired`) 받아들이지 않은 코드로 보고 `AUTH_006` 으로 던진다(웹과 같다).
Future<void> submitCode(String otpCode) async {
final SessionState? current = state.value;
final ({String email, String password})? held = _secret.read();
if (held == null) {
state = const AsyncData<SessionState>(
SignedOut(reason: SignOutReason.codeTimedOut),
state = AsyncData<SessionState>(
SignedOut(
reason: SignOutReason.codeTimedOut,
email: current is CodeRequired ? current.email : null,
),
);
return;
}
@@ -126,18 +161,150 @@ class SessionNotifier extends AsyncNotifier<SessionState> {
ref.mounted &&
apiErrorCodeOf(error) == 'AUTH_004') {
_secret.clear();
state = const AsyncData<SessionState>(SignedOut());
state = AsyncData<SessionState>(
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<RestrictedStepResult> 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<RestrictedStepResult> 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<RestrictedStepResult> 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<String, Object?>? 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<void> switchAccount() async {
@@ -195,6 +362,7 @@ class SessionNotifier extends AsyncNotifier<SessionState> {
/// 답을 기다리는 요청에는 stale 로 답한다(다른 계정의 새 로그인 오류가 앞 세션의 me 뒤에서 기다리지 않게)
void _advanceEpoch() {
_epoch++;
_passedCodeStep = false;
_rechecking = null;
final List<SessionRecheckNeeded> waiting = List<SessionRecheckNeeded>.of(
_pendingRechecks,
@@ -277,6 +445,7 @@ class SessionNotifier extends AsyncNotifier<SessionState> {
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<SessionState> {
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<SessionState> {
}
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<void> _registerPush() async {
try {
await _push.register();
@@ -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,
}
@@ -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<String>? onSubmitted;
final ValueChanged<String>? onChanged;
final Iterable<String>? autofillHints;
/// 칸 끝의 단추([보기])
final Widget? suffix;
/// 보내는 동안 읽기만
final bool readOnly;
final List<TextInputFormatter>? 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<AppExtraColors>()!;
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 = <String?>[
describedBy,
hintText,
errorText,
].whereType<String>().join('\n');
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: AppSpacing.s6,
children: <Widget>[
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<AppExtraColors>()!.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<String>? onSubmitted;
final ValueChanged<String>? onChanged;
final Iterable<String>? 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<String>? onSubmitted;
final ValueChanged<String>? 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>[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 <String>[AutofillHints.oneTimeCode],
readOnly: readOnly,
inputFormatters: <TextInputFormatter>[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),
);
}
}
@@ -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<Widget> children;
@override
Widget build(BuildContext context) {
return _LoginScaffold(
top: const _StatusBarStrip(),
body: _LoginScroll(
children: <Widget>[
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<Widget> children;
/// 띠와 첫 내용 사이 (보드: 코드 · 새 비밀번호 28, 2단계 켜기 24)
final double topPadding;
@override
Widget build(BuildContext context) {
return _LoginScaffold(
top: _StepBand(
onOtherAccount: otherAccountEnabled ? onOtherAccount : null,
),
body: _LoginScroll(
children: <Widget>[
_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<SystemUiOverlayStyle>(
value: kLoginOverlayStyle,
child: Theme(
data: theme,
child: Scaffold(
backgroundColor: theme.colorScheme.surface,
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
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<Widget> 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<Widget> 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<AppExtraColors>()!;
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: <Widget>[
Image.asset(
AppAssets.logoFull,
width: LoginLayout.logoWidth,
semanticLabel: kAppName,
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: AppSpacing.s6,
children: <Widget>[
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<AppExtraColors>()!;
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: <Widget>[
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),
),
],
),
),
),
),
),
);
}
}
@@ -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: <Widget>[
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<AppExtraColors>()!;
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: <Widget>[
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: <Widget>[
Semantics(
container: true,
liveRegion: true,
namesRoute: namesRoute,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: AppSpacing.s8,
children: <Widget>[
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<AppExtraColors>()!
.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<AppExtraColors>()!;
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: <InlineSpan>[
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<AppExtraColors>()!;
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: <Widget>[
SizedBox(
width: LoginLayout.stepNumberWidth,
child: ExcludeSemantics(
child: Text(
'$number',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: AppTypography.bold,
color: theme.colorScheme.primary,
),
),
),
),
Expanded(child: child),
],
);
}
}
@@ -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);
}
}
@@ -7,9 +7,13 @@
#include "generated_plugin_registrant.h"
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
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);
}
@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_linux
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
@@ -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"))
}
+64
View File
@@ -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:
+5
View File
@@ -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:
+8 -6
View File
@@ -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));
});
@@ -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<Object?>(_mailState)),
);
final ApiException error = apiErrorOf(
await errorOf(api.dio.get<Object?>(_mailState)),
);
expect(error.code, 'AUTH_002');
expect(api.server.received('GET', _mailState), isEmpty);
expect(api.notifier.shownCodes, <String>['AUTH_002']);
expect(api.emitted.whereType<SessionEnded>(), 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<SessionEnded>(), 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<SessionEnded>(), hasLength(1));
expect(api.notifier.shownCodes, <String>['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, <String>['AUTH_002']);
expect(api.notifier.shownCodes, isEmpty);
});
test('갱신이 일시 장애(503 · 연결 끊김)면 세션을 두고 그 코드로 알린다', () async {
@@ -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'), <String>[
'K7QF 2M4X HB6R TZ3N',
'PW5C YL7D JQ2V MGAS',
]);
});
test('길이가 4의 배수가 아니어도 남은 글자를 잃지 않는다', () {
expect(keyLines('ABCDEFGHIJ'), <String>['ABCD EFGH IJ']);
expect(keyLines(''), isEmpty);
});
});
group('한국어 줄 나눔 (keep-all)', () {
// WORD JOINER(U+2060) — 보이지 않는 글자라 코드 포인트로 만든다
final String joiner = String.fromCharCode(0x2060);
test('한글 낱말 안에 줄 나눔 막기를 넣고 띄어쓰기는 그대로 둔다', () {
final String first = <String>['자', '동', '으', '로'].join(joiner);
final String second = <String>['맞', '춰'].join(joiner);
expect(keepAllWordBreak('자동으로 맞춰'), '$first $second');
});
test('한글과 붙은 영문 · 기호도 한 낱말로 남는다', () {
expect(
keepAllWordBreak('Outlook이나'),
<String>['Outlook', '이', '나'].join(joiner),
);
expect(
keepAllWordBreak('[설정]에서'),
<String>['[', '설', '정', ']', '에', '서'].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('가👍나'), '가👍나');
});
});
}
+5 -1
View File
@@ -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',
File diff suppressed because it is too large Load Diff
@@ -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<bool> seen = <bool>[];
secret.held.addListener(() => seen.add(secret.held.value));
secret.hold('ara.kim@arasystem.it.kr', 'temp-pw');
secret.clear();
expect(seen, <bool>[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');
});
});
}
@@ -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<void> 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<String, Object?> bodyOf(RequestOptions options) =>
options.data! as Map<String, Object?>;
/// 제한된 세션으로 로그인한다 — 코드 단계를 거치면 [viaCode]
Future<void> 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<SignedIn>());
}
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), <String, Object?>{
'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),
<String, Object?>{
'currentPassword': 'typed-temp',
'newPassword': 'new-pw-1234',
},
);
});
test('보내는 사이 [다른 계정으로 로그인]하면 결과를 쓰지 않는다(stale) — 새 비밀번호를 쥐지 않는다', () async {
await start();
await signInRestricted('passwordChange');
final Completer<void> release = Completer<void>();
h.server.on('PUT', ApiPaths.password, (_) async {
await release.future;
return FakeResponse.ok();
});
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
final Future<RestrictedStepResult> 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), <String, Object?>{
'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<Object?>('/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<SignedIn>());
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<SignedIn>());
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));
});
});
}
@@ -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, <String>['AUTH_002']);
// 로그인 화면의 위쪽 상자가 알린다 — 스낵바는 띄우지 않는다
expect(h.notifier.shownCodes, isEmpty);
});
test(
@@ -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(<String, Object?>{
'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(<String, Object?>{
'enrollmentId': 'enroll-1',
'secret': kTestSecret,
'otpauthUrl': 'https://evil.example/otp',
}),
isNull,
);
expect(
TwoFactorEnrollment.tryParse(<String, Object?>{
'enrollmentId': '',
'secret': kTestSecret,
'otpauthUrl': kTestOtpauthUrl,
}),
isNull,
);
});
test('상태는 isEnabled 만 본다 — 없으면 읽지 않는다', () {
expect(
TwoFactorStatus.tryParse(<String, Object?>{
'isEnabled': true,
})?.isEnabled,
isTrue,
);
expect(TwoFactorStatus.tryParse(<String, Object?>{}), 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(<String, Object?>{
'text': ' IT 담당 홍길동\n02-000-0000 ',
}),
);
final SupportContactNotifier notifier = container.read(
supportContactProvider.notifier,
);
await Future.wait(<Future<void>>[
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(<String, Object?>{'text': '사내 메신저 @it'}),
);
await notifier.ensureLoaded();
expect(container.read(supportContactProvider), '사내 메신저 @it');
});
});
}
+183
View File
@@ -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(<String, Object?>{
'status': 'twoFactorRequired',
'email': null,
'name': null,
'restriction': null,
'twoFactorDeadline': null,
'tokens': null,
});
/// 로그인을 마친 응답 ([n] 번째 토큰)
FakeResponse signedInResponse(
int n, {
required DateTime now,
String? restriction,
}) => FakeResponse.ok(<String, Object?>{
'status': 'signedIn',
...sessionJson(restriction: restriction),
'tokens': tokensJson(n, now: now),
});
/// 갱신 응답 ([n] 번째 토큰 · 세션 칸)
FakeResponse refreshResponse(
int n, {
required DateTime now,
String? restriction,
}) => FakeResponse.ok(<String, Object?>{
...sessionJson(restriction: restriction),
'tokens': tokensJson(n, now: now),
});
/// 2단계 등록 응답 ([id] 번째 등록 — 키는 같다)
FakeResponse enrollmentResponse({String id = 'enroll-1'}) =>
FakeResponse(201, <String, Object?>{
'success': true,
'data': <String, Object?>{
'enrollmentId': id,
'secret': kTestSecret,
'otpauthUrl': kTestOtpauthUrl,
'expiresAt': '2026-11-05T03:10:00.000Z',
},
});
/// 2단계 상태 응답
FakeResponse twoFactorStatusResponse({required bool enabled}) =>
FakeResponse.ok(<String, Object?>{
'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<TextField>(fieldInput(label)).controller!.text;
/// [인증 앱에 추가]를 적어 두는 가짜 — [opens] 가 false 면 받을 앱이 없는 것처럼 답한다
class FakeAuthenticatorLauncher extends AuthenticatorLauncher {
FakeAuthenticatorLauncher() : super(launch: _never);
bool opens = true;
final List<String> opened = <String>[];
static Future<bool> _never(Uri url, LaunchMode mode) async => false;
@override
Future<bool> 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<String> clipboard = <String>[];
/// 켜면 클립보드 쓰기가 실패한다(플랫폼 예외)
bool clipboardFails = false;
/// 문의 연락처를 답한다 — 부르지 않으면 가짜 서버가 404 로 답해 화면은 지금 글 그대로다
void supportContact(String? text) {
server.on(
'GET',
ApiPaths.supportContact,
(_) => FakeResponse.ok(<String, Object?>{'text': text}),
);
}
/// 앱을 띄운다 — [refreshToken] 이 있으면 앱을 다시 연 것처럼 세션을 되살린다.
/// 화면은 휴대폰 폭(390)에 세로로 길게 — 아래쪽 단추까지 스크롤 없이 누른다
Future<ProviderContainer> pump(
WidgetTester tester, {
String? refreshToken,
List<ProviderObserver> observers = const <ProviderObserver>[],
}) 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<Object?, Object?>) {
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: <Override>[
...base.overrides,
authenticatorLauncherProvider.overrideWithValue(launcher),
],
child: const App(),
),
);
await tester.pumpAndSettle();
return ProviderScope.containerOf(tester.element(find.byType(App)));
}
}
@@ -7,8 +7,11 @@
#include "generated_plugin_registrant.h"
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}
@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_windows
url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST