generated from ttipo/project-base
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:
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user