/// 인증 계약 — 제공자 인터페이스와 결과 타입. /// /// 원칙: **"키 미투입"은 예외가 아니라 상태다.** Firebase 설정 파일이나 Apple /// 웹 인증 값처럼 빌드에 들어가야 할 값이 없을 때가 그렇다. "아직 준비가 안 됐다"이지 /// "지원하지 않는다"가 아니므로 예외를 던지지 않고 [AuthNotConfigured] 로 돌려준다. /// 화면은 이걸 안내 다이얼로그로 보여 준다. library; import '../../core/i18n/app_locale.dart'; /// 로그인 수단 — **별첨 B v1.4 확정값**. /// /// > 1차 로그인은 Google / Apple / Kakao / LINE 4종입니다. 이메일/비밀번호 가입 및 /// > X 로그인은 제외합니다. 하나의 FANUZU UID 에 복수 소셜 Provider 연결은 허용합니다. /// /// 답변서 v1.1 Q7 은 이메일을 포함한 5종이었으나 별첨 B v1.4 가 이를 뒤집었다. /// [email] 값이 남아 있는 것은 **QA 계정(qa-test@fanuzu.co.kr) 내부 경로** 하나 /// 때문이다 — 사용자에게 노출되는 목록([AuthRepository.providers])에는 들어가지 /// 않는다. X(트위터)는 확정 목록에 없어 값 자체를 두지 않는다. enum AuthProviderKind { email, kakao, line, apple, google; /// 버튼 라벨. 임의로 바꾸지 마라 — 화면 대조 대상이다. String get label => appL10n.authProviderStart(name); /// 제공자 이름만 — 설정 「SNS 연동」·안내 문구용. [label] 에서 「로 시작하기」를 /// 떼어 만들면 「LINE으로」가 「LINE으」로 잘린다(인계 결함). String get shortName => appL10n.authProviderName(name); /// 이메일은 소셜이 아니라 입력 폼이 붙는다. QA 내부 경로만 이걸 쓴다. bool get needsForm => this == AuthProviderKind.email; } /// 이전 이름. 한 번에 다 바꾸지 않아도 되게 남겨 둔다. typedef SocialProvider = AuthProviderKind; /// 로그인된 계정. class AuthAccount { const AuthAccount({ required this.uid, required this.provider, this.displayName, this.email, this.isMock = false, }); final String uid; final AuthProviderKind? provider; final String? displayName; final String? email; /// Firebase 미설정 상태에서 만든 임시 세션인지. 실 세션과 절대 섞이면 안 된다. final bool isMock; } /// 로그인 시도 결과. sealed class AuthOutcome { const AuthOutcome(); } final class AuthSucceeded extends AuthOutcome { const AuthSucceeded(this.account); final AuthAccount account; } /// 사용자가 취소함. 에러로 취급하지 않는다. final class AuthCancelled extends AuthOutcome { const AuthCancelled(); } /// 실패. [code] 는 에러 카탈로그 ID 다(소셜 실패 = E-AUTH-03). final class AuthFailed extends AuthOutcome { const AuthFailed(this.code, this.message); final String code; final String message; } /// 아직 쓸 수 없음 — 키·설정 파일 미투입. final class AuthNotConfigured extends AuthOutcome { const AuthNotConfigured(this.provider, this.missing); final AuthProviderKind provider; /// 투입해야 하는 것들(파일명·키명). 화면과 README 에 그대로 노출한다. final List missing; } /// 로그인 제공자 인터페이스. 이메일도 같은 인터페이스를 쓴다. abstract interface class SocialAuthProvider { AuthProviderKind get provider; /// 실 연동 코드 경로를 가지고 있는지. 스텁이면 false. bool get isLive; /// 비어 있지 않으면 지금은 로그인할 수 없다. [signIn] 은 [AuthNotConfigured] 를 낸다. List get missingConfig; Future signIn(); Future signOut(); } /// 이메일 제공자 — **QA 계정 전용 내부 경로**. /// /// 별첨 B v1.4 가 이메일/비밀번호 가입을 1차 범위에서 뺐다. 코드를 지우지 않은 것은 /// 발주사 QA 계정(qa-test@fanuzu.co.kr)으로 로그인 이후 화면을 확인해야 하기 /// 때문이며, [AuthRepository.qaEmailProvider] 가 **디버그 빌드에서만** 이 구현체를 /// 돌려준다. 릴리스 빌드에는 부를 수 있는 경로가 없다. abstract interface class EmailAuthProvider implements SocialAuthProvider { Future signInWithEmail(String email, String password); Future signUpWithEmail(String email, String password); /// 비밀번호 재설정 메일 발송. Figma 「비밀번호 재설정」 화면이 이걸 부른다. Future sendPasswordReset(String email); } /// 앱 재실행·토큰 만료·다른 기기 로그아웃을 인증 상태에 반영하는 세션 경계. /// /// 저장소는 이 인터페이스만 알며 Firebase SDK 자체는 어댑터 뒤에 둔다. 덕분에 /// 외부 설정 파일 없이도 세션 복원과 만료 전이를 단위 테스트할 수 있다. abstract interface class AuthSession { AuthAccount? get currentAccount; Stream get accountChanges; Future signOut(); } /// 「동일 이메일만 연동 가능」 판정 — 명세 `554:22797` · 안내 문구 `554:23251`. /// /// **제공자가 인증한 이메일 전체**를 정규화(trim·소문자)해 비교한다. /// 로컬 파트만 보면 `test123@naver.com` 과 `test123@gmail.com` 처럼 남의 계정이 /// 붙는다. 비었거나 `@` 가 없는 값은 이메일로 보지 않아 false. bool sameAccountIdentity(String a, String b) { final x = a.trim().toLowerCase(); return x.contains('@') && x == b.trim().toLowerCase(); } /// 연동 화면에 거는 제공자 — 별첨 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 AuthFailed('E-AUTH-13', appL10n.authLastProvider); } _linked.remove(provider); return AuthSucceeded( AuthAccount(uid: 'mock-local', provider: provider, isMock: true), ); } }