diff --git a/app/README.md b/app/README.md index b191b39..f648654 100644 --- a/app/README.md +++ b/app/README.md @@ -7,7 +7,7 @@ | | 내용 | |---|---| -| **포함** | 앱 기동 · 코스믹 다크 테마(design-tokens.md 생성) · 5탭 셸 네비게이션 · 로그인 화면(S-A-LOGIN) 4종 소셜 버튼 · 인증 인터페이스와 Google/Apple 실 코드 경로 · Firebase 초기화(미설정 시 목 모드) | +| **포함** | 앱 기동 · 코스믹 다크 테마(design-tokens.md 생성) · 탭 셸 네비게이션 · 로그인 화면(S-A-LOGIN) 소셜 4종 버튼 · 인증 인터페이스와 Google/Apple 실 코드 경로 · 계정 연결(linkWithCredential) · Firebase 초기화(미설정 시 목 모드) | | **미포함** | 각 탭의 실제 화면(3주차 이후) · 온보딩 나머지 6화면(S-A-INTRO · S-A-TERMS · S-A-PROFILE · S-A-ARTIST · S-A-PLANET-NEW · S-A-PERMS) · 도메인 로직(POP·FPI·퀘스트·이클립스) · 상태관리 라이브러리 | 미포함 항목을 지금 만들지 않은 이유: **RN/Flutter 승인이 아직 없다(2주차_확인요청항목 Q1).** @@ -113,8 +113,8 @@ bool get isMockSessionAvailable => !FirebaseBoot.isLive && !_releaseMode; | ID | 항목 | 투입 위치 | 없으면 | |---|---|---|---| -| Q3 | 카카오 네이티브 앱 키 | 미정(플러그인 도입 시 확정) | 카카오 버튼이 「키 미투입」 안내를 반환 | -| Q3 | 네이버 client ID / secret | 미정 | 네이버 버튼이 「키 미투입」 안내를 반환 | +| Q3 | 카카오 네이티브 앱 키 | 미정(플러그인 도입 시 확정) | 카카오 버튼이 「키 대기」 안내 다이얼로그를 띄운다 | +| Q3 | LINE Login 채널 ID / 시크릿 | 미정 | LINE 버튼이 「키 대기」 안내 다이얼로그를 띄운다 | | Q5 | 스토어 등록 명의 | — | 3주차 TestFlight·Play 내부트랙 제출 불가 | | ~~Q14~~ | 앱 식별자 확정 | `android/app/build.gradle.kts` · `ios/Runner.xcodeproj` | **2026-09-11 확정 — 별첨 C 4번 `kr.co.fanuzu.app`.** Android applicationId·namespace·Kotlin 패키지, iOS bundle id 를 모두 옮겼다. **스토어 등록 후에는 변경 불가** | | ~~Q6~~ | 공식 FANUZU 심볼 원본 | `assets/brand/` | **2026-08-13 반영 완료.** 제공본이 심볼 전용이라 앱 아이콘은 공식 자산으로 생성했고 로그인 워드마크는 `FANUZU` 텍스트를 유지 | @@ -133,16 +133,52 @@ bool get isMockSessionAvailable => !FirebaseBoot.isLive && !_releaseMode; 미투입 상태에서는 **시스템 폰트로 폴백**된다. 크기·굵기·행간·자간은 토큰 값 그대로 적용되므로 투입 전후 레이아웃 차이는 자소 폭 수준에 그친다. -## 6. 소셜 로그인 상태 +## 6. 로그인 — 별첨 B v1.4 확정 4종 -화면명세 `S-A-LOGIN.md` 와 시안이 4종(카카오·네이버·Apple·Google)이고, 버튼 순서·라벨을 그대로 옮겼다. +> 1차 로그인은 Google / Apple / Kakao / LINE 4종입니다. 이메일/비밀번호 가입 및 X 로그인은 +> 제외합니다. 하나의 FANUZU UID 에 복수 소셜 Provider 연결은 허용합니다. +> — 별첨B_정책설계서_요구사항정의서_반영_회신표_v1.4 + +버튼 순서는 이 회신 문구 순서다(`AuthRepository._defaultProviders()`). | 제공자 | 상태 | 구현 | 막고 있는 것 | |---|---|---|---| -| 카카오 | 스텁 | `KeylessSocialProvider` | 앱 키(Q3) | -| 네이버 | 스텁 | `KeylessSocialProvider` | 앱 키(Q3) | -| Apple | 실 코드 경로 | `AppleSignInProvider` | Firebase 설정(S2) + Xcode Capability + Android Service ID/redirect URI | -| Google | 실 코드 경로 | `GoogleSignInProvider` | Firebase 설정(S2) | +| Google | 실 코드 경로 | `GoogleSignInProvider` | **Firebase 콘솔에서 Google 제공자 미활성** + Android OAuth 클라이언트(SHA-1) 미등록 | +| Apple | 실 코드 경로 | `AppleSignInProvider` | Apple 개발자 키 + Xcode Capability + Android Service ID/redirect URI | +| 카카오 | 키 대기 | `KeylessSocialProvider` | 카카오 네이티브 앱 키(Q3) | +| LINE | 키 대기 | `KeylessSocialProvider` | LINE Login 채널 ID/시크릿(Q3) | + +키가 없는 제공자의 버튼에는 **「키 대기」 배지**가 붙고, 누르면 무엇이 없어서 못 쓰는지 +**안내 다이얼로그**가 뜬다. 버튼을 회색으로 잠그지 않는 것은 회색이 사유를 말해 주지 않기 때문이다. + +### 이메일 가입 — 사용자 노출 없음 + +별첨 B v1.4 가 이메일/비밀번호 가입을 1차 범위에서 뺐다. 로그인 화면에 이메일 버튼도, +「회원가입」도, 「비밀번호 재설정」도 없다. 닫은 방법은 **기능 플래그가 아니라 목록에서 빼는 것**이다 — +`AuthRepository.providers` 에 없으면 화면이 버튼을 그리지 않고, 버튼이 없으면 닿는 경로가 없다. + +발주사 QA 계정(`qa-test@fanuzu.co.kr`)용 이메일 로그인 코드는 남아 있으나 입구가 하나뿐이다: + +* **디버그 빌드에서 로그인 화면의 FANUZU 로고를 길게 누른다.** +* 릴리스 빌드에서는 `AuthRepository.qaEmailProvider` 가 `null` 이라 폼이 열리지 않고 + `signInWithEmail` 도 실패로 닫힌다. 회귀 가드 4건이 이걸 고정한다. +* 보이는 「QA 로그인」 버튼을 두지 않은 이유: **디버그 APK 도 발주사가 설치해 보는 산출물**이라 + 그 버튼 자체가 「이메일 가입이 살아 있다」로 읽힌다. + +### 계정 연결 — 하나의 UID 에 복수 Provider + +`FirebaseAccountLinker` 가 `User.linkWithCredential` / `User.unlink` 를 쓴다. +`signInWithCredential` 이 아니라 **링크 API 만** 쓰는 것이 「UID 는 하나」를 지키는 지점이다 — +전자는 해당 계정이 없으면 새로 만들어 버린다. + +* 로그인과 연결은 자격증명을 얻는 데까지 완전히 같아서 그 공통 부분을 `CredentialProvider` + 하나로 뺐다. 두 벌로 적으면 Apple nonce 같은 보안 절차가 한쪽에서만 고쳐진다. +* 명세 `554:22797`(*동일 이메일만 연동 가능*)은 **붙이기 전에** 판정한다. 붙인 뒤 되돌리는 + 방식은 `unlink` 가 실패하면 남의 계정이 붙은 채로 남는다. + 단 Apple 은 **최초 인가 때만** 이메일을 주므로 재연동 시에는 판정할 값이 없어 통과한다 — + 서버에서 다시 보려면 Functions 층(fn-account-link)에 같은 판정을 둔다. +* **마지막 하나 남은 Provider 는 뗄 수 없다.** 떼는 순간 로그인 수단이 0개가 되어 계정에 + 다시 들어올 방법이 사라지는데, Firebase 는 이걸 막아 주지 않는다. 설계 원칙: **「키 미투입」은 예외가 아니라 상태다.** 예외를 던지지 않고 `AuthNotConfigured(provider, missing)` 를 반환해 "무엇이 없어서 못 쓰는지"를 화면에 그대로 보여 준다. @@ -290,7 +326,8 @@ I flutter : [FirebaseBoot] detail :: PlatformException(java.lang.Exception: Fail |---|---|---|---| | L1 | Firebase 설정 파일 미투입 → 목 모드 | **실 소셜 로그인 불가.** 로그인 버튼은 「키 미투입」 안내를 반환 | S2 (3주차 빌드 전) | | L2 | `com.google.gms.google-services` Gradle 플러그인 **미적용** | 지금은 이게 정상이다. 설정 파일 없이 플러그인만 적용하면 Gradle 빌드가 실패한다. 파일 투입과 **동시에** `android/settings.gradle.kts` · `android/app/build.gradle.kts` 에 추가해야 한다 | S2 | -| L3 | 카카오·네이버 SDK 미도입 | 스텁만 있다. 실 연동 시 `kakao_flutter_sdk_user` · 네이버 SDK 추가와 함께 `KeylessSocialProvider` 를 실 구현체로 교체 | Q3 회신 후 | +| L3 | 카카오·LINE SDK 미도입 | 「키 대기」 배지 + 안내 다이얼로그까지만 있다. 키가 오면 `kakao_flutter_sdk_user` · LINE SDK 를 넣고 `KeylessSocialProvider` 를 `CredentialProvider` 구현체로 교체하면 로그인·계정 연결이 함께 열린다. Firebase 쪽은 OIDC 커스텀 제공자(`oidc.kakao` · `oidc.line`)로 등록한다 | Q3 회신 후 | +| L3-1 | 「동일 이메일만 연동」이 Apple 재연동에서는 판정되지 않는다 | Apple 이 **최초 인가 때만** 이메일을 준다. 클라이언트에서 볼 값이 없어 통과시킨다 — 서버(fn-account-link)에 같은 판정을 두어야 완결된다 | Firestore 설계서 v1.1 | | L4 | 폰트 파일 미투입 | 시스템 폰트로 폴백. 크기·굵기·행간·자간은 토큰 값대로 적용됨 | 라이선스 원본 확보 시 | | L6 | 탭 아이콘이 Material 기본(Q15) | 시안 탭바에는 아이콘이 없어 대조 대상이 없다 | 3주차 | | L7 | **iOS 최소 지원 버전을 13.0 → 15.0 으로 올렸다** | `firebase_auth` 6.x 가 15.0 이상을 요구해 13.0 으로는 `pod install` 이 실패했다. 기준 단말 iPhone 11(S4)은 iOS 18까지 지원하므로 범위 영향 없음. `ios/Podfile` · `Runner.xcodeproj` 3곳 수정 | 반영 완료 | diff --git a/app/lib/core/config/feature_flags.dart b/app/lib/core/config/feature_flags.dart new file mode 100644 index 0000000..2d49b98 --- /dev/null +++ b/app/lib/core/config/feature_flags.dart @@ -0,0 +1,56 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +/// 원격 기능 플래그 — Firestore `config/features` 문서 한 장. +/// +/// 별첨 B v1.4 (STATION) — *1차에서는 사용자 탭과 Coming Soon 을 모두 비노출하고 +/// Route/데이터 구조만 유지해 향후 **앱 재배포 없이** 활성화합니다.* +/// +/// 「재배포 없이」가 이 클래스가 존재하는 이유 전부다. 컴파일 상수나 +/// `--dart-define` 은 값을 바꾸려면 새 빌드를 올려야 해 회신 문구를 만족하지 못한다. +/// 반대로 `firebase_remote_config` 는 신규 의존성이라 pubspec 머리말대로 최소 지원 +/// OS 를 다시 계산해야 하는데, `firestore.rules` 의 `config/{id}` 가 이미 +/// `allow read: if true` 로 열려 있고 `cloud_firestore` 는 이미 들어와 있다. +/// 그래서 이미 있는 것으로 끝낸다. +/// +/// **기본값은 전부 꺼짐이다.** 문서가 없거나, 목 모드거나, 읽기가 실패해도 STATION +/// 은 나타나지 않는다 — 「원격 값을 못 읽었다」가 「켜졌다」가 되면 안 된다. +class FeatureFlags extends ChangeNotifier { + FeatureFlags({Stream>? source}) { + if (source != null) _subscription = source.listen(_apply, onError: _ignore); + } + + /// Firestore 문서 ID. 서버 설계서에 넘기는 계약 이름이다. + static const String documentPath = 'config/features'; + + /// STATION 탭 노출 스위치. 문서의 `stationEnabled`(bool) 하나를 읽는다. + static const String stationKey = 'stationEnabled'; + + StreamSubscription>? _subscription; + + bool _stationTab = false; + + /// 하단 탭에 STATION 을 그릴 것인가. 기본 false. + bool get stationTab => _stationTab; + + void _apply(Map data) { + // 타입까지 확인한다. 누가 문자열 "true" 를 넣어도 켜지지 않는다. + final next = data[stationKey] == true; + if (next == _stationTab) return; + _stationTab = next; + notifyListeners(); + } + + /// 원격 읽기 실패는 꺼짐 상태를 유지하는 것으로 끝낸다. 화면에 띄울 것이 없다. + void _ignore(Object error) { + debugPrint('[FeatureFlags] read failed: ${error.runtimeType}'); + } + + @override + void dispose() { + unawaited(_subscription?.cancel()); + _subscription = null; + super.dispose(); + } +} diff --git a/app/lib/core/router/app_router.dart b/app/lib/core/router/app_router.dart index 59d102d..fa36155 100644 --- a/app/lib/core/router/app_router.dart +++ b/app/lib/core/router/app_router.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import '../config/feature_flags.dart'; import '../../features/auth/auth_repository.dart'; import '../../features/auth/login_screen.dart'; import '../../features/galaxy/galaxy_screen.dart'; @@ -11,12 +12,23 @@ import '../../features/station/station_screen.dart'; /// 하단 탭 하나. class AppTab { - const AppTab({required this.label, required this.path, required this.icon}); + const AppTab({ + required this.label, + required this.path, + required this.icon, + this.gatedBy, + }); /// **계약 고정 문자열.** 한글화·치환·순서 변경 금지. final String label; final String path; final IconData icon; + + /// 이 탭을 여는 원격 플래그. null 이면 항상 보인다. + /// + /// 플래그가 붙어도 [appTabs]·라우트·저장소는 그대로 남는다 — 별첨 B v1.4 가 + /// STATION 에 대해 「Route/데이터 구조만 유지」를 요구했기 때문이다. + final bool Function(FeatureFlags flags)? gatedBy; } /// 계약 고정 5탭. @@ -28,82 +40,139 @@ class AppTab { /// /// 라벨 문자열과 순서는 스모크 테스트가 정확 일치로 검증한다. /// 아이콘은 아직 확정 자산이 없어 Material 기본 아이콘을 쓴다(README 「미투입 항목」). +/// +/// **이 목록은 계약이지 화면이 아니다.** 지금 하단 탭에 실제로 그려지는 것은 +/// [visibleTabs] 이고 STATION 은 원격 플래그가 켜질 때까지 빠진다 — 목록에서 +/// 지우면 `design/craft-allowlist.yaml` 과 어긋나고, 켜려면 재배포가 필요해진다. const List appTabs = [ AppTab(label: 'HOME', path: '/home', icon: Icons.home_outlined), AppTab(label: 'INSIDE', path: '/inside', icon: Icons.chat_bubble_outline), AppTab(label: 'GALAXY', path: '/galaxy', icon: Icons.auto_awesome_outlined), - AppTab(label: 'STATION', path: '/station', icon: Icons.storefront_outlined), + // 별첨 B v1.4 — *1차에서는 사용자 탭과 Coming Soon 을 모두 비노출하고 + // Route/데이터 구조만 유지해 향후 앱 재배포 없이 활성화합니다.* + AppTab( + label: 'STATION', + path: '/station', + icon: Icons.storefront_outlined, + gatedBy: _stationGate, + ), AppTab(label: 'MY', path: '/my', icon: Icons.account_circle_outlined), ]; +bool _stationGate(FeatureFlags flags) => flags.stationTab; + +/// 지금 하단 탭에 그릴 탭들. 계약 순서는 그대로고 닫힌 탭만 빠진다. +List visibleTabs(FeatureFlags flags) => [ + for (final tab in appTabs) + if (tab.gatedBy?.call(flags) ?? true) tab, +]; + const String loginPath = '/login'; /// 5탭 셸 라우트 + 온보딩(로그인) 라우트. /// /// 온보딩 나머지 화면(S-A-INTRO · S-A-TERMS · S-A-PROFILE · S-A-ARTIST · /// S-A-PLANET-NEW · S-A-PERMS)은 3주차 범위라 아직 라우트가 없다. -GoRouter buildRouter(AuthRepository auth) => GoRouter( - initialLocation: appTabs.first.path, - refreshListenable: auth, - redirect: (context, state) { - final atLogin = state.matchedLocation == loginPath; - if (!auth.isSignedIn) return atLogin ? null : loginPath; - if (atLogin) return appTabs.first.path; - return null; - }, - routes: [ - GoRoute(path: loginPath, builder: (_, _) => LoginScreen(auth: auth)), - StatefulShellRoute.indexedStack( - builder: (_, _, shell) => AppShell(shell: shell), - branches: [ - StatefulShellBranch( - routes: [ - GoRoute(path: appTabs[0].path, builder: (_, _) => const HomeScreen()), - ], - ), - StatefulShellBranch( - routes: [ - GoRoute(path: appTabs[1].path, builder: (_, _) => const InsideScreen()), - ], - ), - StatefulShellBranch( - routes: [ - GoRoute(path: appTabs[2].path, builder: (_, _) => const GalaxyScreen()), - ], - ), - StatefulShellBranch( - routes: [ - GoRoute(path: appTabs[3].path, builder: (_, _) => const StationScreen()), - ], - ), - StatefulShellBranch( - routes: [ - GoRoute(path: appTabs[4].path, builder: (_, _) => const MyScreen()), - ], - ), - ], - ), - ], -); +GoRouter buildRouter(AuthRepository auth, {FeatureFlags? flags}) { + final featureFlags = flags ?? FeatureFlags(); + return GoRouter( + initialLocation: appTabs.first.path, + refreshListenable: auth, + redirect: (context, state) { + final atLogin = state.matchedLocation == loginPath; + if (!auth.isSignedIn) return atLogin ? null : loginPath; + if (atLogin) return appTabs.first.path; + return null; + }, + routes: [ + GoRoute( + path: loginPath, + builder: (_, _) => LoginScreen(auth: auth), + ), + StatefulShellRoute.indexedStack( + builder: (_, _, shell) => AppShell(shell: shell, flags: featureFlags), + branches: [ + StatefulShellBranch( + routes: [ + GoRoute( + path: appTabs[0].path, + builder: (_, _) => const HomeScreen(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: appTabs[1].path, + builder: (_, _) => const InsideScreen(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: appTabs[2].path, + builder: (_, _) => const GalaxyScreen(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: appTabs[3].path, + builder: (_, _) => const StationScreen(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: appTabs[4].path, + builder: (_, _) => const MyScreen(), + ), + ], + ), + ], + ), + ], + ); +} -/// 5탭 셸. 탭별 상태는 [StatefulNavigationShell] 이 유지한다. +/// 탭 셸. 탭별 상태는 [StatefulNavigationShell] 이 유지한다. +/// +/// **브랜치는 언제나 [appTabs] 5개 전부다.** 숨긴 탭의 브랜치를 빼면 라우트가 +/// 사라져 딥링크·기존 경로가 죽고, 플래그를 켤 때 재배포가 필요해진다. 여기서는 +/// 하단 바에 그리는 목적지만 줄이고, 눌린 위치를 원래 브랜치 번호로 되돌린다. class AppShell extends StatelessWidget { - const AppShell({super.key, required this.shell}); + const AppShell({super.key, required this.shell, required this.flags}); final StatefulNavigationShell shell; + final FeatureFlags flags; @override - Widget build(BuildContext context) => Scaffold( - body: shell, - bottomNavigationBar: NavigationBar( - selectedIndex: shell.currentIndex, - // initialLocation: true 를 주면 이미 열려 있는 탭을 다시 누를 때 루트로 되돌아간다. - onDestinationSelected: (i) => - shell.goBranch(i, initialLocation: i == shell.currentIndex), - destinations: [ - for (final tab in appTabs) - NavigationDestination(icon: Icon(tab.icon), label: tab.label), - ], - ), + Widget build(BuildContext context) => ListenableBuilder( + listenable: flags, + builder: (context, _) { + final tabs = visibleTabs(flags); + // 보이는 목적지 번호 ↔ 셸 브랜치 번호. 숨긴 탭 때문에 어긋나는 지점이다. + final branches = [for (final tab in tabs) appTabs.indexOf(tab)]; + final selected = branches.indexOf(shell.currentIndex); + return Scaffold( + body: shell, + bottomNavigationBar: NavigationBar( + // 숨긴 탭에 머무는 동안(딥링크 등)에는 선택 표시를 첫 탭으로 둔다. + selectedIndex: selected < 0 ? 0 : selected, + onDestinationSelected: (i) => shell.goBranch( + branches[i], + // initialLocation: true 를 주면 이미 열려 있는 탭을 다시 누를 때 루트로 되돌아간다. + initialLocation: branches[i] == shell.currentIndex, + ), + destinations: [ + for (final tab in tabs) + NavigationDestination(icon: Icon(tab.icon), label: tab.label), + ], + ), + ); + }, ); } diff --git a/app/lib/features/auth/auth.dart b/app/lib/features/auth/auth.dart index d5b23a9..844d9f4 100644 --- a/app/lib/features/auth/auth.dart +++ b/app/lib/features/auth/auth.dart @@ -1,19 +1,20 @@ /// 인증 계약 — 제공자 인터페이스와 결과 타입. /// -/// 원칙: **"키 미투입"은 예외가 아니라 상태다.** 카카오·네이버는 발주사 사업자 정보로 -/// 개발자 콘솔에 앱을 등록해야 키가 나오고(Q3), Google·Apple 은 Firebase 설정 파일이 -/// 있어야 한다(S1·S2). 둘 다 "아직 준비가 안 됐다"이지 "지원하지 않는다"가 아니므로 -/// 예외를 던지지 않고 [AuthNotConfigured] 로 돌려준다. 화면은 이걸 안내로 보여 준다. +/// 원칙: **"키 미투입"은 예외가 아니라 상태다.** Apple·카카오·LINE 은 발주사 사업자 +/// 정보로 각 개발자 콘솔에 앱을 등록해야 키가 나온다. "아직 준비가 안 됐다"이지 +/// "지원하지 않는다"가 아니므로 예외를 던지지 않고 [AuthNotConfigured] 로 돌려준다. +/// 화면은 이걸 안내 다이얼로그로 보여 준다. library; -/// 가입 수단 — **정책 설계서 §P-01 확정값 5종**(답변서 v1.1 Q7). +/// 로그인 수단 — **별첨 B v1.4 확정값**. /// -/// 초안 당시 CD-18 이 미확정이라 소셜 4종(카카오·네이버·Apple·Google)만 두었는데, -/// 답변서가 **이메일 · Google · Apple · Kakao · LINE** 으로 확정했다. -/// **네이버는 확정 목록에 없고, 이메일과 LINE 이 새로 들어왔다.** +/// > 1차 로그인은 Google / Apple / Kakao / LINE 4종입니다. 이메일/비밀번호 가입 및 +/// > X 로그인은 제외합니다. 하나의 FANUZU UID 에 복수 소셜 Provider 연결은 허용합니다. /// -/// 앱 Figma 에는 WeChat · X 버튼도 그려져 있으나 답변서가 5종으로 정했으므로 -/// 1차는 5종이다. 추가 시 재작업이 없도록 제공자를 인터페이스 뒤에 둔다. +/// 답변서 v1.1 Q7 은 이메일을 포함한 5종이었으나 별첨 B v1.4 가 이를 뒤집었다. +/// [email] 값이 남아 있는 것은 **QA 계정(qa-test@fanuzu.co.kr) 내부 경로** 하나 +/// 때문이다 — 사용자에게 노출되는 목록([AuthRepository.providers])에는 들어가지 +/// 않는다. X(트위터)는 확정 목록에 없어 값 자체를 두지 않는다. enum AuthProviderKind { email('이메일로 시작하기'), kakao('카카오로 시작하기'), @@ -26,7 +27,7 @@ enum AuthProviderKind { /// 버튼 라벨. 임의로 바꾸지 마라 — 화면 대조 대상이다. final String label; - /// 이메일은 소셜이 아니라 입력 폼이 붙는다. 화면이 이걸로 갈린다. + /// 이메일은 소셜이 아니라 입력 폼이 붙는다. QA 내부 경로만 이걸 쓴다. bool get needsForm => this == AuthProviderKind.email; } @@ -101,11 +102,12 @@ abstract interface class SocialAuthProvider { Future signOut(); } -/// 이메일 제공자만 쓰는 확장 계약. +/// 이메일 제공자 — **QA 계정 전용 내부 경로**. /// -/// 소셜은 외부 창이 열리고 끝이지만 이메일은 **앱 안에서 값을 받아야 한다.** -/// [SocialAuthProvider.signIn] 하나로 묶으면 화면이 제공자 종류를 보고 분기하게 -/// 되므로, 입력이 필요한 쪽만 별도 계약으로 뺀다. +/// 별첨 B v1.4 가 이메일/비밀번호 가입을 1차 범위에서 뺐다. 코드를 지우지 않은 것은 +/// 발주사 QA 계정(qa-test@fanuzu.co.kr)으로 로그인 이후 화면을 확인해야 하기 +/// 때문이며, [AuthRepository.qaEmailProvider] 가 **디버그 빌드에서만** 이 구현체를 +/// 돌려준다. 릴리스 빌드에는 부를 수 있는 경로가 없다. abstract interface class EmailAuthProvider implements SocialAuthProvider { Future signInWithEmail(String email, String password); @@ -126,3 +128,72 @@ abstract interface class AuthSession { Future signOut(); } + +/// 「동일 이메일만 연동 가능」 판정 — 명세 `554:22797` · 안내 문구 `554:23251`. +/// +/// **로컬 파트만 비교한다.** 명세의 예시가 `test123@naver.com` 과 +/// `test123@gmail.com` 을 같다고 보므로 도메인은 보지 않는다. 전체 문자열을 +/// 비교하면 명세가 (O) 로 적은 사례가 막힌다. +bool sameAccountIdentity(String a, String b) => _localPart(a) == _localPart(b); + +String _localPart(String email) => email.trim().toLowerCase().split('@').first; + +/// 연동 화면에 거는 제공자 — 별첨 B v1.4 확정 4종. +/// 이메일은 QA 내부 경로라 목록에 없다. +const List linkableProviders = [ + AuthProviderKind.google, + AuthProviderKind.apple, + AuthProviderKind.kakao, + AuthProviderKind.line, +]; + +/// 하나의 FANUZU UID 에 소셜 Provider 를 더 붙이거나 떼는 경계. +/// +/// 별첨 B v1.4 — *하나의 FANUZU UID 에 복수 소셜 Provider 연결은 허용합니다.* +/// Firebase 는 `User.linkWithCredential` / `User.unlink` 로 이걸 지원한다. +/// 저장소와 화면은 이 인터페이스만 알고 SDK 는 어댑터 뒤에 둔다 — 덕분에 설정 +/// 파일 없이도 연결·해제 규칙을 단위 테스트할 수 있다. +abstract interface class AccountLinker { + /// 지금 UID 에 붙어 있는 Provider → 그 Provider 가 준 이메일(모르면 null). + /// + /// 설정 화면의 「SNS 연동」 목록이 이걸 그대로 그린다. + Map get linkedAccounts; + + Future link(AuthProviderKind provider); + + /// 마지막 하나 남은 Provider 는 뗄 수 없다 — 떼면 로그인 수단이 사라진다. + Future unlink(AuthProviderKind provider); +} + +/// 설정 파일·키가 없어 실제로는 붙일 수 없는 상태의 연동 경계. +/// +/// 목 모드에서도 「SNS 연동」 화면 자체는 열려야 한다(별첨2 확인 항목의 화면 동선). +/// 상태를 메모리에만 들고 있고 UID 에는 아무 일도 하지 않는다. +final class SampleAccountLinker implements AccountLinker { + SampleAccountLinker({Map? seed}) + : _linked = {...?seed}; + + final Map _linked; + + @override + Map get linkedAccounts => {..._linked}; + + @override + Future link(AuthProviderKind provider) async { + _linked[provider] = 'fan@fanuzu.co.kr'; + return AuthSucceeded( + AuthAccount(uid: 'mock-local', provider: provider, isMock: true), + ); + } + + @override + Future unlink(AuthProviderKind provider) async { + if (_linked.length <= 1) { + return const AuthFailed('E-AUTH-13', '마지막 로그인 수단은 연결을 해제할 수 없습니다.'); + } + _linked.remove(provider); + return AuthSucceeded( + AuthAccount(uid: 'mock-local', provider: provider, isMock: true), + ); + } +} diff --git a/app/lib/features/auth/auth_repository.dart b/app/lib/features/auth/auth_repository.dart index 1e4b2e0..1dec3e1 100644 --- a/app/lib/features/auth/auth_repository.dart +++ b/app/lib/features/auth/auth_repository.dart @@ -11,8 +11,10 @@ class AuthRepository extends ChangeNotifier { AuthRepository({ List? providers, AuthSession? session, + EmailAuthProvider? qaEmailProvider, bool? releaseMode, }) : providers = providers ?? _defaultProviders(), + _qaEmail = qaEmailProvider ?? const EmailPasswordProvider(), _session = session, _account = session?.currentAccount, // 기본값은 실제 빌드 모드. 테스트에서만 주입해 릴리스 동작을 재현한다 @@ -23,21 +25,28 @@ class AuthRepository extends ChangeNotifier { final bool _releaseMode; final AuthSession? _session; + final EmailAuthProvider _qaEmail; StreamSubscription? _sessionSubscription; - /// 로그인 화면에 그릴 제공자 목록. 순서는 시안 S-A-LOGIN 버튼 순서다. + /// 로그인 화면에 그릴 제공자 목록. **여기 없는 수단은 사용자가 닿을 수 없다.** final List providers; AuthAccount? _account; AuthAccount? get account => _account; bool get isSignedIn => _account != null; - /// 정책 설계서 §P-01 확정 5종. **순서가 화면 버튼 순서다.** + /// **별첨 B v1.4 확정 4종.** 순서가 회신 문구 순서이자 화면 버튼 순서다. /// - /// 네이버가 빠지고 이메일·LINE 이 들어왔다(답변서 Q7). 카카오·LINE 은 개발자 - /// 콘솔 앱 등록에 발주사 사업자 정보가 필요해 아직 키가 없다. + /// > 1차 로그인은 Google / Apple / Kakao / LINE 4종입니다. 이메일/비밀번호 가입 및 + /// > X 로그인은 제외합니다. + /// + /// **이메일은 여기 없다.** 목록에 없으면 화면이 버튼을 그리지 않고, 버튼이 없으면 + /// 사용자가 닿는 경로가 없다 — 읽는 분기가 없는 기능 플래그로 닫는 것보다 확실하다. + /// QA 경로는 [qaEmailProvider] 하나뿐이다. + /// + /// Apple·카카오·LINE 은 발주사 키가 없어 「키 대기」 상태다. 눌러도 예외가 아니라 + /// [AuthNotConfigured] 가 돌아오고 화면이 안내 다이얼로그를 띄운다. static List _defaultProviders() => [ - const EmailPasswordProvider(), const GoogleSignInProvider(), AppleSignInProvider( webAuthenticationOptions: appleWebAuthenticationOptionsFromEnvironment(), @@ -49,21 +58,23 @@ class AuthRepository extends ChangeNotifier { lineProvider, ]; - /// 이메일 제공자. 화면이 입력 폼을 띄우기 전에 있는지 확인한다. - EmailAuthProvider? get emailProvider { - for (final p in providers) { - if (p is EmailAuthProvider) return p; - } - return null; - } + /// QA 계정(qa-test@fanuzu.co.kr) 전용 이메일 로그인 — **디버그 빌드에서만**. + /// + /// 별첨 B v1.4 가 이메일/비밀번호 가입을 1차 범위에서 뺐다. 그래도 발주사가 + /// 로그인 이후 화면을 확인하려면 실 계정 하나로 들어갈 수단이 필요해 코드를 + /// 남겼고, 릴리스 빌드에서는 null 이라 부를 수 있는 경로 자체가 없다. + /// + /// [isMockSessionAvailable] 과 같은 이유로 빌드 모드로 막는다 — 릴리스 + /// 산출물에 내부 우회 입구가 새어 나가면 안 된다. + EmailAuthProvider? get qaEmailProvider => _releaseMode ? null : _qaEmail; - /// 이메일 로그인·가입. 결과가 성공이면 세션을 갈아 끼운다. + /// QA 이메일 로그인·가입. 결과가 성공이면 세션을 갈아 끼운다. Future signInWithEmail({ required String email, required String password, bool signUp = false, }) async { - final impl = emailProvider; + final impl = qaEmailProvider; if (impl == null) { return const AuthFailed('E-AUTH-03', '이메일 로그인을 쓸 수 없습니다.'); } @@ -81,7 +92,14 @@ class AuthRepository extends ChangeNotifier { } Future signIn(SocialProvider provider) async { - final impl = providers.firstWhere((p) => p.provider == provider); + final impl = providers.firstWhere( + (p) => p.provider == provider, + orElse: () => throw ArgumentError.value( + provider, + 'provider', + '노출 목록에 없는 로그인 수단이다 (별첨 B v1.4 확정 4종)', + ), + ); late final AuthOutcome outcome; try { outcome = await impl.signIn(); diff --git a/app/lib/features/auth/firebase_account_linker.dart b/app/lib/features/auth/firebase_account_linker.dart new file mode 100644 index 0000000..51b1194 --- /dev/null +++ b/app/lib/features/auth/firebase_account_linker.dart @@ -0,0 +1,131 @@ +import 'package:firebase_auth/firebase_auth.dart' as fb; +import 'package:flutter/foundation.dart'; + +import 'auth.dart'; +import 'social_providers.dart'; + +/// 하나의 FANUZU UID 에 소셜 Provider 를 붙이고 떼는 Firebase 어댑터. +/// +/// 별첨 B v1.4 — *하나의 FANUZU UID 에 복수 소셜 Provider 연결은 허용합니다.* +/// +/// **새 계정을 만들지 않는다.** `signInWithCredential` 은 자격증명에 해당하는 계정이 +/// 없으면 새로 만들어 버리지만 `linkWithCredential` 은 지금 로그인한 UID 에 붙이기만 +/// 한다. 이 구분이 「UID 는 하나」를 지키는 지점이라 연결 경로는 링크 API 만 쓴다. +final class FirebaseAccountLinker implements AccountLinker { + FirebaseAccountLinker({ + required List providers, + fb.FirebaseAuth? firebaseAuth, + }) : _providers = providers, + _firebaseAuth = firebaseAuth ?? fb.FirebaseAuth.instance; + + final List _providers; + final fb.FirebaseAuth _firebaseAuth; + + /// Firebase providerId ↔ 앱 제공자. `authStateChanges` 로 오는 값과 같은 표를 쓴다. + static const Map providerIds = { + AuthProviderKind.google: 'google.com', + AuthProviderKind.apple: 'apple.com', + // 카카오·LINE 은 Firebase 기본 제공자가 아니라 OIDC 커스텀 제공자로 붙는다. + // 접두사 `oidc.` 는 Firebase 콘솔 등록 시 강제되는 형식이다. + AuthProviderKind.kakao: 'oidc.kakao', + AuthProviderKind.line: 'oidc.line', + AuthProviderKind.email: 'password', + }; + + @override + Map get linkedAccounts { + final data = _firebaseAuth.currentUser?.providerData; + if (data == null) return const {}; + final byId = {for (final d in data) d.providerId: d.email}; + return { + for (final entry in providerIds.entries) + if (byId.containsKey(entry.value)) entry.key: byId[entry.value], + }; + } + + @override + Future link(AuthProviderKind provider) async { + final user = _firebaseAuth.currentUser; + if (user == null) { + return const AuthFailed('E-AUTH-03', '로그인이 필요합니다.'); + } + final impl = _implOf(provider); + if (impl == null) { + return AuthNotConfigured(provider, const ['연결 가능한 제공자 구현']); + } + final missing = impl.missingConfig; + if (missing.isNotEmpty) return AuthNotConfigured(provider, missing); + // 키 대기 제공자(카카오·LINE)는 자격증명을 만들 수 없다. + if (impl is! CredentialProvider) { + return AuthNotConfigured(provider, const ['자격증명 구현']); + } + final source = impl as CredentialProvider; + + try { + final obtained = await source.obtainCredential(); + // 사용자가 소셜 창을 닫았다. 실패로 세지 않는다. + if (obtained == null) return const AuthCancelled(); + + // 명세 `554:22797` — *동일 이메일만 연동 가능*. 붙이기 **전에** 막는다. + // 붙인 뒤 되돌리는 방식은 unlink 가 실패하면 남의 계정이 붙은 채로 남는다. + // + // ponytail: 제공자가 이메일을 알려 줄 때만 판정한다. Apple 은 최초 인가 + // 때만 이메일을 주므로 재연동 시 통과한다 — 서버(fn-account-link)에서 + // 다시 보려면 Functions 층에 같은 판정을 둔다. + final mine = user.email; + final theirs = obtained.email; + if (mine != null && + theirs != null && + !sameAccountIdentity(mine, theirs)) { + return const AuthFailed('E-AUTH-08', '가입하신 이메일과 다른 계정입니다.'); + } + + final result = await user.linkWithCredential(obtained.credential); + return AuthSucceeded(_accountOf(result.user, provider)); + } on fb.FirebaseAuthException catch (error) { + return AuthFailed(authCatalogCode(error), authMessage(error)); + } on Object catch (error) { + // SDK 가 문서화하지 않은 플랫폼 예외를 던져도 화면이 busy 에 고착되지 않게 닫는다. + debugPrint('[FirebaseAccountLinker] link failed: ${error.runtimeType}'); + return const AuthFailed('E-AUTH-03', '계정 연결에 실패했습니다.'); + } + } + + @override + Future unlink(AuthProviderKind provider) async { + final user = _firebaseAuth.currentUser; + if (user == null) { + return const AuthFailed('E-AUTH-03', '로그인이 필요합니다.'); + } + // **마지막 하나는 떼지 않는다.** 떼는 순간 로그인 수단이 0개가 되어 계정에 + // 다시 들어올 방법이 사라진다 — Firebase 는 이걸 막아 주지 않는다. + if (linkedAccounts.length <= 1) { + return const AuthFailed('E-AUTH-13', '마지막 로그인 수단은 연결을 해제할 수 없습니다.'); + } + final id = providerIds[provider]; + if (id == null) { + return const AuthFailed('E-AUTH-10', '연결되어 있지 않은 계정입니다.'); + } + try { + await user.unlink(id); + return AuthSucceeded(_accountOf(_firebaseAuth.currentUser, provider)); + } on fb.FirebaseAuthException catch (error) { + return AuthFailed(authCatalogCode(error), authMessage(error)); + } + } + + SocialAuthProvider? _implOf(AuthProviderKind provider) { + for (final candidate in _providers) { + if (candidate.provider == provider) return candidate; + } + return null; + } + + static AuthAccount _accountOf(fb.User? user, AuthProviderKind provider) => + AuthAccount( + uid: user?.uid ?? '', + provider: provider, + displayName: user?.displayName, + email: user?.email, + ); +} diff --git a/app/lib/features/auth/login_screen.dart b/app/lib/features/auth/login_screen.dart index 62d20b8..2a43e5c 100644 --- a/app/lib/features/auth/login_screen.dart +++ b/app/lib/features/auth/login_screen.dart @@ -5,11 +5,15 @@ import '../../core/theme/tokens.dart'; import 'auth.dart'; import 'auth_repository.dart'; -/// 로그인 — Figma `03_Wireframe` 「로그인」(`510:86330`). +/// 로그인 — Figma `03_Wireframe` 「로그인」(`173:818` · `510:86330`). /// -/// **가입 수단은 정책 설계서 §P-01 확정 5종**이다(답변서 Q7) — 이메일 · Google · -/// Apple · Kakao · LINE. 이메일만 앱 안에서 값을 받으므로 폼이 붙고, 나머지는 -/// 버튼 하나로 외부 창을 연다. +/// **로그인 수단은 별첨 B v1.4 확정 4종**이다 — Google · Apple · Kakao · LINE. +/// 같은 회신이 이메일/비밀번호 가입과 X 로그인을 1차 범위에서 뺐으므로 이 화면에 +/// 이메일 입력 폼으로 가는 **사용자 경로가 없다.** Figma 로그인 화면군의 +/// 「회원가입」·「비밀번호 재설정」 동선도 같은 이유로 그리지 않는다. +/// +/// 이메일 폼 자체는 발주사 QA 계정(qa-test@fanuzu.co.kr) 확인용으로 남아 있으나 +/// **디버그 빌드에서 로고를 길게 눌러야만** 열린다([_openQaForm]). /// /// 약관 동의(S-A-TERMS) 이후 동선은 09-10 제출분이다. class LoginScreen extends StatefulWidget { @@ -29,8 +33,8 @@ class _LoginScreenState extends State { String? _notice; bool _busy = false; - /// 이메일 폼이 펼쳐졌는가. 처음에는 접어 둔다 — 소셜이 주 경로다. - bool _emailOpen = false; + /// QA 이메일 폼이 열렸는가. 숨은 입구로만 열린다(별첨 B v1.4 — 이메일 가입 제외). + bool _qaFormOpen = false; /// 가입 모드인가. 로그인과 가입을 한 버튼에 합치지 않는다. bool _signUp = false; @@ -63,7 +67,7 @@ class _LoginScreenState extends State { setState(() => _notice = '재설정 메일을 받으실 이메일을 먼저 입력해 주세요.'); return; } - final impl = widget.auth.emailProvider; + final impl = widget.auth.qaEmailProvider; if (impl == null) return; setState(() => _busy = true); final outcome = await impl.sendPasswordReset(email); @@ -90,12 +94,46 @@ class _LoginScreenState extends State { setState(() => _busy = true); final outcome = await widget.auth.signIn(provider); if (!mounted) return; - setState(() { - _busy = false; - _notice = _noticeOf(outcome); - }); + setState(() => _busy = false); + // 키 대기 제공자는 안내 다이얼로그로 끊는다 — 눌렀는데 아무 일도 안 일어나면 + // 사용자는 앱이 멈춘 줄 안다. + if (outcome is AuthNotConfigured) { + await _showNotConfigured(outcome); + return; + } + if (!mounted) return; + setState(() => _notice = _noticeOf(outcome)); } + Future _showNotConfigured(AuthNotConfigured outcome) async { + final name = outcome.provider.label.replaceAll('로 시작하기', ''); + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('$name 로그인 준비 중'), + content: Text( + '$name 로그인은 발주사 키가 들어오면 열립니다.\n\n' + '필요한 항목\n· ${outcome.missing.join('\n· ')}', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('확인'), + ), + ], + ), + ); + } + + /// QA 계정 전용 숨은 입구 — **디버그 빌드에서 로고 길게 누르기**. + /// + /// 별첨 B v1.4 가 이메일 가입을 뺐으므로 눈에 보이는 진입점을 두지 않는다. + /// 디버그 APK 도 발주사가 설치해 보는 산출물이라, 보이는 「QA 로그인」 버튼은 + /// 그 자체로 「이메일 가입이 살아 있다」로 읽힌다. + VoidCallback? get _openQaForm => widget.auth.qaEmailProvider == null + ? null + : () => setState(() => _qaFormOpen = !_qaFormOpen); + @override Widget build(BuildContext context) { final text = Theme.of(context).textTheme; @@ -110,20 +148,23 @@ class _LoginScreenState extends State { // 시안 로그인(173:1905)은 화면 위쪽 가운데에 핑크 원(로고 자리) 하나만 둔다. // 로고 원본은 미확정(Q6)이라 원 안에 워드마크를 텍스트로 넣는다. Center( - child: Container( - width: 120, - height: 120, - decoration: const BoxDecoration( - color: AppColors.cosmicAccent, - shape: BoxShape.circle, - ), - alignment: Alignment.center, - child: Text( - 'FANUZU', - style: text.titleLarge?.copyWith( - color: AppColors.onCosmicAccent, - fontFamily: AppFonts.display, - fontWeight: FontWeight.w700, + child: GestureDetector( + onLongPress: _openQaForm, + child: Container( + width: 120, + height: 120, + decoration: const BoxDecoration( + color: AppColors.cosmicAccent, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Text( + 'FANUZU', + style: text.titleLarge?.copyWith( + color: AppColors.onCosmicAccent, + fontFamily: AppFonts.display, + fontWeight: FontWeight.w700, + ), ), ), ), @@ -131,44 +172,46 @@ class _LoginScreenState extends State { const SizedBox(height: AppSpace.s6), Text( '당신의 팬 활동을\n하나의 행성으로', - style: text.headlineSmall?.copyWith(fontWeight: FontWeight.w500), + style: text.headlineSmall?.copyWith( + fontWeight: FontWeight.w500, + ), textAlign: TextAlign.center, ), const SizedBox(height: AppSpace.s3), Text( '로그인하면 이용약관과 개인정보 처리방침에 동의하는 절차로 이동합니다.', - style: text.bodySmall?.copyWith(color: AppColors.onCosmicVariant), + style: text.bodySmall?.copyWith( + color: AppColors.onCosmicVariant, + ), textAlign: TextAlign.center, ), const SizedBox(height: AppSpace.s8), Text('계정으로 시작하기', style: text.titleMedium), const SizedBox(height: AppSpace.s2), - Text( - '사용 중인 계정 하나를 선택해 주세요.', - style: text.bodySmall, - ), + Text('사용 중인 계정 하나를 선택해 주세요.', style: text.bodySmall), const SizedBox(height: AppSpace.s4), for (final p in widget.auth.providers) ...[ _ProviderButton( provider: p.provider, + // 발주사 키가 아직 없는 제공자. 눌러도 안내 다이얼로그로 닫힌다. + awaitingKeys: p.missingConfig.isNotEmpty, enabled: !_busy, - onPressed: p.provider.needsForm - ? () => setState(() => _emailOpen = !_emailOpen) - : () => _signIn(p.provider), + onPressed: () => _signIn(p.provider), + ), + const SizedBox(height: AppSpace.s3), + ], + if (_qaFormOpen) ...[ + const SizedBox(height: AppSpace.s2), + _EmailForm( + formKey: _form, + email: _email, + password: _password, + signUp: _signUp, + busy: _busy, + onSubmit: _submitEmail, + onToggleMode: () => setState(() => _signUp = !_signUp), + onReset: _resetPassword, ), - if (p.provider.needsForm && _emailOpen) ...[ - const SizedBox(height: AppSpace.s3), - _EmailForm( - formKey: _form, - email: _email, - password: _password, - signUp: _signUp, - busy: _busy, - onSubmit: _submitEmail, - onToggleMode: () => setState(() => _signUp = !_signUp), - onReset: _resetPassword, - ), - ], const SizedBox(height: AppSpace.s3), ], if (_notice != null) ...[ @@ -194,11 +237,17 @@ class _LoginScreenState extends State { class _ProviderButton extends StatelessWidget { const _ProviderButton({ required this.provider, + required this.awaitingKeys, required this.enabled, required this.onPressed, }); final SocialProvider provider; + + /// 「키 대기」 배지를 붙일지. **버튼을 비활성으로 만들지 않는다** — 눌러야 + /// 무엇이 없어서 못 쓰는지 안내가 뜬다. 회색 버튼은 사유를 말해 주지 않는다. + final bool awaitingKeys; + final bool enabled; final VoidCallback onPressed; @@ -227,7 +276,23 @@ class _ProviderButton extends StatelessWidget { disabledBackgroundColor: fill.withValues(alpha: 0.4), side: stroke == null ? null : BorderSide(color: stroke), ), - child: Text(provider.label), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Text(provider.label, overflow: TextOverflow.ellipsis), + ), + if (awaitingKeys) ...[ + const SizedBox(width: AppSpace.s2), + Text( + keyPendingBadge, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: label.withValues(alpha: 0.7), + ), + ), + ], + ], + ), ); } } @@ -265,17 +330,13 @@ class _MockBanner extends StatelessWidget { const SizedBox(height: AppSpace.s2), Text(FirebaseBoot.reason, style: text.bodySmall), const SizedBox(height: AppSpace.s3), - OutlinedButton( - onPressed: onEnter, - child: const Text('목 세션으로 탭 둘러보기'), - ), + OutlinedButton(onPressed: onEnter, child: const Text('목 세션으로 탭 둘러보기')), ], ); } } - -/// 이메일 입력 폼. +/// QA 계정 전용 이메일 입력 폼 — 숨은 입구로만 열린다. /// /// Figma 「로그인 입력 영역」 명세 — /// *이메일 입력 필드 - 이메일 형식 입력 · 비밀번호 입력 필드 - 입력 시 마스킹 처리 ·* @@ -301,7 +362,6 @@ class _EmailForm extends StatelessWidget { final VoidCallback onToggleMode; final VoidCallback onReset; - @override Widget build(BuildContext context) { final t = Theme.of(context).textTheme; @@ -378,6 +438,9 @@ class _EmailForm extends StatelessWidget { } } +/// 발주사 키를 기다리는 제공자 버튼에 붙는 배지. +/// 「미지원」이 아니라 「대기」다 — 상태이지 결론이 아니다. +const String keyPendingBadge = '키 대기'; /// 이메일 폼이 쓰는 수치. 테스트가 문구를 손으로 적지 않게 밖으로 뺀다. abstract final class EmailFormMetrics { diff --git a/app/lib/features/auth/social_providers.dart b/app/lib/features/auth/social_providers.dart index 24cdf37..dd120f2 100644 --- a/app/lib/features/auth/social_providers.dart +++ b/app/lib/features/auth/social_providers.dart @@ -9,11 +9,30 @@ import 'package:sign_in_with_apple/sign_in_with_apple.dart' as apple; import '../../core/firebase/firebase_boot.dart'; import 'auth.dart'; +/// Firebase 자격증명을 만들 수 있는 제공자. +/// +/// 로그인과 **계정 연결**(별첨 B v1.4 「복수 소셜 Provider 연결 허용」)은 자격증명을 +/// 얻는 데까지 완전히 같고 그 뒤 `signInWithCredential` 이냐 `linkWithCredential` +/// 이냐만 다르다. 그 공통 부분을 여기로 뺀다 — 두 벌로 적으면 nonce 같은 보안 +/// 절차가 한쪽에서만 고쳐진다. +abstract interface class CredentialProvider { + /// 사용자가 취소하면 null. 쓸 수 없는 상태면 [AuthNotConfigured] 를 던지는 대신 + /// 호출부가 [SocialAuthProvider.missingConfig] 를 먼저 본다. + /// + /// [SocialCredential.email] 은 「동일 이메일만 연동 가능」(명세 `554:22797`) + /// 판정에 쓴다. 제공자가 알려 주지 않으면 null 이다. + Future obtainCredential(); +} + +/// 자격증명 한 벌과, 판정에 쓸 그 계정의 이메일. +typedef SocialCredential = ({fb.AuthCredential credential, String? email}); + /// Google 로그인 — 실 코드 경로. /// /// Firebase 설정 파일이 없으면 SDK 를 부르기 전에 [AuthNotConfigured] 로 끊는다. /// 파일이 투입되면(S2) 이 클래스는 수정 없이 그대로 동작한다. -final class GoogleSignInProvider implements SocialAuthProvider { +final class GoogleSignInProvider + implements SocialAuthProvider, CredentialProvider { const GoogleSignInProvider(); static Future? _initialization; @@ -30,23 +49,32 @@ final class GoogleSignInProvider implements SocialAuthProvider { List get missingConfig => FirebaseBoot.isLive ? const [] : FirebaseBoot.missingLabels; + @override + Future obtainCredential() async { + final signIn = gsi.GoogleSignIn.instance; + // clientId/serverClientId 는 설정 파일에서 읽힌다 — 여기서 하드코딩하지 않는다. + await (_initialization ??= signIn.initialize()); + final account = await signIn.authenticate(); + final idToken = account.authentication.idToken; + if (idToken == null) return null; + return ( + credential: fb.GoogleAuthProvider.credential(idToken: idToken), + email: account.email, + ); + } + @override Future signIn() async { final missing = missingConfig; if (missing.isNotEmpty) return AuthNotConfigured(provider, missing); try { - final signIn = gsi.GoogleSignIn.instance; - // clientId/serverClientId 는 설정 파일에서 읽힌다 — 여기서 하드코딩하지 않는다. - await (_initialization ??= signIn.initialize()); - final account = await signIn.authenticate(); - final idToken = account.authentication.idToken; - if (idToken == null) { + final obtained = await obtainCredential(); + if (obtained == null) { return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.'); } - final credential = fb.GoogleAuthProvider.credential(idToken: idToken); final result = await fb.FirebaseAuth.instance.signInWithCredential( - credential, + obtained.credential, ); return AuthSucceeded(_toAccount(result.user, provider)); } on gsi.GoogleSignInException catch (error) { @@ -55,7 +83,7 @@ final class GoogleSignInProvider implements SocialAuthProvider { } return AuthFailed('E-AUTH-03', error.description ?? error.code.name); } on fb.FirebaseAuthException catch (error) { - return AuthFailed('E-AUTH-03', error.message ?? error.code); + return AuthFailed(authCatalogCode(error), authMessage(error)); } } @@ -73,7 +101,8 @@ final class GoogleSignInProvider implements SocialAuthProvider { /// iOS 는 Xcode 에서 「Sign in with Apple」 Capability 를, Android·Web 은 /// Apple Developer 콘솔의 Service ID + redirect URI 를 각각 요구한다. /// 둘 다 스토어 계정 명의가 정해져야(Q5) 발급할 수 있어 지금은 설정 파일 게이트에서 끊긴다. -final class AppleSignInProvider implements SocialAuthProvider { +final class AppleSignInProvider + implements SocialAuthProvider, CredentialProvider { const AppleSignInProvider({this.webAuthenticationOptions}); /// Android·Web에서 필요한 Service ID와 검증된 callback URL. @@ -94,6 +123,36 @@ final class AppleSignInProvider implements SocialAuthProvider { if (!FirebaseBoot.isLive) 'Apple 개발자 설정', ]; + /// 시도마다 새 nonce 를 만들고 SHA-256 해시를 Apple 에, 원문을 Firebase 에 준다. + /// 재사용하면 탈취한 토큰을 되쏘는 replay 가 성립한다. + @override + Future obtainCredential() async { + final rawNonce = apple.generateNonce(); + final hashedNonce = sha256.convert(utf8.encode(rawNonce)).toString(); + final credential = await apple.SignInWithApple.getAppleIDCredential( + scopes: const [ + apple.AppleIDAuthorizationScopes.email, + apple.AppleIDAuthorizationScopes.fullName, + ], + webAuthenticationOptions: _requiresWebAuthentication + ? webAuthenticationOptions + : null, + nonce: hashedNonce, + ); + final idToken = credential.identityToken; + if (idToken == null) return null; + return ( + credential: fb.OAuthProvider('apple.com').credential( + idToken: idToken, + accessToken: credential.authorizationCode, + rawNonce: rawNonce, + ), + // Apple 은 **최초 인가 때만** 이메일을 준다. 두 번째부터는 null 이라 + // 「동일 이메일만 연동」 판정을 할 수 없다 — 그때는 통과시킨다. + email: credential.email, + ); + } + @override Future signIn() async { final missing = missingConfig; @@ -103,28 +162,13 @@ final class AppleSignInProvider implements SocialAuthProvider { return const AuthFailed('E-AUTH-03', '이 기기에서는 Apple 로그인을 쓸 수 없습니다.'); } try { - final rawNonce = apple.generateNonce(); - final hashedNonce = sha256.convert(utf8.encode(rawNonce)).toString(); - final credential = await apple.SignInWithApple.getAppleIDCredential( - scopes: const [ - apple.AppleIDAuthorizationScopes.email, - apple.AppleIDAuthorizationScopes.fullName, - ], - webAuthenticationOptions: _requiresWebAuthentication - ? webAuthenticationOptions - : null, - nonce: hashedNonce, - ); - final idToken = credential.identityToken; - if (idToken == null) { + final obtained = await obtainCredential(); + if (obtained == null) { return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.'); } - final oauth = fb.OAuthProvider('apple.com').credential( - idToken: idToken, - accessToken: credential.authorizationCode, - rawNonce: rawNonce, + final result = await fb.FirebaseAuth.instance.signInWithCredential( + obtained.credential, ); - final result = await fb.FirebaseAuth.instance.signInWithCredential(oauth); return AuthSucceeded(_toAccount(result.user, provider)); } on apple.SignInWithAppleAuthorizationException catch (error) { if (error.code == apple.AuthorizationErrorCode.canceled) { @@ -135,7 +179,7 @@ final class AppleSignInProvider implements SocialAuthProvider { debugPrint('[AppleSignInProvider] failed: ${error.runtimeType}'); return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.'); } on fb.FirebaseAuthException catch (error) { - return AuthFailed('E-AUTH-03', error.message ?? error.code); + return AuthFailed(authCatalogCode(error), authMessage(error)); } } @@ -148,10 +192,11 @@ final class AppleSignInProvider implements SocialAuthProvider { kIsWeb || defaultTargetPlatform == TargetPlatform.android; } -/// 앱 키가 없어 아직 붙이지 못한 제공자 — 카카오·LINE. +/// 발주사 키를 기다리는 제공자 — 카카오·LINE. /// -/// 예외를 던지지 않는다. [AuthNotConfigured] 로 "무엇이 없어서 못 쓰는지"를 돌려준다. -/// 키가 들어오면 이 자리에 실 구현체를 넣고 [AuthRepository] 목록만 바꾸면 된다. +/// 예외를 던지지 않는다. [AuthNotConfigured] 로 "무엇이 없어서 못 쓰는지"를 돌려주고 +/// 로그인 화면이 그걸 안내 다이얼로그로 띄운다. 키가 들어오면 이 자리에 실 구현체를 +/// 넣고 [AuthRepository] 목록만 바꾸면 된다 — 화면·라우터는 그대로다. final class KeylessSocialProvider implements SocialAuthProvider { const KeylessSocialProvider(this.provider, this.missingConfig); @@ -198,14 +243,18 @@ AuthAccount _toAccount(fb.User? user, SocialProvider provider) => AuthAccount( email: user?.email, ); -/// 이메일 · 비밀번호 — 실 코드 경로. +/// 이메일 · 비밀번호 — **QA 계정 전용 내부 경로**. /// -/// 정책 설계서 §P-01 이 가입 수단 5종에 이메일을 넣어 확정했다(답변서 Q7). -/// 초안 당시 CD-18 미확정이라 비워 두었던 자리다. +/// 별첨 B v1.4 가 1차 로그인을 Google/Apple/Kakao/LINE 4종으로 확정하고 +/// 이메일/비밀번호 가입을 제외했다. 그래서 이 구현체는 +/// [AuthRepository.providers] 에 들어가지 않는다 — 사용자가 닿을 수 있는 버튼이 +/// 없다. 남겨 둔 이유는 발주사 QA 계정(qa-test@fanuzu.co.kr)으로 로그인 이후 +/// 화면을 확인해야 하기 때문이고, [AuthRepository.qaEmailProvider] 가 +/// **디버그 빌드에서만** 이걸 돌려준다. /// /// **가입과 로그인을 한 버튼에 합치지 않는다.** 합치면 오타로 친 이메일이 /// 새 계정을 만들어 버리고, 사용자는 「비밀번호가 틀렸다」가 아니라 「내 데이터가 -/// 사라졌다」를 겪는다. Figma 도 로그인 화면과 회원가입 화면을 나눠 두었다. +/// 사라졌다」를 겪는다. final class EmailPasswordProvider implements EmailAuthProvider { const EmailPasswordProvider(); @@ -250,7 +299,7 @@ final class EmailPasswordProvider implements EmailAuthProvider { await fb.FirebaseAuth.instance.sendPasswordResetEmail(email: email.trim()); return const AuthCancelled(); } on fb.FirebaseAuthException catch (error) { - return AuthFailed(_code(error), _message(error)); + return AuthFailed(authCatalogCode(error), authMessage(error)); } } @@ -261,7 +310,7 @@ final class EmailPasswordProvider implements EmailAuthProvider { final result = await action(); return AuthSucceeded(_toAccount(result.user, provider)); } on fb.FirebaseAuthException catch (error) { - return AuthFailed(_code(error), _message(error)); + return AuthFailed(authCatalogCode(error), authMessage(error)); } } @@ -269,32 +318,46 @@ final class EmailPasswordProvider implements EmailAuthProvider { Future signOut() async { // Firebase 세션 종료는 AuthSession 이 한 번만 담당한다. } - - /// SDK 코드를 에러 카탈로그 ID 로 옮긴다. - /// - /// **계정 존재 여부를 드러내지 않는다.** 「등록되지 않은 이메일」과 「비밀번호 - /// 오류」를 갈라 주면 어느 이메일이 가입돼 있는지 훑을 수 있다. - static String _code(fb.FirebaseAuthException error) => switch (error.code) { - 'email-already-in-use' => 'E-AUTH-04', - 'weak-password' => 'E-AUTH-05', - 'invalid-email' => 'E-AUTH-06', - 'too-many-requests' => 'E-AUTH-07', - _ => 'E-AUTH-01', - }; - - static String _message(fb.FirebaseAuthException error) => switch (error.code) { - 'email-already-in-use' => '이미 가입된 이메일입니다.', - 'weak-password' => '비밀번호가 너무 짧습니다. 8자 이상으로 정해 주세요.', - 'invalid-email' => '이메일 형식이 올바르지 않습니다.', - 'too-many-requests' => '잠시 후 다시 시도해 주세요.', - _ => '이메일 또는 비밀번호를 확인해 주세요.', - }; } -/// LINE — 앱 키가 없어 아직 붙이지 못했다. +/// LINE — 발주사 키 대기. /// -/// 네이버 자리를 대신한다. 정책 §P-01 이 확정한 5종에 LINE 이 있고 네이버는 없다. +/// 별첨 B v1.4 확정 4종에 LINE 이 있고 네이버·X 는 없다. const lineProvider = KeylessSocialProvider(AuthProviderKind.line, [ 'LINE Login 채널 ID / 채널 시크릿', 'LINE Developers 콘솔 채널 등록(사업자 정보 필요)', ]); + +/// SDK 코드를 에러 카탈로그(`wireframe/planning/error-catalog.yaml`) ID 로 옮긴다. +/// +/// **계정 존재 여부를 드러내지 않는다.** 「등록되지 않은 이메일」과 「비밀번호 +/// 오류」를 갈라 주면 어느 이메일이 가입돼 있는지 훑을 수 있다. +String authCatalogCode(fb.FirebaseAuthException error) => switch (error.code) { + 'email-already-in-use' => 'E-AUTH-04', + 'weak-password' => 'E-AUTH-05', + 'invalid-email' => 'E-AUTH-06', + 'too-many-requests' => 'E-AUTH-07', + // 계정 연결(별첨 B v1.4) 경로에서만 나오는 코드들. + 'credential-already-in-use' || + 'account-exists-with-different-credential' => 'E-AUTH-08', + 'provider-already-linked' => 'E-AUTH-09', + 'no-such-provider' => 'E-AUTH-10', + // Firebase 콘솔에서 제공자를 켜지 않았을 때. 사유가 사용자가 아니라 설정에 있다. + 'operation-not-allowed' => 'E-AUTH-11', + 'requires-recent-login' => 'E-AUTH-12', + _ => 'E-AUTH-01', +}; + +String authMessage(fb.FirebaseAuthException error) => switch (error.code) { + 'email-already-in-use' => '이미 가입된 이메일입니다.', + 'weak-password' => '비밀번호가 너무 짧습니다. 8자 이상으로 정해 주세요.', + 'invalid-email' => '이메일 형식이 올바르지 않습니다.', + 'too-many-requests' => '잠시 후 다시 시도해 주세요.', + 'credential-already-in-use' || 'account-exists-with-different-credential' => + '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.', + 'provider-already-linked' => '이미 연결된 계정입니다.', + 'no-such-provider' => '연결되어 있지 않은 계정입니다.', + 'operation-not-allowed' => '이 로그인 수단은 아직 사용할 수 없습니다.', + 'requires-recent-login' => '보안을 위해 다시 로그인한 뒤 시도해 주세요.', + _ => '이메일 또는 비밀번호를 확인해 주세요.', +}; diff --git a/app/test/app_smoke_test.dart b/app/test/app_smoke_test.dart index c28d052..1e8e014 100644 --- a/app/test/app_smoke_test.dart +++ b/app/test/app_smoke_test.dart @@ -1,6 +1,8 @@ +import 'dart:async'; import 'dart:io'; import 'package:fancake/app.dart'; +import 'package:fancake/core/config/feature_flags.dart'; import 'package:fancake/core/firebase/firebase_boot.dart'; import 'package:fancake/core/router/app_router.dart'; import 'package:fancake/features/auth/auth.dart'; @@ -23,12 +25,60 @@ void main() { expect(tester.takeException(), isNull); expect(find.text('FANUZU'), findsOneWidget); - // 시안 S-A-LOGIN 의 소셜 버튼 4종이 라벨 그대로 있다. - for (final provider in SocialProvider.values) { - expect(find.text(provider.label), findsOneWidget, reason: provider.name); + // 별첨 B v1.4 확정 4종이 라벨 그대로 있다. + for (final provider in AuthRepository().providers) { + expect( + find.text(provider.provider.label), + findsOneWidget, + reason: provider.provider.name, + ); } }); + // 별첨 B v1.4 — *이메일/비밀번호 가입 및 X 로그인은 제외합니다.* + // 발주사가 「질의 없이 임의 변경」을 지적한 뒤의 회신이라 회귀 가드를 건다. + group('이메일 가입은 사용자에게 노출되지 않는다', () { + test('노출 제공자 목록은 Google·Apple·Kakao·LINE 4종뿐이다', () { + final kinds = AuthRepository().providers.map((p) => p.provider).toList(); + expect(kinds, const [ + AuthProviderKind.google, + AuthProviderKind.apple, + AuthProviderKind.kakao, + AuthProviderKind.line, + ]); + expect(kinds, isNot(contains(AuthProviderKind.email))); + }); + + testWidgets('로그인 화면에 이메일 버튼·가입·재설정 입구가 없다', (tester) async { + tester.view.physicalSize = const Size(1080, 2400); + tester.view.devicePixelRatio = 2; + addTearDown(tester.view.reset); + await tester.pumpWidget(FancakeApp(auth: AuthRepository())); + await tester.pumpAndSettle(); + + expect(find.text(AuthProviderKind.email.label), findsNothing); + expect(find.text('이메일'), findsNothing); + expect(find.text('비밀번호'), findsNothing); + expect(find.text('계정 만들기'), findsNothing); + expect(find.text('비밀번호 재설정'), findsNothing); + }); + + test('릴리스 빌드에는 QA 이메일 경로가 아예 없다', () async { + final auth = AuthRepository(releaseMode: true); + expect(auth.qaEmailProvider, isNull); + final outcome = await auth.signInWithEmail( + email: 'qa-test@fanuzu.co.kr', + password: 'password12', + ); + expect(outcome, isA()); + expect(auth.isSignedIn, isFalse); + }); + + test('디버그 빌드에서는 QA 이메일 경로가 살아 있다', () { + expect(AuthRepository(releaseMode: false).qaEmailProvider, isNotNull); + }); + }); + testWidgets('저장된 세션이 있으면 로그인 화면 대신 5탭 셸로 복원된다', (tester) async { final session = _SmokeSession( const AuthAccount(uid: 'restored-user', provider: SocialProvider.google), @@ -56,9 +106,58 @@ void main() { .map((d) => d.label) .toList(); - // 하드코딩이 아니라 계약 원본(design/craft-allowlist.yaml)과 대조한다. - expect(rendered, _contractTabs()); - expect(rendered, appTabs.map((t) => t.label).toList()); + // 별첨 B v1.4 — STATION 은 사용자 탭에서 비노출. 계약(5탭)은 그대로 두고 + // 화면에 그리는 목록만 줄인다. + expect(rendered, ['HOME', 'INSIDE', 'GALAXY', 'MY']); + expect(rendered, visibleTabs(FeatureFlags()).map((t) => t.label).toList()); + expect(find.text('STATION'), findsNothing); + }); + + // 계약(견적서 v2.1 · craft-allowlist.yaml · 시안 탭바)은 5탭 그대로다. + // 화면에서 뺀 것과 계약에서 뺀 것은 다른 일이고, 뒤엣것은 하지 않았다. + test('2-1. 계약 고정 5탭 표기·순서는 그대로다', () { + expect(appTabs.map((t) => t.label).toList(), _contractTabs()); + }); + + group('STATION 은 원격 플래그로 켠다 (별첨 B v1.4 · 재배포 없이)', () { + test('플래그 기본값은 꺼짐이다', () { + expect(FeatureFlags().stationTab, isFalse); + expect(visibleTabs(FeatureFlags()), hasLength(4)); + }); + + test('config/features 의 stationEnabled 가 true 면 5탭이 된다', () async { + final source = StreamController>(); + final flags = FeatureFlags(source: source.stream); + addTearDown(flags.dispose); + + source.add({FeatureFlags.stationKey: true}); + await Future.delayed(Duration.zero); + + expect(flags.stationTab, isTrue); + expect( + visibleTabs(flags).map((t) => t.label).toList(), + appTabs.map((t) => t.label).toList(), + ); + await source.close(); + }); + + test('문자열 "true" 나 읽기 실패로는 켜지지 않는다', () async { + final source = StreamController>(); + final flags = FeatureFlags(source: source.stream); + addTearDown(flags.dispose); + + source.add({FeatureFlags.stationKey: 'true'}); + source.addError(StateError('permission-denied')); + await Future.delayed(Duration.zero); + + expect(flags.stationTab, isFalse); + await source.close(); + }); + + test('STATION 라우트는 남아 있다 — 비노출이지 삭제가 아니다', () { + final station = appTabs.firstWhere((t) => t.label == 'STATION'); + expect(station.path, '/station'); + }); }); // A-01 회귀 가드 — 목 우회 입구가 배포 앱에 새어 나가지 않는지. @@ -143,7 +242,7 @@ void main() { 'HOME': ('S-H-HOME', '오늘의 퀘스트'), 'INSIDE': ('S-I-LOUNGE', '팬덤 라운지'), 'GALAXY': ('S-G-PLANET', '내 행성'), - 'STATION': ('S-S-HUB', 'STATION 허브'), + // STATION 은 하단 탭에 없다(별첨 B v1.4). 나머지 4탭을 확인한다. 'MY': ('S-M-HOME', '마이 페이지'), }; diff --git a/app/test/auth_email_test.dart b/app/test/auth_email_test.dart index 493e9d3..52637af 100644 --- a/app/test/auth_email_test.dart +++ b/app/test/auth_email_test.dart @@ -4,10 +4,13 @@ import 'package:fancake/features/auth/login_screen.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -/// 이메일 로그인 — 정책 설계서 §P-01 확정 가입 수단. +/// 이메일 로그인 — **QA 계정 전용 내부 경로**. /// -/// 초안 당시 CD-18 미확정이라 비워 두었던 자리이며, 답변서 v1.1 Q7 이 -/// **이메일 · Google · Apple · Kakao · LINE 5종**으로 확정했다. +/// 별첨 B v1.4 가 1차 로그인을 Google/Apple/Kakao/LINE 4종으로 확정하고 +/// 이메일/비밀번호 가입을 제외했다. 그래서 여기서 지키는 것은 두 가지다: +/// ① 사용자에게 노출되는 목록에 이메일이 없을 것 +/// ② 그럼에도 발주사 QA 계정(qa-test@fanuzu.co.kr)으로는 들어갈 수 있을 것 — +/// 단 디버그 빌드에서 숨은 입구(로고 길게 누르기)로만. /// 이메일 제공자 대역. Firebase 없이 결과만 돌려준다. class _FakeEmail implements EmailAuthProvider { @@ -71,13 +74,21 @@ Future tapVisible(WidgetTester tester, Finder finder) async { } void main() { - group('확정 가입 수단 5종', () { - test('네이버가 빠지고 이메일·LINE 이 들어왔다', () { + group('별첨 B v1.4 확정 4종', () { + test('노출 목록은 Google·Apple·Kakao·LINE 이고 이메일·네이버·X 는 없다', () { final kinds = AuthRepository().providers.map((p) => p.provider).toList(); - expect(kinds, hasLength(5)); - expect(kinds, contains(AuthProviderKind.email)); + expect(kinds, hasLength(4)); + expect(kinds, isNot(contains(AuthProviderKind.email))); expect(kinds, contains(AuthProviderKind.line)); expect(kinds.map((k) => k.name), isNot(contains('naver'))); + expect(kinds.map((k) => k.name), isNot(contains('x'))); + }); + + test('노출 목록에 없는 수단으로는 signIn 을 부를 수 없다', () { + expect( + () => AuthRepository().signIn(AuthProviderKind.email), + throwsArgumentError, + ); }); test('이메일만 입력 폼을 요구한다', () { @@ -86,15 +97,16 @@ void main() { } }); - test('저장소가 이메일 제공자를 찾아낸다', () { - expect(AuthRepository().emailProvider, isNotNull); + test('QA 이메일 경로는 디버그 빌드에만 있다', () { + expect(AuthRepository(releaseMode: false).qaEmailProvider, isNotNull); + expect(AuthRepository(releaseMode: true).qaEmailProvider, isNull); }); }); - group('저장소 — 이메일', () { + group('저장소 — QA 이메일', () { test('로그인에 성공하면 세션이 바뀐다', () async { final fake = _FakeEmail(result: const AuthSucceeded(_account)); - final auth = AuthRepository(providers: [fake]); + final auth = AuthRepository(qaEmailProvider: fake, releaseMode: false); final outcome = await auth.signInWithEmail( email: 'fan@fanuzu.co.kr', password: 'password12', @@ -107,7 +119,7 @@ void main() { test('가입은 로그인과 다른 경로로 간다', () async { // 한 버튼에 합치면 오타로 친 이메일이 새 계정을 만들어 버린다. final fake = _FakeEmail(result: const AuthSucceeded(_account)); - final auth = AuthRepository(providers: [fake]); + final auth = AuthRepository(qaEmailProvider: fake, releaseMode: false); await auth.signInWithEmail( email: 'fan@fanuzu.co.kr', password: 'password12', @@ -118,45 +130,73 @@ void main() { test('실패하면 세션이 그대로다', () async { final auth = AuthRepository( - providers: [_FakeEmail(result: const AuthFailed('E-AUTH-01', '확인해 주세요.'))], + qaEmailProvider: _FakeEmail( + result: const AuthFailed('E-AUTH-01', '확인해 주세요.'), + ), + releaseMode: false, ); final outcome = await auth.signInWithEmail(email: 'a@b.c', password: 'x'); expect(outcome, isA()); expect(auth.isSignedIn, isFalse); }); - test('이메일 제공자가 없으면 실패로 닫는다', () async { - final auth = AuthRepository(providers: const []); - final outcome = await auth.signInWithEmail(email: 'a@b.c', password: 'x'); + test('릴리스 빌드에서는 QA 경로가 실패로 닫힌다', () async { + final auth = AuthRepository( + qaEmailProvider: _FakeEmail(result: const AuthSucceeded(_account)), + releaseMode: true, + ); + final outcome = await auth.signInWithEmail( + email: 'qa-test@fanuzu.co.kr', + password: 'password12', + ); expect(outcome, isA()); + expect(auth.isSignedIn, isFalse); }); }); - group('로그인 화면 — 이메일 폼', () { + group('로그인 화면 — QA 이메일 폼(숨은 입구)', () { Future pump(WidgetTester tester, _FakeEmail fake) async { tester.view.physicalSize = const Size(1080, 2400); tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.reset); - final auth = AuthRepository(providers: [fake]); + final auth = AuthRepository(qaEmailProvider: fake, releaseMode: false); await tester.pumpWidget(MaterialApp(home: LoginScreen(auth: auth))); return auth; } - testWidgets('처음에는 폼이 접혀 있고 버튼을 누르면 펼쳐진다', (tester) async { + /// 숨은 입구 — 로고를 길게 누른다. 보이는 진입점은 두지 않는다. + Future openQaForm(WidgetTester tester) async { + await tester.longPress(find.text('FANUZU')); + await tester.pump(); + } + + testWidgets('보이는 입구가 없고 로고를 길게 눌러야 열린다', (tester) async { await pump(tester, _FakeEmail()); expect(find.text('이메일'), findsNothing); + expect(find.text(AuthProviderKind.email.label), findsNothing); - await tapVisible(tester, find.text(AuthProviderKind.email.label)); - await tester.pump(); + await openQaForm(tester); expect(find.text('이메일'), findsOneWidget); expect(find.text('비밀번호'), findsOneWidget); }); + testWidgets('릴리스 빌드에서는 로고를 길게 눌러도 열리지 않는다', (tester) async { + tester.view.physicalSize = const Size(1080, 2400); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp(home: LoginScreen(auth: AuthRepository(releaseMode: true))), + ); + await tester.longPress(find.text('FANUZU')); + await tester.pump(); + expect(find.text('이메일'), findsNothing); + expect(find.text('비밀번호'), findsNothing); + }); + testWidgets('빈 값으로 제출하면 안내가 뜨고 호출하지 않는다', (tester) async { final fake = _FakeEmail(); await pump(tester, fake); - await tapVisible(tester, find.text(AuthProviderKind.email.label)); - await tester.pump(); + await openQaForm(tester); await tapVisible(tester, find.widgetWithText(FilledButton, '로그인')); await tester.pump(); @@ -167,8 +207,7 @@ void main() { testWidgets('이메일 형식이 아니면 막는다', (tester) async { final fake = _FakeEmail(); await pump(tester, fake); - await tapVisible(tester, find.text(AuthProviderKind.email.label)); - await tester.pump(); + await openQaForm(tester); await tester.enterText(find.byType(TextFormField).first, 'fanuzu.co.kr'); await tester.enterText(find.byType(TextFormField).last, 'password12'); @@ -181,12 +220,14 @@ void main() { testWidgets('가입 모드에서는 짧은 비밀번호를 막는다', (tester) async { final fake = _FakeEmail(); await pump(tester, fake); - await tapVisible(tester, find.text(AuthProviderKind.email.label)); - await tester.pump(); + await openQaForm(tester); await tapVisible(tester, find.text('계정 만들기')); await tester.pump(); - await tester.enterText(find.byType(TextFormField).first, 'fan@fanuzu.co.kr'); + await tester.enterText( + find.byType(TextFormField).first, + 'fan@fanuzu.co.kr', + ); await tester.enterText(find.byType(TextFormField).last, 'short'); await tapVisible(tester, find.widgetWithText(FilledButton, '가입하기')); await tester.pump(); @@ -200,10 +241,12 @@ void main() { testWidgets('올바른 값이면 제공자를 부른다', (tester) async { final fake = _FakeEmail(result: const AuthSucceeded(_account)); await pump(tester, fake); - await tapVisible(tester, find.text(AuthProviderKind.email.label)); - await tester.pump(); + await openQaForm(tester); - await tester.enterText(find.byType(TextFormField).first, 'fan@fanuzu.co.kr'); + await tester.enterText( + find.byType(TextFormField).first, + 'fan@fanuzu.co.kr', + ); await tester.enterText(find.byType(TextFormField).last, 'password12'); await tapVisible(tester, find.widgetWithText(FilledButton, '로그인')); await tester.pump(); @@ -215,8 +258,7 @@ void main() { testWidgets('재설정은 이메일이 비어 있으면 보내지 않는다', (tester) async { final fake = _FakeEmail(); await pump(tester, fake); - await tapVisible(tester, find.text(AuthProviderKind.email.label)); - await tester.pump(); + await openQaForm(tester); await tapVisible(tester, find.text('비밀번호 재설정')); await tester.pump(); @@ -227,10 +269,12 @@ void main() { testWidgets('재설정 안내는 계정 존재 여부를 드러내지 않는다', (tester) async { final fake = _FakeEmail(); await pump(tester, fake); - await tapVisible(tester, find.text(AuthProviderKind.email.label)); - await tester.pump(); + await openQaForm(tester); - await tester.enterText(find.byType(TextFormField).first, 'nobody@fanuzu.co.kr'); + await tester.enterText( + find.byType(TextFormField).first, + 'nobody@fanuzu.co.kr', + ); await tapVisible(tester, find.text('비밀번호 재설정')); await tester.pump(); await tester.pump();