Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b2bb5435e | ||
|
|
3511ba2de9 | ||
|
|
6e0aa5d43c | ||
|
|
c42fd615a6 | ||
|
|
c40bde2cd0 | ||
|
|
9119b3beaa | ||
|
|
e440251c78 | ||
|
|
cef5436336 | ||
|
|
897dff950d | ||
|
|
bc19797158 | ||
|
|
4d2ce67886 | ||
|
|
b66863a149 | ||
|
|
7bfc4faa13 | ||
|
|
9d34935b94 | ||
|
|
c301f3a1db | ||
|
|
f483e8d94b | ||
|
|
79d4682e65 | ||
|
|
9dd43ca405 | ||
|
|
4c3bc0304f |
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"flutter": "3.47.2"
|
||||||
|
}
|
||||||
@@ -55,6 +55,8 @@ key.properties
|
|||||||
*.mobileprovision
|
*.mobileprovision
|
||||||
**/GoogleService-Info.plist
|
**/GoogleService-Info.plist
|
||||||
**/google-services.json
|
**/google-services.json
|
||||||
|
# GoogleService-Info.plist 에서 생성한다 — tool/ios/inject_url_scheme.py
|
||||||
|
ios/config/Google.xcconfig
|
||||||
|
|
||||||
# FVM Version Cache
|
# FVM Version Cache
|
||||||
.fvm/
|
.fvm/
|
||||||
@@ -63,3 +65,6 @@ key.properties
|
|||||||
firebase/**/node_modules/
|
firebase/**/node_modules/
|
||||||
firebase/**/*-debug.log
|
firebase/**/*-debug.log
|
||||||
firebase/functions/lib/
|
firebase/functions/lib/
|
||||||
|
|
||||||
|
# 카카오 네이티브 앱 키. 다른 설정 파일(google-services.json 등)과 같이 저장소 밖에서 넣는다.
|
||||||
|
config/kakao.env
|
||||||
|
|||||||
+69
-24
@@ -6,7 +6,7 @@ Gradle 이 조용히 실패하지 않고 명확히 죽으므로 아래를 그대
|
|||||||
| 구성 | 값 | 비고 |
|
| 구성 | 값 | 비고 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Flutter | **3.47.2** | `fvm` 으로 고정 — `app/.fvmrc` |
|
| Flutter | **3.47.2** | `fvm` 으로 고정 — `app/.fvmrc` |
|
||||||
| JDK | **Temurin 17** | `~/.jdks/jdk-17.0.20.1+1/Contents/Home` |
|
| JDK | **Temurin 17** | `fvm flutter config --jdk-dir` 로 지정 |
|
||||||
| Gradle | 8.14 | `android/gradle/wrapper/gradle-wrapper.properties` |
|
| Gradle | 8.14 | `android/gradle/wrapper/gradle-wrapper.properties` |
|
||||||
| AGP | 8.11.1 | `android/settings.gradle.kts` |
|
| AGP | 8.11.1 | `android/settings.gradle.kts` |
|
||||||
| Android SDK | `/opt/homebrew/share/android-commandlinetools` | `brew install --cask android-commandlinetools` |
|
| Android SDK | `/opt/homebrew/share/android-commandlinetools` | `brew install --cask android-commandlinetools` |
|
||||||
@@ -21,40 +21,29 @@ Android Studio 번들 JBR 은 **JDK 25** 이고 **Gradle 8.14 와 비호환**이
|
|||||||
JDK 17 을 쓰는 쪽을 택했다.
|
JDK 17 을 쓰는 쪽을 택했다.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
fvm flutter config --jdk-dir ~/.jdks/jdk-17.0.20.1+1/Contents/Home
|
fvm flutter config --jdk-dir <JDK 17 설치 경로>
|
||||||
```
|
```
|
||||||
|
|
||||||
`brew install --cask temurin@17` 은 sudo 를 요구한다. sudo 없이 쓰려면 Adoptium
|
`brew install --cask temurin@17` 은 sudo 를 요구한다. sudo 없이 쓰려면 Adoptium
|
||||||
tar.gz 를 `~/.jdks/` 에 풀면 된다.
|
tar.gz 를 사용자 폴더에 풀고 그 경로를 지정하면 된다.
|
||||||
|
|
||||||
## 테스트
|
## 테스트
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export ANDROID_HOME=/opt/homebrew/share/android-commandlinetools
|
export ANDROID_HOME=/opt/homebrew/share/android-commandlinetools
|
||||||
fvm flutter analyze
|
fvm flutter analyze
|
||||||
FANUZU_DESIGN_DIR=/path/to/p-fancake/design fvm flutter test
|
fvm flutter test
|
||||||
```
|
```
|
||||||
|
|
||||||
`FANUZU_DESIGN_DIR` 은 5탭 계약 대조용이다. 스모크 테스트가
|
스모크 테스트가 `craft-allowlist.yaml` 의 `APP_TABS` 를 직접 읽어 앱의 `appTabs` 와 정확 일치를
|
||||||
`craft-allowlist.yaml` 의 `APP_TABS` 를 직접 읽어 앱의 `appTabs` 와 정확 일치를 검증한다.
|
검증한다(5탭 계약 대조). 파일은 아래 순서로 찾는다.
|
||||||
**정본은 상위 저장소 `p-fancake/design/craft-allowlist.yaml`** 이고 이 저장소는 분리돼
|
|
||||||
있으므로, 같은 파일의 사본을 `design/craft-allowlist.yaml` 로 두었다. 탐색 순서는 아래와 같다.
|
|
||||||
|
|
||||||
1. `$FANUZU_DESIGN_DIR/craft-allowlist.yaml` — 정본을 직접 가리킬 때 쓴다.
|
1. `$FANUZU_DESIGN_DIR/craft-allowlist.yaml` — 다른 위치의 파일을 가리킬 때 쓴다.
|
||||||
2. `design/craft-allowlist.yaml` — 이 저장소의 사본.
|
2. `app/design/craft-allowlist.yaml` — 앱 폴더 안의 사본.
|
||||||
3. `../design/craft-allowlist.yaml` · `../p-fancake/design/craft-allowlist.yaml` — p-fancake 와
|
3. 저장소 루트 `design/craft-allowlist.yaml`.
|
||||||
형제로 둔 체크아웃.
|
|
||||||
|
|
||||||
사본이 있으므로 **git worktree 에서도 변수 없이 통과한다.** 그래도 못 찾으면 통과가 아니라
|
하나도 못 찾으면 통과가 아니라 실패한다 — 계약 대조가 조용히 사라지면 안 된다.
|
||||||
실패한다 — 계약 대조가 조용히 사라지면 안 된다.
|
루트 파일을 고칠 때는 `app/design/` 사본도 함께 맞춘다.
|
||||||
|
|
||||||
사본은 정본과 갈리면 저장소마다 다른 답을 내므로, **정본을 고칠 때 사본에도 함께 복사한다.**
|
|
||||||
파일 머리에 같은 문장을 적어 두었다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp /path/to/p-fancake/design/craft-allowlist.yaml design/craft-allowlist.yaml
|
|
||||||
# 머리 주석 5줄은 사본에만 있으니 지우지 말고 APP_TABS·CAPS_OK 만 맞춘다.
|
|
||||||
```
|
|
||||||
|
|
||||||
## 빌드
|
## 빌드
|
||||||
|
|
||||||
@@ -65,6 +54,38 @@ fvm flutter build apk --release # build/app/outputs/flutter-apk/app-relea
|
|||||||
fvm flutter build appbundle --release # build/app/outputs/bundle/release/app-release.aab
|
fvm flutter build appbundle --release # build/app/outputs/bundle/release/app-release.aab
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 카카오 · LINE 로그인 설정
|
||||||
|
|
||||||
|
**카카오 네이티브 앱 키** — `config/kakao.env`(저장소 밖, `config/kakao.env.example` 복사) 한 줄
|
||||||
|
`FANUZU_KAKAO_NATIVE_APP_KEY=<키>`. 주석을 넣지 않는다(iOS xcconfig 가 그대로 include 한다).
|
||||||
|
Android `build.gradle.kts`(리다이렉트 스킴) · iOS `Flutter/*.xcconfig`(URL 스킴) 가 이 파일을 읽고,
|
||||||
|
Dart 는 실행·빌드 때 `--dart-define-from-file=config/kakao.env` 로 받는다. 없으면 카카오 로그인만
|
||||||
|
「설정 전」으로 막힌다. 서버 `fnAuthKakao` 는 카카오 **앱 ID**(숫자)를 `firebase/functions/.env.<프로젝트>`
|
||||||
|
의 `KAKAO_APP_ID` 로 받는다(저장소 밖).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fvm flutter build apk --release --dart-define-from-file=config/kakao.env
|
||||||
|
```
|
||||||
|
|
||||||
|
**LINE 채널 ID** — 같은 `config/kakao.env` 에 `FANUZU_LINE_CHANNEL_ID=<LINE Login 채널 ID(숫자)>`. Dart 만 읽는다
|
||||||
|
(iOS 복귀 스킴은 `line3rdp.<번들 ID>` 라 키가 필요 없다). 없으면 LINE 로그인만 「설정 전」으로 막힌다. 서버
|
||||||
|
`fnAuthLine` 은 같은 값을 `firebase/functions/.env.<프로젝트>` 의 `LINE_CHANNEL_ID` 로 받는다(저장소 밖).
|
||||||
|
LINE Developers 콘솔의 LINE Login 채널에 Android 패키지명 · iOS 번들 ID 를 등록한다.
|
||||||
|
|
||||||
|
## Android Apple 로그인 — 앱에 넣을 값 없음
|
||||||
|
|
||||||
|
Android 는 Apple SDK 가 없어 Firebase 웹 흐름(`apple.com` 제공자 · `signInWithProvider`)으로 로그인한다.
|
||||||
|
값은 모두 콘솔에 둔다 — 빌드 옵션은 없다.
|
||||||
|
|
||||||
|
1. Apple 개발자 계정 「Identifiers → Services IDs」에 서비스 ID 를 만들고 「Sign in with Apple」을 켠다.
|
||||||
|
- Domains: `<프로젝트 ID>.firebaseapp.com`
|
||||||
|
- Return URLs: `https://<프로젝트 ID>.firebaseapp.com/__/auth/handler`
|
||||||
|
2. 「Keys」에서 Sign in with Apple 키를 만들고 .p8 를 받는다(키 ID · 팀 ID 기록). 키 파일은 저장소에 넣지 않는다.
|
||||||
|
3. Firebase 콘솔 Authentication → 로그인 방법 → Apple 에 서비스 ID · 팀 ID · 키 ID · 비공개 키를 넣는다.
|
||||||
|
4. 확인: Android 첫 화면 Apple → 브라우저 탭에서 Apple 로그인 → 앱 HOME.
|
||||||
|
|
||||||
|
iOS 는 지금처럼 Apple SDK(`AppleSignInProvider`)로 로그인한다.
|
||||||
|
|
||||||
## 서명
|
## 서명
|
||||||
|
|
||||||
`android/key.properties` 가 없으면 **릴리스 빌드가 의도적으로 실패**한다
|
`android/key.properties` 가 없으면 **릴리스 빌드가 의도적으로 실패**한다
|
||||||
@@ -111,8 +132,32 @@ export DEVELOPER_DIR=~/Downloads/Xcode-beta.app/Contents/Developer
|
|||||||
fvm flutter build ios --release --no-codesign
|
fvm flutter build ios --release --no-codesign
|
||||||
```
|
```
|
||||||
|
|
||||||
**서명은 Apple Developer 계정이 있어야 한다.** 계정 없이 여기까지는 확인되므로,
|
### 제출 빌드 — App Store Connect
|
||||||
TestFlight 제출에 남은 것은 **서명과 업로드뿐**이다.
|
|
||||||
|
서명 팀은 **Fancake Inc. `V28G543VK5`** 다(`Runner.xcodeproj` 의 `DEVELOPMENT_TEAM`,
|
||||||
|
`ios/ExportOptions.plist` 의 `teamID`). 번들 ID `kr.co.fanuzu.app`.
|
||||||
|
|
||||||
|
| 항목 | 파일 | 없으면 |
|
||||||
|
|---|---|---|
|
||||||
|
| Sign in with Apple · 푸시 환경 | `ios/Runner/Runner.entitlements` | Apple 로그인이 동작하지 않고 심사에서 반려된다 |
|
||||||
|
| 암호화 사용 신고 | `Info.plist` `ITSAppUsesNonExemptEncryption` | 업로드마다 수동 응답을 요구한다 |
|
||||||
|
| Google 콜백 스킴 | `Info.plist` + `ios/config/Google.xcconfig` | Google 로그인 콜백이 돌아오지 않는다 |
|
||||||
|
| Firebase 설정 | `ios/Runner/GoogleService-Info.plist` (번들 리소스) | 목 모드로 뜨고 Google 로그인 client ID 를 못 읽는다 |
|
||||||
|
| 내보내기 설정 | `ios/ExportOptions.plist` | `-exportArchive` 가 대화형으로 묻는다 |
|
||||||
|
|
||||||
|
`GoogleService-Info.plist` 와 `ios/config/Google.xcconfig` 는 저장소에 넣지 않는다.
|
||||||
|
Xcode 프로젝트가 `GoogleService-Info.plist` 를 리소스로 참조하므로 **파일이 없으면 iOS 빌드가 멈춘다.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Firebase 콘솔에서 받은 iOS 설정 파일을 둔다
|
||||||
|
cp <받은 파일> ios/Runner/GoogleService-Info.plist
|
||||||
|
# 2. Google 콜백 스킴을 뽑는다 → ios/config/Google.xcconfig
|
||||||
|
python3 tool/ios/inject_url_scheme.py
|
||||||
|
# 3. 아카이브 · IPA
|
||||||
|
fvm flutter build ipa --release --export-options-plist=ios/ExportOptions.plist
|
||||||
|
```
|
||||||
|
|
||||||
|
배포 프로파일이 없으면 `xcodebuild` 로 나눠 `-allowProvisioningUpdates` 를 붙인다.
|
||||||
|
|
||||||
### 정리해야 할 것 — CocoaPods / SwiftPM 이중 상태
|
### 정리해야 할 것 — CocoaPods / SwiftPM 이중 상태
|
||||||
|
|
||||||
|
|||||||
+19
-25
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
| | 내용 |
|
| | 내용 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **포함** | 앱 기동 · 코스믹 다크 테마(design-tokens.md 생성) · 탭 셸 네비게이션 · 로그인 화면(S-A-LOGIN) 소셜 4종 버튼 · 인증 인터페이스와 Google/Apple 실 코드 경로 · 계정 연결(linkWithCredential) · Firebase 초기화(미설정 시 목 모드) |
|
| **포함** | 앱 기동 · 코스믹 다크 테마(design-tokens.md 생성) · 탭 셸 네비게이션 · 로그인 화면(S-A-LOGIN) 소셜 4종 버튼 · 인증 인터페이스와 Google·Apple·카카오·LINE 실 코드 경로 · 계정 연결(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·퀘스트·이클립스) · 상태관리 라이브러리 |
|
| **미포함** | 각 탭의 실제 화면(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).**
|
미포함 항목을 지금 만들지 않은 이유: **RN/Flutter 승인이 아직 없다(2주차_확인요청항목 Q1).**
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
## 2. 빌드·실행
|
## 2. 빌드·실행
|
||||||
|
|
||||||
전제: Flutter stable 3.41.7 / Dart 3.11.5 (검증에 쓴 버전), Android SDK 36, Xcode 26.6
|
전제: Flutter stable 3.47.2 / Dart 3.13.2 (`app/.fvmrc` 로 고정), Android SDK 36, Xcode 26.6
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd app
|
cd app
|
||||||
@@ -30,7 +30,7 @@ flutter build apk --debug # Android 디버그 APK
|
|||||||
빌드 산출물은 `build/app/outputs/flutter-apk/app-debug.apk` 에 떨어진다.
|
빌드 산출물은 `build/app/outputs/flutter-apk/app-debug.apk` 에 떨어진다.
|
||||||
|
|
||||||
> ⚠️ **`pubspec.yaml` 의 의존성을 추가·변경·업그레이드하면 최소 지원 OS 하한을 재계산할 것.**
|
> ⚠️ **`pubspec.yaml` 의 의존성을 추가·변경·업그레이드하면 최소 지원 OS 하한을 재계산할 것.**
|
||||||
> 절차: `주간보고/2주차/src/2주차_플러그인_선검증리포트.md` §3.5
|
> 절차: 2주차 플러그인 선검증 리포트 §3.5
|
||||||
>
|
>
|
||||||
> 최소 지원 OS 는 **별첨1 v1.0 에 계약값으로 박힌다**(견적서 1228행). 지킬 수 없는 하한을
|
> 최소 지원 OS 는 **별첨1 v1.0 에 계약값으로 박힌다**(견적서 1228행). 지킬 수 없는 하한을
|
||||||
> 계약에 적으면 그 아래 단말의 장애가 개발사 책임이 된다. 선검증 리포트의 값은
|
> 계약에 적으면 그 아래 단말의 장애가 개발사 책임이 된다. 선검증 리포트의 값은
|
||||||
@@ -42,7 +42,7 @@ flutter build apk --debug # Android 디버그 APK
|
|||||||
|
|
||||||
## 3. 디자인 토큰 — 손으로 옮기지 않는다
|
## 3. 디자인 토큰 — 손으로 옮기지 않는다
|
||||||
|
|
||||||
`lib/core/theme/tokens.dart` 는 **생성물**이다. 직접 고치면 시안(`시안/assets/ds.css`)과 어긋난다.
|
`lib/core/theme/tokens.dart` 는 **생성물**이다. 직접 고치면 토큰 문서(`design/design-tokens.md`)와 어긋난다.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd app
|
cd app
|
||||||
@@ -79,7 +79,7 @@ Firebase 설정이 없으면 실 소셜 로그인이 물리적으로 불가능
|
|||||||
* `FirebaseBoot.reason` 은 사용자에게 보이는 문자열이라 **내부 파일 경로를 넣지 않는다.**
|
* `FirebaseBoot.reason` 은 사용자에게 보이는 문자열이라 **내부 파일 경로를 넣지 않는다.**
|
||||||
경로와 원시 예외는 `FirebaseBoot.detail` 로 분리해 로그에만 남긴다.
|
경로와 원시 예외는 `FirebaseBoot.detail` 로 분리해 로그에만 남긴다.
|
||||||
|
|
||||||
**가드가 두 겹인 이유 (교차검증 A-01 반영)**
|
**가드가 두 겹인 이유**
|
||||||
|
|
||||||
```dart
|
```dart
|
||||||
bool get isMockSessionAvailable => !FirebaseBoot.isLive && !_releaseMode;
|
bool get isMockSessionAvailable => !FirebaseBoot.isLive && !_releaseMode;
|
||||||
@@ -103,7 +103,7 @@ bool get isMockSessionAvailable => !FirebaseBoot.isLive && !_releaseMode;
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| S1 | Firebase 프로젝트(dev/prod) 개설 | — | 아래 S2 를 만들 수 없다 |
|
| S1 | Firebase 프로젝트(dev/prod) 개설 | — | 아래 S2 를 만들 수 없다 |
|
||||||
| S2 | `google-services.json` | `app/android/app/google-services.json` | 목 모드로 기동. Google 로그인 차단 |
|
| S2 | `google-services.json` | `app/android/app/google-services.json` | 목 모드로 기동. Google 로그인 차단 |
|
||||||
| S2 | `GoogleService-Info.plist` | `app/ios/Runner/GoogleService-Info.plist` | 목 모드로 기동. Google·Apple 로그인 차단 |
|
| S2 | `GoogleService-Info.plist` | `app/ios/Runner/GoogleService-Info.plist` | iOS 빌드가 멈춘다(번들 리소스로 참조). 투입 절차는 `BUILD.md` 「제출 빌드」 |
|
||||||
|
|
||||||
> S2 투입 시 Android 는 `android/settings.gradle.kts` 에 `com.google.gms.google-services` 플러그인
|
> S2 투입 시 Android 는 `android/settings.gradle.kts` 에 `com.google.gms.google-services` 플러그인
|
||||||
> 추가가 함께 필요하다. 지금은 설정 파일이 없어 플러그인을 적용하지 않았다 —
|
> 추가가 함께 필요하다. 지금은 설정 파일이 없어 플러그인을 적용하지 않았다 —
|
||||||
@@ -113,8 +113,8 @@ bool get isMockSessionAvailable => !FirebaseBoot.isLive && !_releaseMode;
|
|||||||
|
|
||||||
| ID | 항목 | 투입 위치 | 없으면 |
|
| ID | 항목 | 투입 위치 | 없으면 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Q3 | 카카오 네이티브 앱 키 | 미정(플러그인 도입 시 확정) | 카카오 버튼이 「키 대기」 안내 다이얼로그를 띄운다 |
|
| Q3 | 카카오 네이티브 앱 키 | `config/kakao.env`(저장소 밖) | 카카오 버튼이 「설정 전」 안내 다이얼로그를 띄운다 |
|
||||||
| Q3 | LINE Login 채널 ID / 시크릿 | 미정 | LINE 버튼이 「키 대기」 안내 다이얼로그를 띄운다 |
|
| Q3 | LINE Login 채널 ID / 시크릿 | Firebase 콘솔 `oidc.line` 제공자 | LINE 로그인이 E-AUTH-11 로 끝난다 |
|
||||||
| Q5 | 스토어 등록 명의 | — | 3주차 TestFlight·Play 내부트랙 제출 불가 |
|
| 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 를 모두 옮겼다. **스토어 등록 후에는 변경 불가** |
|
| ~~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` 텍스트를 유지 |
|
| ~~Q6~~ | 공식 FANUZU 심볼 원본 | `assets/brand/` | **2026-08-13 반영 완료.** 제공본이 심볼 전용이라 앱 아이콘은 공식 자산으로 생성했고 로그인 워드마크는 `FANUZU` 텍스트를 유지 |
|
||||||
@@ -144,9 +144,13 @@ bool get isMockSessionAvailable => !FirebaseBoot.isLive && !_releaseMode;
|
|||||||
| 제공자 | 상태 | 구현 | 막고 있는 것 |
|
| 제공자 | 상태 | 구현 | 막고 있는 것 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Google | 실 코드 경로 | `GoogleSignInProvider` | **Firebase 콘솔에서 Google 제공자 미활성** + Android OAuth 클라이언트(SHA-1) 미등록 |
|
| Google | 실 코드 경로 | `GoogleSignInProvider` | **Firebase 콘솔에서 Google 제공자 미활성** + Android OAuth 클라이언트(SHA-1) 미등록 |
|
||||||
| Apple | 실 코드 경로 | `AppleSignInProvider` | Apple 개발자 키 + Xcode Capability + Android Service ID/redirect URI |
|
| Apple | 실 코드 경로 | iOS `AppleSignInProvider`(Apple SDK) · Android `OidcSignInProvider`(Firebase 웹 흐름 `apple.com`) | iOS Xcode Capability · Android 는 Firebase 콘솔 Apple 제공자에 서비스 ID · 키 등록(`BUILD.md` 「Android Apple 로그인」) |
|
||||||
| 카카오 | 키 대기 | `KeylessSocialProvider` | 카카오 네이티브 앱 키(Q3) |
|
| 카카오 | 실 코드 경로 | `KakaoSignInProvider` — 카카오 SDK 로그인 + 서버 `fnAuthKakao` 커스텀 토큰 | 카카오 네이티브 앱 키(`config/kakao.env`) · 서버 `KAKAO_APP_ID` |
|
||||||
| LINE | 키 대기 | `KeylessSocialProvider` | LINE Login 채널 ID/시크릿(Q3) |
|
| LINE | 실 코드 경로 | `OidcSignInProvider` — Firebase OIDC `oidc.line` | Firebase 콘솔 LINE 제공자 등록 |
|
||||||
|
|
||||||
|
카카오 · LINE 의 이메일은 **선택 동의 항목**이다. 이메일을 받지 못하거나 사용자가 동의하지 않아도
|
||||||
|
제공자 고유 식별값(카카오 회원번호 · LINE 사용자 ID)으로 가입 · 로그인되고, 같은 계정으로 다시 들어오면
|
||||||
|
같은 UID 로 로그인된다. 동의한 이메일만 사용자 문서(`users/{uid}.email`)에 남는다.
|
||||||
|
|
||||||
키가 없는 제공자의 버튼에는 **「키 대기」 배지**가 붙고, 누르면 무엇이 없어서 못 쓰는지
|
키가 없는 제공자의 버튼에는 **「키 대기」 배지**가 붙고, 누르면 무엇이 없어서 못 쓰는지
|
||||||
**안내 다이얼로그**가 뜬다. 버튼을 회색으로 잠그지 않는 것은 회색이 사유를 말해 주지 않기 때문이다.
|
**안내 다이얼로그**가 뜬다. 버튼을 회색으로 잠그지 않는 것은 회색이 사유를 말해 주지 않기 때문이다.
|
||||||
@@ -191,7 +195,7 @@ Firebase가 live가 되면 보존된 `currentUser`를 앱 시작 시 복원하
|
|||||||
**이메일 가입은 구현하지 않았다.** CD-18 미확정(Q2)이라 `SocialAuthProvider` 인터페이스에
|
**이메일 가입은 구현하지 않았다.** CD-18 미확정(Q2)이라 `SocialAuthProvider` 인터페이스에
|
||||||
구현체 하나가 더 붙을 자리만 비워 뒀다. **기능 플래그 상수는 두지 않았다** — 읽는 분기가 없는
|
구현체 하나가 더 붙을 자리만 비워 뒀다. **기능 플래그 상수는 두지 않았다** — 읽는 분기가 없는
|
||||||
플래그는 "false 라서 닫혀 있다" 는 착각만 만들고, 누가 true 로 바꿔도 아무 일이 일어나지 않는다
|
플래그는 "false 라서 닫혀 있다" 는 착각만 만들고, 누가 true 로 바꿔도 아무 일이 일어나지 않는다
|
||||||
(교차검증 A-02). 지금 이메일을 닫고 있는 것은 `AuthRepository._defaultProviders()` 에
|
. 지금 이메일을 닫고 있는 것은 `AuthRepository._defaultProviders()` 에
|
||||||
구현체가 없다는 사실 하나다.
|
구현체가 없다는 사실 하나다.
|
||||||
|
|
||||||
## 7. 계약 고정 표기 — 5탭
|
## 7. 계약 고정 표기 — 5탭
|
||||||
@@ -204,9 +208,8 @@ HOME · INSIDE · GALAXY · STATION · MY
|
|||||||
|
|
||||||
| 근거 | 위치 |
|
| 근거 | 위치 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 견적서 v2.1 「5탭 네비게이션」 | `Docs/Fancake_팬덤플랫폼_견적서.html` |
|
| 견적서 v2.1 「5탭 네비게이션」 | 견적서 v2.1 |
|
||||||
| 크래프트 게이트 C15 | `design/craft-allowlist.yaml` → `APP_TABS` |
|
| 크래프트 게이트 C15 | `design/craft-allowlist.yaml` → `APP_TABS` |
|
||||||
| 시안 하단 탭바 | `시안/screens/S-{H-HOME,I-LOUNGE,G-PLANET,S-HUB,M-HOME}.html` 의 `<nav class="tabbar">` |
|
|
||||||
|
|
||||||
**한글화·치환·순서 변경 금지.** 스모크 테스트가 `design/craft-allowlist.yaml` 을 직접 읽어
|
**한글화·치환·순서 변경 금지.** 스모크 테스트가 `design/craft-allowlist.yaml` 을 직접 읽어
|
||||||
문자열과 순서를 정확 일치로 검증한다 — 앱 코드만 고치면 테스트가 깨진다.
|
문자열과 순서를 정확 일치로 검증한다 — 앱 코드만 고치면 테스트가 깨진다.
|
||||||
@@ -288,16 +291,7 @@ $ python3 tool/gen_tokens.py
|
|||||||
|
|
||||||
`contrast_checks` 25쌍에는 앱 테마가 조립하며 새로 만드는 3쌍(선택 탭 라벨 9.21:1 ·
|
`contrast_checks` 25쌍에는 앱 테마가 조립하며 새로 만드는 3쌍(선택 탭 라벨 9.21:1 ·
|
||||||
인디케이터 위 선택 라벨 7.93:1 · 인디케이터 위 비선택 라벨 8.05:1)이 포함돼 있다.
|
인디케이터 위 선택 라벨 7.93:1 · 인디케이터 위 비선택 라벨 8.05:1)이 포함돼 있다.
|
||||||
토큰 문서에만 있는 쌍을 검사하면 위젯 테마에서 대비가 무너져도 생성기가 통과를 찍는다(교차검증 A-04).
|
토큰 문서에만 있는 쌍을 검사하면 위젯 테마에서 대비가 무너져도 생성기가 통과를 찍는다.
|
||||||
|
|
||||||
### 토큰 대조 — 시안 CSS ↔ 생성된 Dart
|
|
||||||
|
|
||||||
`시안/assets/ds.css` 의 `:root` 변수와 `tokens.dart` 의 색 토큰을 값 비교했다.
|
|
||||||
|
|
||||||
```
|
|
||||||
ds.css 변수 119개 · tokens.dart 색 토큰 51개 · 공통 51개
|
|
||||||
값 불일치: 0개
|
|
||||||
```
|
|
||||||
|
|
||||||
### 실기기 기동 확인 (Android 에뮬레이터 · API 36)
|
### 실기기 기동 확인 (Android 에뮬레이터 · API 36)
|
||||||
|
|
||||||
@@ -326,7 +320,7 @@ I flutter : [FirebaseBoot] detail :: PlatformException(java.lang.Exception: Fail
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| L1 | Firebase 설정 파일 미투입 → 목 모드 | **실 소셜 로그인 불가.** 로그인 버튼은 「키 미투입」 안내를 반환 | S2 (3주차 빌드 전) |
|
| L1 | Firebase 설정 파일 미투입 → 목 모드 | **실 소셜 로그인 불가.** 로그인 버튼은 「키 미투입」 안내를 반환 | S2 (3주차 빌드 전) |
|
||||||
| L2 | `com.google.gms.google-services` Gradle 플러그인 **미적용** | 지금은 이게 정상이다. 설정 파일 없이 플러그인만 적용하면 Gradle 빌드가 실패한다. 파일 투입과 **동시에** `android/settings.gradle.kts` · `android/app/build.gradle.kts` 에 추가해야 한다 | S2 |
|
| L2 | `com.google.gms.google-services` Gradle 플러그인 **미적용** | 지금은 이게 정상이다. 설정 파일 없이 플러그인만 적용하면 Gradle 빌드가 실패한다. 파일 투입과 **동시에** `android/settings.gradle.kts` · `android/app/build.gradle.kts` 에 추가해야 한다 | S2 |
|
||||||
| L3 | 카카오·LINE SDK 미도입 | 「키 대기」 배지 + 안내 다이얼로그까지만 있다. 키가 오면 `kakao_flutter_sdk_user` · LINE SDK 를 넣고 `KeylessSocialProvider` 를 `CredentialProvider` 구현체로 교체하면 로그인·계정 연결이 함께 열린다. Firebase 쪽은 OIDC 커스텀 제공자(`oidc.kakao` · `oidc.line`)로 등록한다 | Q3 회신 후 |
|
| L3 | 카카오 · LINE 로그인 연결 | 카카오는 SDK + 서버 커스텀 토큰(`fnAuthKakao`, 사용자 제공자 정보는 `oidc.kakao`), LINE 은 Firebase OIDC(`oidc.line`)다. 설정 방법은 `BUILD.md` 「카카오 · LINE 로그인 설정」 | 반영 완료 |
|
||||||
| L3-1 | 「동일 이메일만 연동」이 Apple 재연동에서는 판정되지 않는다 | Apple 이 **최초 인가 때만** 이메일을 준다. 클라이언트에서 볼 값이 없어 통과시킨다 — 서버(fn-account-link)에 같은 판정을 두어야 완결된다 | Firestore 설계서 v1.1 |
|
| L3-1 | 「동일 이메일만 연동」이 Apple 재연동에서는 판정되지 않는다 | Apple 이 **최초 인가 때만** 이메일을 준다. 클라이언트에서 볼 값이 없어 통과시킨다 — 서버(fn-account-link)에 같은 판정을 두어야 완결된다 | Firestore 설계서 v1.1 |
|
||||||
| L4 | 폰트 파일 미투입 | 시스템 폰트로 폴백. 크기·굵기·행간·자간은 토큰 값대로 적용됨 | 라이선스 원본 확보 시 |
|
| L4 | 폰트 파일 미투입 | 시스템 폰트로 폴백. 크기·굵기·행간·자간은 토큰 값대로 적용됨 | 라이선스 원본 확보 시 |
|
||||||
| L6 | 탭 아이콘이 Material 기본(Q15) | 시안 탭바에는 아이콘이 없어 대조 대상이 없다 | 3주차 |
|
| L6 | 탭 아이콘이 Material 기본(Q15) | 시안 탭바에는 아이콘이 없어 대조 대상이 없다 | 3주차 |
|
||||||
|
|||||||
@@ -2,3 +2,6 @@ analyzer:
|
|||||||
exclude:
|
exclude:
|
||||||
# Firebase 도구가 내려받는 서드파티 소스. 우리 분석 대상이 아니다.
|
# Firebase 도구가 내려받는 서드파티 소스. 우리 분석 대상이 아니다.
|
||||||
- '**/node_modules/**'
|
- '**/node_modules/**'
|
||||||
|
- build/**
|
||||||
|
- android/**
|
||||||
|
- ios/**
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ val keystoreProperties = Properties().apply {
|
|||||||
if (f.exists()) f.inputStream().use { load(it) }
|
if (f.exists()) f.inputStream().use { load(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 카카오 네이티브 앱 키 — `config/kakao.env` 한 파일이 Dart(--dart-define-from-file)·
|
||||||
|
// Android(여기)·iOS(Flutter/*.xcconfig)에 같은 값을 준다. 파일은 저장소에 넣지 않는다.
|
||||||
|
// 없으면 빈 값으로 빌드되고 카카오 로그인만 「설정 전」으로 막힌다.
|
||||||
|
val kakaoProperties = Properties().apply {
|
||||||
|
val f = rootProject.file("../config/kakao.env")
|
||||||
|
if (f.exists()) f.inputStream().use { load(it) }
|
||||||
|
}
|
||||||
|
|
||||||
/// 이번 실행이 릴리스 산출물(assembleRelease · bundleRelease 등)을 요구하는가.
|
/// 이번 실행이 릴리스 산출물(assembleRelease · bundleRelease 등)을 요구하는가.
|
||||||
val wantsRelease = gradle.startParameter.taskNames.any { it.contains("Release", ignoreCase = true) }
|
val wantsRelease = gradle.startParameter.taskNames.any { it.contains("Release", ignoreCase = true) }
|
||||||
|
|
||||||
@@ -53,6 +61,9 @@ android {
|
|||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
|
// AndroidManifest 의 카카오 로그인 리다이렉트 스킴 `kakao{네이티브 앱 키}`.
|
||||||
|
manifestPlaceholders["kakaoNativeAppKey"] =
|
||||||
|
kakaoProperties.getProperty("FANUZU_KAKAO_NATIVE_APP_KEY", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
@@ -93,3 +104,9 @@ android {
|
|||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// MainActivity 의 「버려진 Firebase 웹 로그인 정리」(kr.co.fanuzu.app/auth) — firebase-auth 가 이미 쓰는
|
||||||
|
// 라이브러리라 새로 들어오는 것은 없다. 앱 모듈에서 직접 부르려고 컴파일 경로에 올린다.
|
||||||
|
implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0")
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,18 @@
|
|||||||
<data android:path="callback" />
|
<data android:path="callback" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<!-- 카카오 로그인 리다이렉트(카카오계정 웹 로그인 후 앱 복귀). Kakao Flutter SDK 공식 설정.
|
||||||
|
키는 config/kakao.env → build.gradle.kts manifestPlaceholders. -->
|
||||||
|
<activity
|
||||||
|
android:name="com.kakao.sdk.flutter.auth.AuthCodeHandlerActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter android:label="flutter_web_auth">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="kakao${kakaoNativeAppKey}" android:host="oauth" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<meta-data
|
||||||
|
|||||||
@@ -1,5 +1,29 @@
|
|||||||
package kr.co.fanuzu.app
|
package kr.co.fanuzu.app
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
class MainActivity : FlutterActivity()
|
class MainActivity : FlutterActivity() {
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
// 버려진 Firebase 웹 로그인(LINE · Apple OIDC) 정리 — Dart `clearStaleWebSignIn`.
|
||||||
|
// 인가 중 다른 앱(LINE 본인 확인 등)으로 갔다 돌아오면 Firebase 의 대기 작업이 결과 없이 남아,
|
||||||
|
// 다음 시도가 `web-context-already-presented` 로 막힌다. Firebase 의 인가 화면이 「닫힘」을 알릴 때
|
||||||
|
// 보내는 것과 같은 취소 신호를 보내 대기 작업을 취소로 끝내고 다음 시도를 연다. 대기 작업이 없으면 아무 일도 없다.
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "kr.co.fanuzu.app/auth")
|
||||||
|
.setMethodCallHandler { call, result ->
|
||||||
|
if (call.method != "cancelPendingWebSignIn") {
|
||||||
|
result.notImplemented()
|
||||||
|
return@setMethodCallHandler
|
||||||
|
}
|
||||||
|
LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(
|
||||||
|
Intent("com.google.firebase.auth.ACTION_RECEIVE_FIREBASE_AUTH_INTENT")
|
||||||
|
.putExtra("com.google.firebase.auth.internal.EXTRA_CANCELED", true)
|
||||||
|
)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
FANUZU_KAKAO_NATIVE_APP_KEY=
|
||||||
|
FANUZU_LINE_CHANNEL_ID=
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
# ⚠️ 이 파일은 사본이다. 정본은 상위 저장소 p-fancake/design/craft-allowlist.yaml 이며,
|
# ⚠️ 이 파일은 저장소 루트 design/craft-allowlist.yaml 의 사본이다.
|
||||||
# 정본을 갱신할 때 이 사본에도 함께 복사한다. 두 파일이 갈리면 계약 대조(APP_TABS)가
|
# 루트 파일을 고칠 때 이 사본도 함께 맞춘다(BUILD.md 「테스트」).
|
||||||
# 저장소마다 다른 답을 내므로, 사본만 고치는 일은 없어야 한다.
|
|
||||||
# 사본을 둔 이유는 git worktree 에서 형제 경로(../design/)가 없어 스모크 테스트가
|
|
||||||
# 계약 대조를 못 하고 실패하기 때문이다(BUILD.md 「테스트」).
|
|
||||||
|
|
||||||
# craft-allowlist.yaml — 크래프트 체커의 "분류 결정"을 적는 곳.
|
# craft-allowlist.yaml — 크래프트 체커의 "분류 결정"을 적는 곳.
|
||||||
# 경고를 끄는 곳이 아니다. 각 항목에 근거(reason)가 없으면 킷이 읽지 않는다.
|
# 경고를 끄는 곳이 아니다. 각 항목에 근거(reason)가 없으면 킷이 읽지 않는다.
|
||||||
# 계약: 하네스/디자인/resources/codex/check_craft_render.py C2
|
|
||||||
|
|
||||||
CAPS_OK:
|
CAPS_OK:
|
||||||
# ── 5탭 라벨 — 견적서 v2.1 「5탭 네비게이션」으로 고정된 화면 이름이다.
|
# ── 5탭 라벨 — 견적서 v2.1 「5탭 네비게이션」으로 고정된 화면 이름이다.
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ rules_version = '2';
|
|||||||
// FANUZU — Firestore 보안 규칙 (2주차 설계본 v0.1)
|
// FANUZU — Firestore 보안 규칙 (2주차 설계본 v0.1)
|
||||||
//
|
//
|
||||||
// 근거
|
// 근거
|
||||||
// - wireframe/handoff/rbac-summary.md 역할 3종 · 운영자 등급 3단계(D-18)
|
// - 요구사항 정의서 권한 요구 역할 3종 · 운영자 등급 3단계
|
||||||
// - Docs/별첨1_기능별_완료기준표.html 「전 기능 공통 완료 기준 — 서버검증 원칙」
|
// - 별첨 1 기능별 완료 기준표 「전 기능 공통 완료 기준 — 서버검증 원칙」
|
||||||
// - 1주차 정책 설계서 P-02 원장 무결성 · P-06 어뷰징 방어
|
// - 1주차 정책 설계서 P-02 원장 무결성 · P-06 어뷰징 방어
|
||||||
//
|
//
|
||||||
// 3대 원칙
|
// 3대 원칙
|
||||||
@@ -48,15 +48,15 @@ service cloud.firestore {
|
|||||||
function myUser() {
|
function myUser() {
|
||||||
return get(/databases/$(database)/documents/users/$(request.auth.uid)).data;
|
return get(/databases/$(database)/documents/users/$(request.auth.uid)).data;
|
||||||
}
|
}
|
||||||
// 작성자 표시명이 프로필 원본과 같은지 — 운영팀 사칭 차단 (교차검증 F-05)
|
// 작성자 표시명이 프로필 원본과 같은지 — 운영팀 사칭 차단
|
||||||
function authorNameMatchesProfile() {
|
function authorNameMatchesProfile() {
|
||||||
return request.resource.data.authorName == myUser().displayName;
|
return request.resource.data.authorName == myUser().displayName;
|
||||||
}
|
}
|
||||||
// 소속 팬덤이 본인 소속과 같은지 — 타 팬덤 피드 삽입 차단 (교차검증 F-05)
|
// 소속 팬덤이 본인 소속과 같은지 — 타 팬덤 피드 삽입 차단
|
||||||
function fandomIsMine() {
|
function fandomIsMine() {
|
||||||
return request.resource.data.fandomId == myUser().fandomId;
|
return request.resource.data.fandomId == myUser().fandomId;
|
||||||
}
|
}
|
||||||
// 무인증 열람을 허용하는 캠페인 상태 — draft·review 는 제외 (교차검증 F-08)
|
// 무인증 열람을 허용하는 캠페인 상태 — draft·review 는 제외
|
||||||
function campaignIsPublic() {
|
function campaignIsPublic() {
|
||||||
return resource.data.status in ['funding', 'success', 'fail', 'report'];
|
return resource.data.status in ['funding', 'success', 'fail', 'report'];
|
||||||
}
|
}
|
||||||
@@ -97,7 +97,7 @@ service cloud.firestore {
|
|||||||
match /users/{uid} {
|
match /users/{uid} {
|
||||||
allow get: if isSelf(uid) || isViewer();
|
allow get: if isSelf(uid) || isViewer();
|
||||||
allow list: if isViewer(); // 전체 사용자 목록은 운영자만(S-AD-USER)
|
allow list: if isViewer(); // 전체 사용자 목록은 운영자만(S-AD-USER)
|
||||||
// 교차검증 F-06 — 클라이언트 생성 경로를 닫는다.
|
// 클라이언트 생성 경로를 닫는다.
|
||||||
// 기존에는 클라이언트 생성도 허용해 ① fn-auth-oncreate(§8.5)와 생성 경합이 생기고
|
// 기존에는 클라이언트 생성도 허용해 ① fn-auth-oncreate(§8.5)와 생성 경합이 생기고
|
||||||
// ② 클라이언트가 먼저 만들면 totalContributedPop 등 서버 산출 필드가 빈
|
// ② 클라이언트가 먼저 만들면 totalContributedPop 등 서버 산출 필드가 빈
|
||||||
// 사용자 문서가 남았다(별첨1 조건부적합 제외 ④ 로그인 불가와 닿는 지점).
|
// 사용자 문서가 남았다(별첨1 조건부적합 제외 ④ 로그인 불가와 닿는 지점).
|
||||||
@@ -150,7 +150,7 @@ service cloud.firestore {
|
|||||||
// 보유 배지 이력. 부여는 서버 판정.
|
// 보유 배지 이력. 부여는 서버 판정.
|
||||||
// 화면: S-M-BADGE(본인 도감) · S-M-IDCARD(본인 카드)
|
// 화면: S-M-BADGE(본인 도감) · S-M-IDCARD(본인 카드)
|
||||||
//
|
//
|
||||||
// 교차검증 G-04 — 예전 주석은 「타인 프로필의 배지 노출 허용」이라 적고 signedIn() 으로 열어 뒀는데,
|
// 예전 주석은 「타인 프로필의 배지 노출 허용」이라 적고 signedIn() 으로 열어 뒀는데,
|
||||||
// **1차 범위에 타인 프로필 화면이 없다**(화면 95개 전수 확인). 주석이 사실과 달랐다.
|
// **1차 범위에 타인 프로필 화면이 없다**(화면 95개 전수 확인). 주석이 사실과 달랐다.
|
||||||
// 타인 배지가 실제로 보이는 곳은 S-M-HERO 히어로 랭킹 하나뿐이고,
|
// 타인 배지가 실제로 보이는 곳은 S-M-HERO 히어로 랭킹 하나뿐이고,
|
||||||
// 그것은 /fandoms/{f}/members/{uid}.honorBadgeIds 비정규화 필드로 읽는다(설계서 §2.3·§4.4).
|
// 그것은 /fandoms/{f}/members/{uid}.honorBadgeIds 비정규화 필드로 읽는다(설계서 §2.3·§4.4).
|
||||||
@@ -179,7 +179,7 @@ service cloud.firestore {
|
|||||||
// 본인은 자기 동의 이력을 볼 수 있어야 한다(열람권). 화면: S-A-TERMS · S-M-SETTINGS
|
// 본인은 자기 동의 이력을 볼 수 있어야 한다(열람권). 화면: S-A-TERMS · S-M-SETTINGS
|
||||||
match /users/{uid}/consents/{consentId} {
|
match /users/{uid}/consents/{consentId} {
|
||||||
allow read: if isSelf(uid) || isViewer();
|
allow read: if isSelf(uid) || isViewer();
|
||||||
allow write: if false; // 기록은 fn-consent-record 경유 (교차검증 F-04)
|
allow write: if false; // 기록은 fn-consent-record 경유
|
||||||
}
|
}
|
||||||
|
|
||||||
// 차단 목록. 본인만 읽고 쓴다.
|
// 차단 목록. 본인만 읽고 쓴다.
|
||||||
@@ -315,19 +315,19 @@ service cloud.firestore {
|
|||||||
// 캠페인. 목표 달성률·후원 합계는 집계 필드라 클라이언트가 못 쓴다.
|
// 캠페인. 목표 달성률·후원 합계는 집계 필드라 클라이언트가 못 쓴다.
|
||||||
// 화면: S-C-LIST · S-C-DETAIL · S-C-NEW · S-AD-CAMPAIGN
|
// 화면: S-C-LIST · S-C-DETAIL · S-C-NEW · S-AD-CAMPAIGN
|
||||||
//
|
//
|
||||||
// ⚠️ 목록 쿼리에는 status 필터가 필수다 (교차검증 G-03).
|
// ⚠️ 목록 쿼리에는 status 필터가 필수다.
|
||||||
// 아래 read 규칙이 공개 상태만 허용하므로, status 필터가 없는 목록 쿼리는
|
// 아래 read 규칙이 공개 상태만 허용하므로, status 필터가 없는 목록 쿼리는
|
||||||
// draft·review 문서까지 매칭돼 **쿼리 전체가 거부**된다(Firestore 는 list 규칙을
|
// draft·review 문서까지 매칭돼 **쿼리 전체가 거부**된다(Firestore 는 list 규칙을
|
||||||
// 반환 후보 전체에 적용한다). 인덱스 문제로 오인하기 쉬운 지점이다.
|
// 반환 후보 전체에 적용한다). 인덱스 문제로 오인하기 쉬운 지점이다.
|
||||||
// 허용되는 목록 쿼리 형태는 설계서 §6.1 IDX-15·16·17·19 참조.
|
// 허용되는 목록 쿼리 형태는 설계서 §6.1 IDX-15·16·17·19 참조.
|
||||||
match /campaigns/{campaignId} {
|
match /campaigns/{campaignId} {
|
||||||
// 교차검증 F-08 — 예전에는 read: if true 라 심사 중(review)·초안(draft) 캠페인 본문이
|
// 예전에는 read: if true 라 심사 중(review)·초안(draft) 캠페인 본문이
|
||||||
// URL만 알면 미로그인 상태로 읽혔다. FR-CMP-01「승인 대기 캠페인은 목록에 노출하지 않는다」(E-CMP-03)를
|
// URL만 알면 미로그인 상태로 읽혔다. FR-CMP-01「승인 대기 캠페인은 목록에 노출하지 않는다」(E-CMP-03)를
|
||||||
// 클라이언트 쿼리 필터가 아니라 규칙에서 강제한다. 공개 상태만 무인증 열람 대상이다.
|
// 클라이언트 쿼리 필터가 아니라 규칙에서 강제한다. 공개 상태만 무인증 열람 대상이다.
|
||||||
allow read: if campaignIsPublic()
|
allow read: if campaignIsPublic()
|
||||||
|| isOps()
|
|| isOps()
|
||||||
|| (signedIn() && resource.data.creatorUid == request.auth.uid);
|
|| (signedIn() && resource.data.creatorUid == request.auth.uid);
|
||||||
// 교차검증 F-02 — 화이트리스트 방식. 목록에 없는 필드가 하나라도 있으면 거부되므로
|
// 화이트리스트 방식. 목록에 없는 필드가 하나라도 있으면 거부되므로
|
||||||
// 나중에 집계 필드가 추가돼도 자동으로 막힌다. 집계·분류 필드는 초기값을 못 벗어난다.
|
// 나중에 집계 필드가 추가돼도 자동으로 막힌다. 집계·분류 필드는 초기값을 못 벗어난다.
|
||||||
allow create: if signedIn()
|
allow create: if signedIn()
|
||||||
&& request.resource.data.keys().hasOnly([
|
&& request.resource.data.keys().hasOnly([
|
||||||
@@ -367,7 +367,7 @@ service cloud.firestore {
|
|||||||
// 화면: S-C-RESULT
|
// 화면: S-C-RESULT
|
||||||
match /campaigns/{campaignId}/reports/{reportId} {
|
match /campaigns/{campaignId}/reports/{reportId} {
|
||||||
allow read: if true;
|
allow read: if true;
|
||||||
// 교차검증 F-07 — §7.3 표는 「성공 캠페인 개설자」라고 적었는데 규칙은 creatorUid만 보고
|
// §7.3 표는 「성공 캠페인 개설자」라고 적었는데 규칙은 creatorUid만 보고
|
||||||
// 상태를 확인하지 않아 실패·초안 캠페인에도 결과보고를 올릴 수 있었다.
|
// 상태를 확인하지 않아 실패·초안 캠페인에도 결과보고를 올릴 수 있었다.
|
||||||
// 별첨1 ⑤ 판정 기준 「결과보고 게시 확인」의 대상은 성공한 캠페인이다.
|
// 별첨1 ⑤ 판정 기준 「결과보고 게시 확인」의 대상은 성공한 캠페인이다.
|
||||||
allow create: if signedIn()
|
allow create: if signedIn()
|
||||||
@@ -385,7 +385,7 @@ service cloud.firestore {
|
|||||||
// 화면: S-I-LOUNGE · S-I-POST · S-I-POST-NEW
|
// 화면: S-I-LOUNGE · S-I-POST · S-I-POST-NEW
|
||||||
match /posts/{postId} {
|
match /posts/{postId} {
|
||||||
allow read: if signedIn();
|
allow read: if signedIn();
|
||||||
// 교차검증 F-05 — 예전에는 authorUid·likeCount·visibility 3개만 봐서
|
// 예전에는 authorUid·likeCount·visibility 3개만 봐서
|
||||||
// ① authorName 을 "FANUZU 운영팀"으로 적어 사칭 ② commentCount 위조
|
// ① authorName 을 "FANUZU 운영팀"으로 적어 사칭 ② commentCount 위조
|
||||||
// ③ 소속하지 않은 팬덤 피드에 글 삽입이 가능했다. 화이트리스트 + 원본 대조로 닫는다.
|
// ③ 소속하지 않은 팬덤 피드에 글 삽입이 가능했다. 화이트리스트 + 원본 대조로 닫는다.
|
||||||
allow create: if authorIsSelf()
|
allow create: if authorIsSelf()
|
||||||
@@ -398,7 +398,7 @@ service cloud.firestore {
|
|||||||
&& request.resource.data.likeCount == 0
|
&& request.resource.data.likeCount == 0
|
||||||
&& request.resource.data.commentCount == 0
|
&& request.resource.data.commentCount == 0
|
||||||
&& request.resource.data.visibility == 'visible';
|
&& request.resource.data.visibility == 'visible';
|
||||||
// 교차검증 F-14 — 'title' 을 뺐다. 설계서 §2.6 posts 스키마에 title 필드가 없다.
|
// 'title' 을 뺐다. 설계서 §2.6 posts 스키마에 title 필드가 없다.
|
||||||
allow update: if signedIn() && resource.data.authorUid == request.auth.uid
|
allow update: if signedIn() && resource.data.authorUid == request.auth.uid
|
||||||
&& touchesOnly(['body','imageUrls','updatedAt']);
|
&& touchesOnly(['body','imageUrls','updatedAt']);
|
||||||
// 숨김·복원. rbac-summary 「콘텐츠 숨김·복원」 = 운영 이상
|
// 숨김·복원. rbac-summary 「콘텐츠 숨김·복원」 = 운영 이상
|
||||||
@@ -416,7 +416,7 @@ service cloud.firestore {
|
|||||||
// 댓글. 화면: S-I-POST
|
// 댓글. 화면: S-I-POST
|
||||||
match /posts/{postId}/comments/{commentId} {
|
match /posts/{postId}/comments/{commentId} {
|
||||||
allow read: if signedIn();
|
allow read: if signedIn();
|
||||||
// 교차검증 F-05 — 댓글에도 같은 표시명 사칭 경로가 있었다.
|
// 댓글에도 같은 표시명 사칭 경로가 있었다.
|
||||||
allow create: if authorIsSelf()
|
allow create: if authorIsSelf()
|
||||||
&& request.resource.data.keys().hasOnly([
|
&& request.resource.data.keys().hasOnly([
|
||||||
'authorUid','authorName','body','visibility','createdAt','updatedAt'
|
'authorUid','authorName','body','visibility','createdAt','updatedAt'
|
||||||
@@ -440,7 +440,7 @@ service cloud.firestore {
|
|||||||
// 화면: S-I-QUESTION · S-I-ANSWER-NEW
|
// 화면: S-I-QUESTION · S-I-ANSWER-NEW
|
||||||
match /questions/{questionId}/answers/{answerId} {
|
match /questions/{questionId}/answers/{answerId} {
|
||||||
allow read: if signedIn();
|
allow read: if signedIn();
|
||||||
// 교차검증 F-05 — 답변에도 같은 표시명 사칭 경로가 있었다.
|
// 답변에도 같은 표시명 사칭 경로가 있었다.
|
||||||
allow create: if authorIsSelf()
|
allow create: if authorIsSelf()
|
||||||
&& request.resource.data.keys().hasOnly([
|
&& request.resource.data.keys().hasOnly([
|
||||||
'authorUid','authorName','body','sourceUrls','accepted',
|
'authorUid','authorName','body','sourceUrls','accepted',
|
||||||
@@ -505,7 +505,7 @@ service cloud.firestore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 푸시 발송 이력·도달률. 운영자만 보고, 발송 집계는 Functions(FCM)가 쓴다.
|
// 푸시 발송 이력·도달률. 운영자만 보고, 발송 집계는 Functions(FCM)가 쓴다.
|
||||||
// 화면: S-AD-PUSH · rbac-summary 「푸시 발송 관리」 = 운영 이상 (교차검증 F-04 #2)
|
// 화면: S-AD-PUSH · rbac-summary 「푸시 발송 관리」 = 운영 이상
|
||||||
match /pushCampaigns/{pushId} {
|
match /pushCampaigns/{pushId} {
|
||||||
allow read: if isViewer();
|
allow read: if isViewer();
|
||||||
allow write: if false; // 발송·집계는 fn-push-send 경유
|
allow write: if false; // 발송·집계는 fn-push-send 경유
|
||||||
@@ -579,7 +579,7 @@ service cloud.firestore {
|
|||||||
allow write: if false;
|
allow write: if false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────── 12. 컬렉션 그룹 쿼리 (교차검증 F-01) ───────────────
|
// ─────────────── 12. 컬렉션 그룹 쿼리 ───────────────
|
||||||
//
|
//
|
||||||
// Firestore에서 `match /users/{uid}/popTransactions/{txId}` 형태의 규칙은
|
// Firestore에서 `match /users/{uid}/popTransactions/{txId}` 형태의 규칙은
|
||||||
// **단일 컬렉션 쿼리에만** 적용되고 collectionGroup('popTransactions') 에는 적용되지 않는다.
|
// **단일 컬렉션 쿼리에만** 적용되고 collectionGroup('popTransactions') 에는 적용되지 않는다.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:emulator": "../test/node_modules/.bin/firebase emulators:exec --config ../firebase.json --only firestore --project demo-fanuzu \"npm test\""
|
"test:emulator": "../test/node_modules/.bin/firebase emulators:exec --config ../firebase.json --only firestore,auth --project demo-fanuzu \"npm test\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"firebase-admin": "13.6.0",
|
"firebase-admin": "13.6.0",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as functionsV1 from 'firebase-functions/v1';
|
import * as functionsV1 from 'firebase-functions/v1';
|
||||||
import { onCall } from 'firebase-functions/v2/https';
|
import { HttpsError, onCall } from 'firebase-functions/v2/https';
|
||||||
import { getFirestore, FieldValue } from 'firebase-admin/firestore';
|
import { getFirestore, FieldValue } from 'firebase-admin/firestore';
|
||||||
import { getAuth } from 'firebase-admin/auth';
|
import { getAuth } from 'firebase-admin/auth';
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ import { ERR, fail } from '../core/errors.js';
|
|||||||
/**
|
/**
|
||||||
* `fn-auth-oncreate` — 계정 문서의 유일한 생성 주체.
|
* `fn-auth-oncreate` — 계정 문서의 유일한 생성 주체.
|
||||||
*
|
*
|
||||||
* 보안 규칙이 `users` 의 클라이언트 `create` 를 닫아 두었으므로(교차검증 F-06)
|
* 보안 규칙이 `users` 의 클라이언트 `create` 를 닫아 두었으므로
|
||||||
* 이 트리거가 만들지 않으면 계정 문서가 존재하지 않는다. 생성 주체를 하나로
|
* 이 트리거가 만들지 않으면 계정 문서가 존재하지 않는다. 생성 주체를 하나로
|
||||||
* 고정한 이유는 경합 때문이다 — 클라이언트가 먼저 만들면 서버 산출 필드가 빈
|
* 고정한 이유는 경합 때문이다 — 클라이언트가 먼저 만들면 서버 산출 필드가 빈
|
||||||
* 사용자 문서가 남는다.
|
* 사용자 문서가 남는다.
|
||||||
@@ -18,13 +18,18 @@ import { ERR, fail } from '../core/errors.js';
|
|||||||
*/
|
*/
|
||||||
export const fnAuthOnCreate = functionsV1.auth.user().onCreate(async (user) => {
|
export const fnAuthOnCreate = functionsV1.auth.user().onCreate(async (user) => {
|
||||||
const db = getFirestore();
|
const db = getFirestore();
|
||||||
const batch = db.batch();
|
const userRef = db.doc(`users/${user.uid}`);
|
||||||
|
const walletRef = db.doc(`users/${user.uid}/wallet/main`);
|
||||||
batch.set(db.doc(`users/${user.uid}`), {
|
// 이메일 — 플랫폼이 이메일 제공을 승인하고 사용자가 동의한 경우에만 수집한다. 없어도 가입·로그인된다.
|
||||||
|
// Auth 계정 이메일이 비면(OIDC 등) 제공자 정보의 이메일을 쓴다. 카카오(커스텀 토큰)는
|
||||||
|
// 계정을 만든 뒤에 연결 정보가 붙으므로 `exchangeSocial`(social_token.ts)이 따로 쓴다.
|
||||||
|
const email = user.email ?? user.providerData.find((p) => p.email)?.email ?? null;
|
||||||
|
const defaults: Record<string, unknown> = {
|
||||||
uid: user.uid,
|
uid: user.uid,
|
||||||
displayName: user.displayName ?? '팬우주 여행자',
|
displayName: user.displayName ?? '팬우주 여행자',
|
||||||
photoUrl: user.photoURL ?? null,
|
photoUrl: user.photoURL ?? null,
|
||||||
countryCode: 'KR',
|
countryCode: 'KR',
|
||||||
|
email,
|
||||||
locale: 'ko',
|
locale: 'ko',
|
||||||
fandomId: '',
|
fandomId: '',
|
||||||
authProviders: user.providerData.map((p) => p.providerId),
|
authProviders: user.providerData.map((p) => p.providerId),
|
||||||
@@ -34,22 +39,29 @@ export const fnAuthOnCreate = functionsV1.auth.user().onCreate(async (user) => {
|
|||||||
notificationPrefs: {},
|
notificationPrefs: {},
|
||||||
createdAt: FieldValue.serverTimestamp(),
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
updatedAt: FieldValue.serverTimestamp(),
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
}, { merge: true });
|
};
|
||||||
|
const wallet: Record<string, unknown> = {
|
||||||
batch.set(db.doc(`users/${user.uid}/wallet/main`), {
|
|
||||||
activityBalance: 0,
|
activityBalance: 0,
|
||||||
supportBalance: 0,
|
supportBalance: 0,
|
||||||
pendingActivity: 0,
|
pendingActivity: 0,
|
||||||
reconcileStatus: 'ok',
|
reconcileStatus: 'ok',
|
||||||
createdAt: FieldValue.serverTimestamp(),
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
updatedAt: FieldValue.serverTimestamp(),
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
}, { merge: true });
|
};
|
||||||
|
|
||||||
await batch.commit();
|
// 트리거가 늦게 돌면 그 사이 서버가 쓴 값(카카오 동의 이메일 등)이 이미 있다.
|
||||||
|
// 없는 필드만 채운다 — 덮어쓰면 먼저 쓴 값이 기본값으로 되돌아간다.
|
||||||
|
await db.runTransaction(async (tx) => {
|
||||||
|
const [u, w] = await Promise.all([tx.get(userRef), tx.get(walletRef)]);
|
||||||
|
const missing = (base: Record<string, unknown>, have: Record<string, unknown> | undefined) =>
|
||||||
|
Object.fromEntries(Object.entries(base).filter(([k]) => have?.[k] == null));
|
||||||
|
tx.set(userRef, missing(defaults, u.data()), { merge: true });
|
||||||
|
tx.set(walletRef, missing(wallet, w.data()), { merge: true });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `fn-consent-record` — 약관 동의 이력(교차검증 F-04).
|
* `fn-consent-record` — 약관 동의 이력.
|
||||||
*
|
*
|
||||||
* append-only 다. 철회도 기존 행을 고치지 않고 `granted: false` 인 새 행으로 남긴다.
|
* append-only 다. 철회도 기존 행을 고치지 않고 `granted: false` 인 새 행으로 남긴다.
|
||||||
* 동의 시점을 되짚을 수 없으면 분쟁에서 증명할 수단이 사라진다.
|
* 동의 시점을 되짚을 수 없으면 분쟁에서 증명할 수단이 사라진다.
|
||||||
@@ -73,6 +85,57 @@ export const fnConsentRecord = onCall(async (req) => {
|
|||||||
return { consentId };
|
return { consentId };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 가입 약관 필수 항목 — 서비스 이용약관 · 개인정보 수집·이용 동의 · 만 14세 이상 확인.
|
||||||
|
*
|
||||||
|
* 위치기반 서비스 약관은 가입 때 받지 않는다(위치기능을 처음 쓰기 직전에 따로 받는다).
|
||||||
|
* 개인정보처리방침은 동의 항목이 아니라 고지(링크)라 기록하지 않는다. 마케팅 수신은 선택 · 기본 OFF.
|
||||||
|
*/
|
||||||
|
export const SIGNUP_REQUIRED_TERMS = ['service', 'privacy', 'age'] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-signup-consent` — 가입 약관 동의를 한 번에 기록한다.
|
||||||
|
*
|
||||||
|
* 항목마다 `consents` 에 append-only 행을 남기고(약관 버전 포함), 사용자 문서에 `termsAgreedAt` 을
|
||||||
|
* 찍는다. 앱은 이 값이 없으면 로그인 직후 약관 화면으로 보낸다 — 가입 · 로그인 버튼이나 제공자와
|
||||||
|
* 무관하게 약관 · 만 14세 확인 없이 지나가지 못하게 하는 기준이다.
|
||||||
|
* 마케팅 동의는 알림 설정의 마케팅 구분(`notificationPrefs.marketing`)과 같은 값으로 둔다.
|
||||||
|
*/
|
||||||
|
export const fnSignupConsent = onCall(async (req) => {
|
||||||
|
const uid = callerUid(req);
|
||||||
|
const version = str(req.data, 'version');
|
||||||
|
const raw = (req.data as Record<string, unknown>)?.agreed;
|
||||||
|
const agreed = new Set(Array.isArray(raw) ? raw.filter((v): v is string => typeof v === 'string') : []);
|
||||||
|
if (!SIGNUP_REQUIRED_TERMS.every((t) => agreed.has(t))) {
|
||||||
|
throw new HttpsError('invalid-argument', '필수 약관에 모두 동의해 주세요.');
|
||||||
|
}
|
||||||
|
const marketing = agreed.has('marketing');
|
||||||
|
const db = getFirestore();
|
||||||
|
const batch = db.batch();
|
||||||
|
const meta = {
|
||||||
|
docVersion: version,
|
||||||
|
ip: req.rawRequest?.ip ?? null,
|
||||||
|
userAgent: req.rawRequest?.headers?.['user-agent'] ?? null,
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
};
|
||||||
|
for (const docType of [...SIGNUP_REQUIRED_TERMS, 'marketing']) {
|
||||||
|
const granted = docType === 'marketing' ? marketing : true;
|
||||||
|
batch.set(db.doc(`users/${uid}/consents/${docType}_${version}_${granted ? 'grant' : 'revoke'}`),
|
||||||
|
{ docType, granted, ...meta }, { merge: true });
|
||||||
|
}
|
||||||
|
batch.set(db.doc(`users/${uid}`), {
|
||||||
|
termsAgreedAt: FieldValue.serverTimestamp(),
|
||||||
|
termsVersion: version,
|
||||||
|
notificationPrefs: { marketing },
|
||||||
|
consentState: {
|
||||||
|
marketing: { granted: marketing, version, at: FieldValue.serverTimestamp() },
|
||||||
|
},
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
}, { merge: true });
|
||||||
|
await batch.commit();
|
||||||
|
return { termsVersion: version, marketing };
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `fn-admin-set-role` — 마스터 전용. 운영자 등급을 커스텀 클레임으로 심는다.
|
* `fn-admin-set-role` — 마스터 전용. 운영자 등급을 커스텀 클레임으로 심는다.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { defineString } from 'firebase-functions/params';
|
||||||
|
import { onCall } from 'firebase-functions/v2/https';
|
||||||
|
|
||||||
|
import { str } from '../core/callable.js';
|
||||||
|
import { ERR, fail } from '../core/errors.js';
|
||||||
|
import { exchangeSocial, type SocialSpec } from './social_token.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-auth-kakao` — 카카오 로그인·SNS 연동을 Firebase 커스텀 토큰으로 잇는다.
|
||||||
|
*
|
||||||
|
* **왜 OIDC 가 아니라 이 경로인가.** Android 에서 Firebase OIDC(`oidc.kakao`) 웹 흐름은
|
||||||
|
* 「카카오톡으로 로그인」을 고르면 카카오톡 앱으로 넘어갔다가 콜백이 Custom Tab 이 아닌 일반
|
||||||
|
* Chrome 에서 열려 Firebase 핸들러가 「missing initial state」로 끝난다. 카카오에는 앱 전환을
|
||||||
|
* 막는 파라미터가 없어서, 앱이 카카오 공식 SDK 로 로그인하고 서버가 그 토큰을 검증해
|
||||||
|
* 커스텀 토큰을 내준다(Firebase 「Authenticate with Firebase Using a Custom Authentication
|
||||||
|
* System」).
|
||||||
|
*
|
||||||
|
* **사용자 식별은 기존 `oidc.kakao` 연결을 그대로 쓴다.** Firebase 사용자의 제공자 정보에
|
||||||
|
* `oidc.kakao` / 카카오 회원번호(OIDC `sub` 와 같은 값)를 남겨 두고 그것으로 찾는다. 그래서
|
||||||
|
* - OIDC 시절 가입자는 같은 카카오 계정으로 들어오면 **같은 UID** 로 로그인되고,
|
||||||
|
* - SNS 연동 화면(`linkedAccounts`)이 지금처럼 `oidc.kakao` 를 보고 「연결됨」을 그린다.
|
||||||
|
* 신규 가입자의 UID 는 `kakao:<회원번호>` 다(결정적 — 동시 첫 로그인이 겹쳐도 한 명).
|
||||||
|
* UID 찾기 · SNS 연동 규칙은 LINE 과 같아 `social_token.ts` 에 있다.
|
||||||
|
*
|
||||||
|
* 입력: `{ accessToken, link? }`. `link: true` 면 로그인한 호출자 UID 에 카카오를 붙이고
|
||||||
|
* `{ linked: true }`, 아니면 `{ token }`(커스텀 토큰)을 돌려준다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const KAKAO_PROVIDER_ID = 'oidc.kakao';
|
||||||
|
|
||||||
|
/** 카카오 Developers 「앱 ID」(숫자). 비밀값이 아니다 — 토큰이 우리 앱에서 발급됐는지 본다. */
|
||||||
|
const kakaoAppId = defineString('KAKAO_APP_ID', { default: '' });
|
||||||
|
|
||||||
|
export interface KakaoProfile {
|
||||||
|
/** 카카오 회원번호. */
|
||||||
|
id: string;
|
||||||
|
nickname?: string;
|
||||||
|
/** 동의했고 유효·인증된 이메일만. 없어도 가입 · 로그인 · 연결이 된다. */
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const kakaoUidOf = (kakaoId: string) => `kakao:${kakaoId}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카카오 액세스 토큰을 카카오 API 로 검증한다.
|
||||||
|
*
|
||||||
|
* 1. `/v1/user/access_token_info` — 유효성 + **우리 앱 ID 로 발급됐는가**. 남의 앱에서 받은
|
||||||
|
* 토큰을 들고 와 그 사람으로 로그인하는 토큰 치환을 막는 지점이다.
|
||||||
|
* 2. `/v2/user/me` — 닉네임·이메일. 회원번호가 1 과 같아야 한다.
|
||||||
|
*/
|
||||||
|
export async function verifyKakaoToken(accessToken: string, appId: string): Promise<KakaoProfile> {
|
||||||
|
if (!appId) fail(ERR.authFailed, '카카오 로그인 설정이 끝나지 않았습니다.');
|
||||||
|
const get = async (path: string) => {
|
||||||
|
const res = await fetch(`https://kapi.kakao.com${path}`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) fail(ERR.authFailed, '카카오 인증을 확인하지 못했습니다.');
|
||||||
|
return (await res.json()) as Record<string, unknown>;
|
||||||
|
};
|
||||||
|
const info = await get('/v1/user/access_token_info');
|
||||||
|
if (String(info.app_id) !== appId) fail(ERR.authFailed, '카카오 인증을 확인하지 못했습니다.');
|
||||||
|
const me = await get('/v2/user/me');
|
||||||
|
if (String(me.id) !== String(info.id)) fail(ERR.authFailed, '카카오 인증을 확인하지 못했습니다.');
|
||||||
|
|
||||||
|
const account = (me.kakao_account ?? {}) as Record<string, unknown>;
|
||||||
|
const profile = (account.profile ?? {}) as Record<string, unknown>;
|
||||||
|
const email = account.is_email_valid === true && account.is_email_verified === true
|
||||||
|
&& typeof account.email === 'string' ? account.email : undefined;
|
||||||
|
const nickname = typeof profile.nickname === 'string' ? profile.nickname : undefined;
|
||||||
|
return {
|
||||||
|
id: String(info.id),
|
||||||
|
...(nickname ? { nickname } : {}),
|
||||||
|
...(email ? { email } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const KAKAO: SocialSpec = { providerId: KAKAO_PROVIDER_ID, uidPrefix: 'kakao', label: '카카오' };
|
||||||
|
|
||||||
|
export const fnAuthKakao = onCall(async (req) => {
|
||||||
|
const p = await verifyKakaoToken(str(req.data, 'accessToken'), kakaoAppId.value());
|
||||||
|
return exchangeSocial(KAKAO, { id: p.id, displayName: p.nickname, email: p.email }, req);
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { defineString } from 'firebase-functions/params';
|
||||||
|
import { onCall } from 'firebase-functions/v2/https';
|
||||||
|
|
||||||
|
import { optionalStr } from '../core/callable.js';
|
||||||
|
import { ERR, fail } from '../core/errors.js';
|
||||||
|
import { exchangeSocial, type SocialProfile, type SocialSpec } from './social_token.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-auth-line` — LINE 로그인 · SNS 연동을 Firebase 커스텀 토큰으로 잇는다. 카카오(`fnAuthKakao`)와 같은 구조다.
|
||||||
|
*
|
||||||
|
* **왜 OIDC 가 아니라 이 경로인가.** iOS 에서 Firebase OIDC(`oidc.line`) 웹 흐름은 첫 시도에 Firebase
|
||||||
|
* 핸들러가 「missing initial state」로 끝난다(`disable_ios_auto_login` 을 붙여도 같다). 앱이 LINE 공식
|
||||||
|
* SDK 로 로그인하고 서버가 그 토큰을 LINE API 로 검증해 커스텀 토큰을 내준다.
|
||||||
|
*
|
||||||
|
* **사용자 식별은 기존 `oidc.line` 연결을 그대로 쓴다** — OIDC 시절 가입자는 같은 UID 로 들어오고, SNS 연동
|
||||||
|
* 화면이 지금처럼 「연결됨」을 그린다. 신규 가입자의 UID 는 `line:<LINE userId>` 다.
|
||||||
|
*
|
||||||
|
* 입력: `{ idToken?, accessToken?, link? }` — 둘 중 하나는 있어야 한다. ID 토큰이 있으면 그것으로 검증한다
|
||||||
|
* (이메일은 ID 토큰에만 온다). `link: true` 면 로그인한 호출자 UID 에 LINE 을 붙이고 `{ linked: true }`,
|
||||||
|
* 아니면 `{ token }`(커스텀 토큰)을 돌려준다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const LINE_PROVIDER_ID = 'oidc.line';
|
||||||
|
|
||||||
|
/** LINE Login 채널 ID(숫자). 비밀값이 아니다 — 토큰이 우리 채널에서 발급됐는지 본다. */
|
||||||
|
const lineChannelId = defineString('LINE_CHANNEL_ID', { default: '' });
|
||||||
|
|
||||||
|
const LINE: SocialSpec = { providerId: LINE_PROVIDER_ID, uidPrefix: 'line', label: 'LINE' };
|
||||||
|
|
||||||
|
export const lineUidOf = (lineId: string) => `${LINE.uidPrefix}:${lineId}`;
|
||||||
|
|
||||||
|
const API = 'https://api.line.me';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LINE 토큰을 LINE API 로 검증한다. 남의 채널에서 받은 토큰을 들고 와 그 사람으로 로그인하는 토큰 치환을
|
||||||
|
* 막는 지점이다.
|
||||||
|
*
|
||||||
|
* - ID 토큰: `POST /oauth2/v2.1/verify` — 서명 · 만료 · `client_id`(우리 채널) 검증을 LINE 이 하고 `sub` ·
|
||||||
|
* `name` · `email`(동의했을 때만)을 돌려준다.
|
||||||
|
* - 액세스 토큰: `GET /oauth2/v2.1/verify` 로 우리 채널 발급 · 남은 유효 시간을 보고 `/v2/profile` 로 회원번호를 읽는다.
|
||||||
|
*/
|
||||||
|
export async function verifyLineToken(
|
||||||
|
token: { idToken?: string | undefined; accessToken?: string | undefined },
|
||||||
|
channelId: string,
|
||||||
|
): Promise<SocialProfile> {
|
||||||
|
if (!channelId) fail(ERR.authFailed, 'LINE 로그인 설정이 끝나지 않았습니다.');
|
||||||
|
const bad = (): never => fail(ERR.authFailed, 'LINE 인증을 확인하지 못했습니다.');
|
||||||
|
const json = async (res: Response) => {
|
||||||
|
if (!res.ok) bad();
|
||||||
|
return (await res.json()) as Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token.idToken) {
|
||||||
|
const claims = await json(await fetch(`${API}/oauth2/v2.1/verify`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({ id_token: token.idToken, client_id: channelId }).toString(),
|
||||||
|
}));
|
||||||
|
if (String(claims.aud) !== channelId || typeof claims.sub !== 'string' || !claims.sub) bad();
|
||||||
|
return {
|
||||||
|
id: claims.sub as string,
|
||||||
|
...(typeof claims.name === 'string' && claims.name ? { displayName: claims.name } : {}),
|
||||||
|
...(typeof claims.email === 'string' && claims.email ? { email: claims.email } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token.accessToken) fail(ERR.authFailed, 'LINE 인증 정보가 없습니다.');
|
||||||
|
const info = await json(await fetch(
|
||||||
|
`${API}/oauth2/v2.1/verify?access_token=${encodeURIComponent(token.accessToken)}`,
|
||||||
|
));
|
||||||
|
if (String(info.client_id) !== channelId || !(Number(info.expires_in) > 0)) bad();
|
||||||
|
const me = await json(await fetch(`${API}/v2/profile`, {
|
||||||
|
headers: { Authorization: `Bearer ${token.accessToken}` },
|
||||||
|
}));
|
||||||
|
if (typeof me.userId !== 'string' || !me.userId) bad();
|
||||||
|
return {
|
||||||
|
id: me.userId as string,
|
||||||
|
...(typeof me.displayName === 'string' && me.displayName ? { displayName: me.displayName } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fnAuthLine = onCall(async (req) => {
|
||||||
|
const profile = await verifyLineToken(
|
||||||
|
{ idToken: optionalStr(req.data, 'idToken'), accessToken: optionalStr(req.data, 'accessToken') },
|
||||||
|
lineChannelId.value(),
|
||||||
|
);
|
||||||
|
return exchangeSocial(LINE, profile, req);
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { getAuth } from 'firebase-admin/auth';
|
||||||
|
import { getFirestore } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
|
import { ERR, fail } from '../core/errors.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공식 SDK 로그인 + 서버 커스텀 토큰 제공자(카카오 `fnAuthKakao` · LINE `fnAuthLine`)의 공통 부분 —
|
||||||
|
* 검증된 제공자 계정으로 로그인할 UID 를 찾거나 만들고, SNS 연동으로 붙인다.
|
||||||
|
*
|
||||||
|
* **사용자 식별은 OIDC 시절 제공자 ID(`oidc.kakao` · `oidc.line`)를 그대로 쓴다.** Firebase 사용자의
|
||||||
|
* 제공자 정보에 그 ID / 제공자 회원번호(OIDC `sub` 와 같은 값)를 남겨 두고 그것으로 찾는다.
|
||||||
|
* OIDC 시절 가입자는 같은 UID 로 로그인되고, SNS 연동 화면이 지금처럼 「연결됨」을 그린다.
|
||||||
|
*/
|
||||||
|
export interface SocialSpec {
|
||||||
|
/** Firebase 제공자 ID — `oidc.kakao` · `oidc.line`. */
|
||||||
|
providerId: string;
|
||||||
|
/** 신규 가입자 UID 접두 — `kakao` · `line`. UID 는 `<접두>:<회원번호>`. */
|
||||||
|
uidPrefix: string;
|
||||||
|
/** 오류 문구에 쓰는 이름 — 「카카오」 · 「LINE」. */
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SocialProfile {
|
||||||
|
/** 제공자 회원번호(OIDC `sub`). */
|
||||||
|
id: string;
|
||||||
|
displayName?: string | undefined;
|
||||||
|
/** 동의했고 제공자가 준 이메일만. 없어도 가입 · 로그인 · 연결이 된다. */
|
||||||
|
email?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이 제공자 계정이 붙은 Firebase UID. 없으면 null. */
|
||||||
|
async function ownerOf(spec: SocialSpec, id: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
return (await getAuth().getUserByProviderUid(spec.providerId, id)).uid;
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as { code?: string }).code === 'auth/user-not-found') return null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkInfo(spec: SocialSpec, p: SocialProfile) {
|
||||||
|
return {
|
||||||
|
providerId: spec.providerId,
|
||||||
|
uid: p.id,
|
||||||
|
...(p.displayName ? { displayName: p.displayName } : {}),
|
||||||
|
...(p.email ? { email: p.email } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 로그인할 UID — 기존 연결이 있으면 그 UID, 없으면 `<접두>:<id>` 를 만들고 연결한다. */
|
||||||
|
export async function resolveSocialUid(spec: SocialSpec, p: SocialProfile): Promise<string> {
|
||||||
|
const existing = await ownerOf(spec, p.id);
|
||||||
|
if (existing) return existing;
|
||||||
|
const uid = `${spec.uidPrefix}:${p.id}`;
|
||||||
|
const auth = getAuth();
|
||||||
|
try {
|
||||||
|
await auth.createUser({ uid, ...(p.displayName ? { displayName: p.displayName } : {}) });
|
||||||
|
} catch (e) {
|
||||||
|
// 같은 사람의 첫 로그인이 겹쳤다. 먼저 만든 쪽을 쓴다.
|
||||||
|
if ((e as { code?: string }).code !== 'auth/uid-already-exists') throw e;
|
||||||
|
}
|
||||||
|
await auth.updateUser(uid, { providerToLink: linkInfo(spec, p) });
|
||||||
|
return uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SNS 연동 — 로그인한 [uid] 에 제공자 계정을 붙인다. 새 계정을 만들지 않고 자동 병합하지 않는다.
|
||||||
|
*
|
||||||
|
* 현재 FANUZU 계정의 유효한 인증 상태와 새 Provider 인증 자격을 **서버에서 모두** 확인해 붙인다.
|
||||||
|
* 이메일 일치는 소유 확인이 아니다 — 비교하지 않는다(이메일을 주지 않는 계정도 연결된다).
|
||||||
|
* - 새 Provider 자격: 호출부가 제공자 API 로 우리 앱(채널)에서 발급된 유효 토큰인지 먼저 본다.
|
||||||
|
* - 현재 계정: 호출 토큰(callable 이 서명 · 만료를 검증)에 더해, 계정이 정지 상태가 아니고 토큰이
|
||||||
|
* 세션 폐기(`tokensValidAfterTime`) 이후에 발급됐는지 본다 — 「세션 만료 · 인증 취소」.
|
||||||
|
* - 그 회원번호가 다른 UID 에 붙어 있으면 거부(E-AUTH-08). 붙이는 순간 다른 UID 가 먼저 붙였어도
|
||||||
|
* (동시 연결 충돌) 붙인 뒤 소유자를 다시 읽어 확인하고, 다르면 거부한다.
|
||||||
|
*/
|
||||||
|
export async function linkSocial(
|
||||||
|
spec: SocialSpec, uid: string, p: SocialProfile, authTime?: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const inUse = `이미 다른 계정에 연결된 ${spec.label} 계정입니다.`;
|
||||||
|
const owner = await ownerOf(spec, p.id);
|
||||||
|
if (owner === uid) return;
|
||||||
|
if (owner) fail(ERR.authCredentialInUse, inUse);
|
||||||
|
const user = await getAuth().getUser(uid);
|
||||||
|
const revokedAt = user.tokensValidAfterTime ? Date.parse(user.tokensValidAfterTime) : 0;
|
||||||
|
if (user.disabled || (authTime !== undefined && authTime * 1000 < revokedAt)) {
|
||||||
|
fail(ERR.authFailed, '로그인이 만료되었습니다. 다시 로그인한 뒤 연결해 주세요.', { reason: 'session' });
|
||||||
|
}
|
||||||
|
if (user.providerData.some((d) => d.providerId === spec.providerId)) {
|
||||||
|
fail(ERR.authAlreadyLinked, `이미 다른 ${spec.label} 계정이 연결되어 있습니다.`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await getAuth().updateUser(uid, { providerToLink: linkInfo(spec, p) });
|
||||||
|
} catch (e) {
|
||||||
|
const code = (e as { code?: string }).code ?? '';
|
||||||
|
if (code.includes('already-in-use') || code.includes('already-linked') || code.includes('already-exists')) {
|
||||||
|
fail(ERR.authCredentialInUse, inUse);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
if ((await ownerOf(spec, p.id)) !== uid) fail(ERR.authCredentialInUse, inUse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* callable 본문 — `link: true` 면 로그인한 호출자 UID 에 붙이고 `{ linked: true }`, 아니면 로그인할 UID 를
|
||||||
|
* 찾거나 만들어 `{ token }`(커스텀 토큰)을 돌려준다.
|
||||||
|
*/
|
||||||
|
export async function exchangeSocial(
|
||||||
|
spec: SocialSpec,
|
||||||
|
profile: SocialProfile,
|
||||||
|
req: { data: unknown; auth?: { uid: string; token?: { auth_time?: unknown } } },
|
||||||
|
): Promise<{ linked: true } | { token: string }> {
|
||||||
|
if ((req.data as Record<string, unknown>).link === true) {
|
||||||
|
const uid = req.auth?.uid;
|
||||||
|
if (!uid) fail(ERR.authFailed, '로그인이 필요합니다.');
|
||||||
|
const authTime = req.auth?.token?.auth_time;
|
||||||
|
await linkSocial(spec, uid, profile, typeof authTime === 'number' ? authTime : undefined);
|
||||||
|
return { linked: true };
|
||||||
|
}
|
||||||
|
const uid = await resolveSocialUid(spec, profile);
|
||||||
|
// 동의한 이메일만 사용자 문서에 남긴다. Auth 이메일은 다른 제공자 계정과 겹치면 생성이
|
||||||
|
// 거부되므로 쓰지 않는다. 이메일이 없어도 로그인은 그대로 된다.
|
||||||
|
if (profile.email) {
|
||||||
|
await getFirestore().doc(`users/${uid}`).set({ email: profile.email }, { merge: true });
|
||||||
|
}
|
||||||
|
return { token: await getAuth().createCustomToken(uid) };
|
||||||
|
}
|
||||||
@@ -9,6 +9,9 @@ import { HttpsError } from 'firebase-functions/v2/https';
|
|||||||
export const ERR = {
|
export const ERR = {
|
||||||
permissionDenied: 'E-PERM-01',
|
permissionDenied: 'E-PERM-01',
|
||||||
authFailed: 'E-AUTH-03',
|
authFailed: 'E-AUTH-03',
|
||||||
|
// 계정 연결(SNS 연동) — 앱 auth 카탈로그와 같은 ID. 08 은 「그 Provider 가 다른 계정에 이미 연결」(이메일 비교 없음).
|
||||||
|
authCredentialInUse: 'E-AUTH-08',
|
||||||
|
authAlreadyLinked: 'E-AUTH-09',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type ErrorCode = (typeof ERR)[keyof typeof ERR];
|
export type ErrorCode = (typeof ERR)[keyof typeof ERR];
|
||||||
@@ -20,12 +23,14 @@ type Status = 'invalid-argument' | 'failed-precondition' | 'already-exists'
|
|||||||
const STATUS: Record<ErrorCode, Status> = {
|
const STATUS: Record<ErrorCode, Status> = {
|
||||||
'E-PERM-01': 'permission-denied',
|
'E-PERM-01': 'permission-denied',
|
||||||
'E-AUTH-03': 'permission-denied',
|
'E-AUTH-03': 'permission-denied',
|
||||||
|
'E-AUTH-08': 'already-exists',
|
||||||
|
'E-AUTH-09': 'already-exists',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 카탈로그 ID 를 그대로 실은 오류. `details.code` 가 계약면이다. */
|
/** 카탈로그 ID 를 그대로 실은 오류. `details.code` 가 계약면이다. */
|
||||||
export class CatalogError extends HttpsError {
|
export class CatalogError extends HttpsError {
|
||||||
constructor(readonly catalogCode: ErrorCode, message: string) {
|
constructor(readonly catalogCode: ErrorCode, message: string, extra: Record<string, unknown> = {}) {
|
||||||
super(STATUS[catalogCode], message, { code: catalogCode });
|
super(STATUS[catalogCode], message, { ...extra, code: catalogCode });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,8 +41,8 @@ export class CatalogError extends HttpsError {
|
|||||||
* => {...}` 형태로는 호출 이후를 도달 불가로 좁히지 못한다. 그러면 호출 뒤의
|
* => {...}` 형태로는 호출 이후를 도달 불가로 좁히지 못한다. 그러면 호출 뒤의
|
||||||
* 코드가 「값이 없을 수도 있다」로 남아 불필요한 널 검사가 번진다.
|
* 코드가 「값이 없을 수도 있다」로 남아 불필요한 널 검사가 번진다.
|
||||||
*/
|
*/
|
||||||
export function fail(code: ErrorCode, message: string): never {
|
export function fail(code: ErrorCode, message: string, extra?: Record<string, unknown>): never {
|
||||||
throw new CatalogError(code, message);
|
throw new CatalogError(code, message, extra);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
import './core/bootstrap.js';
|
import './core/bootstrap.js';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
fnAuthOnCreate, fnConsentRecord, fnAdminSetRole,
|
fnAuthOnCreate, fnConsentRecord, fnSignupConsent, fnAdminSetRole,
|
||||||
} from './account/account.js';
|
} from './account/account.js';
|
||||||
|
export { fnAuthKakao } from './account/kakao.js';
|
||||||
|
export { fnAuthLine } from './account/line.js';
|
||||||
export { fnAdminWrite } from './admin/admin.js';
|
export { fnAdminWrite } from './admin/admin.js';
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { getAuth } from 'firebase-admin/auth';
|
||||||
|
import { getFirestore } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
|
import { bootAdmin, shutdownAdmin } from './support.js';
|
||||||
|
import { fnAuthOnCreate } from '../src/account/account.js';
|
||||||
|
import { fnAuthKakao, kakaoUidOf } from '../src/account/kakao.js';
|
||||||
|
|
||||||
|
/** 이메일 선택 수집 — 동의한 이메일만 남기고, 이메일이 없어도 가입 · 로그인된다. */
|
||||||
|
|
||||||
|
const U = 'test-email-user';
|
||||||
|
const KAKAO_ID = '555000111';
|
||||||
|
const db = () => getFirestore();
|
||||||
|
|
||||||
|
type KakaoReq = Parameters<typeof fnAuthKakao.run>[0];
|
||||||
|
const onCreate = (uid: string, extra: Record<string, unknown> = {}) =>
|
||||||
|
(fnAuthOnCreate as unknown as { run: (u: unknown) => Promise<void> })
|
||||||
|
.run({ uid, email: undefined, displayName: undefined, photoURL: undefined, providerData: [], ...extra });
|
||||||
|
|
||||||
|
function stubKakao(email?: string) {
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
|
||||||
|
if (url.endsWith('/v1/user/access_token_info')) return Response.json({ id: Number(KAKAO_ID), app_id: 123 });
|
||||||
|
return Response.json({
|
||||||
|
id: Number(KAKAO_ID),
|
||||||
|
kakao_account: { profile: { nickname: '카카오' }, ...(email ? { email, is_email_valid: true, is_email_verified: true } : {}) },
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wipe() {
|
||||||
|
await getAuth().deleteUsers([U, kakaoUidOf(KAKAO_ID)]);
|
||||||
|
for (const uid of [U, kakaoUidOf(KAKAO_ID)]) await db().recursiveDelete(db().doc(`users/${uid}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
if (!process.env.FIREBASE_AUTH_EMULATOR_HOST) throw new Error('FIREBASE_AUTH_EMULATOR_HOST 가 없습니다.');
|
||||||
|
process.env.KAKAO_APP_ID = '123';
|
||||||
|
bootAdmin();
|
||||||
|
});
|
||||||
|
afterAll(async () => { await wipe(); await shutdownAdmin(); });
|
||||||
|
beforeEach(wipe);
|
||||||
|
afterEach(() => { vi.unstubAllGlobals(); });
|
||||||
|
|
||||||
|
describe('계정 생성 트리거', () => {
|
||||||
|
it('제공자 정보의 이메일을 쓰고 · 이미 쓴 값은 덮지 않는다', async () => {
|
||||||
|
await db().doc(`users/${U}`).set({ displayName: '먼저씀' });
|
||||||
|
await onCreate(U, { providerData: [{ providerId: 'oidc.line', email: 'line@fan.io' }] });
|
||||||
|
const user = (await db().doc(`users/${U}`).get()).data()!;
|
||||||
|
expect(user.displayName).toBe('먼저씀');
|
||||||
|
expect(user.email).toBe('line@fan.io');
|
||||||
|
expect(user.status).toBe('active');
|
||||||
|
expect((await db().doc(`users/${U}/wallet/main`).get()).get('activityBalance')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('이메일이 없으면 null 로 만든다', async () => {
|
||||||
|
await onCreate(U, { providerData: [{ providerId: 'oidc.line' }] });
|
||||||
|
expect((await db().doc(`users/${U}`).get()).get('email')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('카카오 이메일', () => {
|
||||||
|
it('동의한 이메일만 사용자 문서에 남기고 · 없어도 로그인된다', async () => {
|
||||||
|
stubKakao();
|
||||||
|
const first = await fnAuthKakao.run({ data: { accessToken: 'good' } } as KakaoReq) as { token: string };
|
||||||
|
expect(first.token).toBeTruthy();
|
||||||
|
expect((await db().doc(`users/${kakaoUidOf(KAKAO_ID)}`).get()).get('email')).toBeUndefined();
|
||||||
|
stubKakao('fan@kakao.com');
|
||||||
|
await fnAuthKakao.run({ data: { accessToken: 'good' } } as KakaoReq);
|
||||||
|
expect((await db().doc(`users/${kakaoUidOf(KAKAO_ID)}`).get()).get('email')).toBe('fan@kakao.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { getAuth } from 'firebase-admin/auth';
|
||||||
|
|
||||||
|
import { bootAdmin, shutdownAdmin } from './support.js';
|
||||||
|
import { fnAuthKakao, KAKAO_PROVIDER_ID, kakaoUidOf } from '../src/account/kakao.js';
|
||||||
|
|
||||||
|
/** 카카오 커스텀 토큰 — 토큰 검증 · 기존 oidc.kakao 사용자 매핑 · 신규 UID · SNS 연동. */
|
||||||
|
|
||||||
|
const APP_ID = '123456';
|
||||||
|
const KAKAO_ID = '987654321';
|
||||||
|
const OLD_UID = 'test-kakao-oidc-user';
|
||||||
|
const GOOGLE_UID = 'test-kakao-google-user';
|
||||||
|
|
||||||
|
type Req = Parameters<typeof fnAuthKakao.run>[0];
|
||||||
|
const call = (data: Record<string, unknown>, uid?: string) =>
|
||||||
|
fnAuthKakao.run({ data, ...(uid ? { auth: { uid, token: {} } } : {}) } as Req);
|
||||||
|
const codeOf = async (p: Promise<unknown>) => {
|
||||||
|
try { await p; return 'ok'; } catch (e) {
|
||||||
|
const d = (e as { details?: { code?: string; reason?: string } }).details;
|
||||||
|
return d?.reason ?? d?.code;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 카카오 API 흉내. 토큰 'good' 만 우리 앱 것, 'other-app' 은 남의 앱 토큰이다. */
|
||||||
|
function stubKakao(email?: string) {
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async (url: string, init: { headers: Record<string, string> }) => {
|
||||||
|
const token = init.headers.Authorization?.replace('Bearer ', '');
|
||||||
|
if (token === 'expired') return new Response('{}', { status: 401 });
|
||||||
|
if (url.endsWith('/v1/user/access_token_info')) {
|
||||||
|
return Response.json({ id: Number(KAKAO_ID), expires_in: 100, app_id: token === 'other-app' ? 1 : Number(APP_ID) });
|
||||||
|
}
|
||||||
|
return Response.json({
|
||||||
|
id: Number(KAKAO_ID),
|
||||||
|
kakao_account: {
|
||||||
|
profile: { nickname: '카카오팬' },
|
||||||
|
...(email ? { email, is_email_valid: true, is_email_verified: true } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wipe() {
|
||||||
|
const uids = [OLD_UID, GOOGLE_UID, kakaoUidOf(KAKAO_ID)];
|
||||||
|
await getAuth().deleteUsers(uids);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
if (!process.env.FIREBASE_AUTH_EMULATOR_HOST) {
|
||||||
|
throw new Error('FIREBASE_AUTH_EMULATOR_HOST 가 없습니다. `npm run test:emulator` 로 실행하십시오.');
|
||||||
|
}
|
||||||
|
process.env.KAKAO_APP_ID = APP_ID;
|
||||||
|
bootAdmin();
|
||||||
|
});
|
||||||
|
afterAll(async () => { await wipe(); await shutdownAdmin(); });
|
||||||
|
beforeEach(async () => { await wipe(); stubKakao(); });
|
||||||
|
afterEach(() => { vi.unstubAllGlobals(); });
|
||||||
|
|
||||||
|
describe('토큰 검증', () => {
|
||||||
|
it('만료·무효 토큰과 남의 앱 토큰은 E-AUTH-03', async () => {
|
||||||
|
expect(await codeOf(call({ accessToken: 'expired' }))).toBe('E-AUTH-03');
|
||||||
|
expect(await codeOf(call({ accessToken: 'other-app' }))).toBe('E-AUTH-03');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('로그인', () => {
|
||||||
|
it('OIDC 시절 oidc.kakao 가입자는 같은 UID 로 들어온다', async () => {
|
||||||
|
await getAuth().createUser({ uid: OLD_UID });
|
||||||
|
await getAuth().updateUser(OLD_UID, { providerToLink: { providerId: KAKAO_PROVIDER_ID, uid: KAKAO_ID } });
|
||||||
|
const { token } = await call({ accessToken: 'good' }) as { token: string };
|
||||||
|
expect(JSON.parse(Buffer.from(token.split('.')[1]!, 'base64url').toString()).uid).toBe(OLD_UID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('신규는 kakao:<회원번호> 로 만들고 oidc.kakao 를 붙인다 · 두 번째도 같은 UID', async () => {
|
||||||
|
const first = await call({ accessToken: 'good' }) as { token: string };
|
||||||
|
await call({ accessToken: 'good' });
|
||||||
|
const uid = JSON.parse(Buffer.from(first.token.split('.')[1]!, 'base64url').toString()).uid;
|
||||||
|
expect(uid).toBe(kakaoUidOf(KAKAO_ID));
|
||||||
|
const user = await getAuth().getUser(uid);
|
||||||
|
expect(user.displayName).toBe('카카오팬');
|
||||||
|
expect(user.providerData.map((d) => [d.providerId, d.uid])).toEqual([[KAKAO_PROVIDER_ID, KAKAO_ID]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SNS 연동', () => {
|
||||||
|
it('로그인 없이 link 는 E-AUTH-03', async () => {
|
||||||
|
expect(await codeOf(call({ accessToken: 'good', link: true }))).toBe('E-AUTH-03');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Google 사용자에 붙인다 · 다른 UID 가 쓰는 카카오는 E-AUTH-08', async () => {
|
||||||
|
await getAuth().createUser({ uid: GOOGLE_UID, email: 'fan@fanuzu.co.kr' });
|
||||||
|
expect(await call({ accessToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true });
|
||||||
|
const user = await getAuth().getUser(GOOGLE_UID);
|
||||||
|
expect(user.providerData.some((d) => d.providerId === KAKAO_PROVIDER_ID && d.uid === KAKAO_ID)).toBe(true);
|
||||||
|
|
||||||
|
await getAuth().createUser({ uid: OLD_UID });
|
||||||
|
expect(await codeOf(call({ accessToken: 'good', link: true }, OLD_UID))).toBe('E-AUTH-08');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('이메일이 달라도 · 카카오 이메일이 없어도 연결한다 — 이메일 일치는 소유 확인이 아니다', async () => {
|
||||||
|
await getAuth().createUser({ uid: GOOGLE_UID, email: 'fan@fanuzu.co.kr' });
|
||||||
|
stubKakao('other@kakao.com');
|
||||||
|
expect(await call({ accessToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true });
|
||||||
|
await getAuth().deleteUser(GOOGLE_UID);
|
||||||
|
await getAuth().createUser({ uid: GOOGLE_UID, email: 'fan@fanuzu.co.kr' });
|
||||||
|
stubKakao();
|
||||||
|
expect(await call({ accessToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true });
|
||||||
|
const user = await getAuth().getUser(GOOGLE_UID);
|
||||||
|
expect(user.providerData.some((d) => d.providerId === KAKAO_PROVIDER_ID && d.uid === KAKAO_ID)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('세션이 폐기된 뒤의 토큰 · 정지된 계정으로는 붙이지 않는다', async () => {
|
||||||
|
await getAuth().createUser({ uid: GOOGLE_UID });
|
||||||
|
const before = Math.floor(Date.now() / 1000) - 60;
|
||||||
|
await getAuth().revokeRefreshTokens(GOOGLE_UID);
|
||||||
|
const linkAs = (authTime: number) => fnAuthKakao.run(
|
||||||
|
{ data: { accessToken: 'good', link: true }, auth: { uid: GOOGLE_UID, token: { auth_time: authTime } } } as Req,
|
||||||
|
);
|
||||||
|
expect(await codeOf(linkAs(before))).toBe('session');
|
||||||
|
await getAuth().updateUser(GOOGLE_UID, { disabled: true });
|
||||||
|
expect(await codeOf(linkAs(Math.floor(Date.now() / 1000) + 5))).toBe('session');
|
||||||
|
expect((await getAuth().getUser(GOOGLE_UID)).providerData).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('이미 다른 카카오가 붙어 있으면 E-AUTH-09 · 같은 카카오면 그대로 성공', async () => {
|
||||||
|
await getAuth().createUser({ uid: GOOGLE_UID });
|
||||||
|
await getAuth().updateUser(GOOGLE_UID, { providerToLink: { providerId: KAKAO_PROVIDER_ID, uid: '111' } });
|
||||||
|
expect(await codeOf(call({ accessToken: 'good', link: true }, GOOGLE_UID))).toBe('E-AUTH-09');
|
||||||
|
await getAuth().deleteUser(GOOGLE_UID);
|
||||||
|
await getAuth().createUser({ uid: GOOGLE_UID });
|
||||||
|
expect(await call({ accessToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true });
|
||||||
|
expect(await call({ accessToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { getAuth } from 'firebase-admin/auth';
|
||||||
|
import { getFirestore } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
|
import { bootAdmin, shutdownAdmin } from './support.js';
|
||||||
|
import { fnAuthLine, LINE_PROVIDER_ID, lineUidOf } from '../src/account/line.js';
|
||||||
|
|
||||||
|
/** LINE 커스텀 토큰 — ID 토큰 · 액세스 토큰 검증 · 기존 oidc.line 사용자 매핑 · 신규 UID · 이메일 · SNS 연동. */
|
||||||
|
|
||||||
|
const CHANNEL = '2000000001';
|
||||||
|
const LINE_ID = 'U0123456789abcdef0123456789abcdef';
|
||||||
|
const OLD_UID = 'test-line-oidc-user';
|
||||||
|
const GOOGLE_UID = 'test-line-google-user';
|
||||||
|
|
||||||
|
type Req = Parameters<typeof fnAuthLine.run>[0];
|
||||||
|
const call = (data: Record<string, unknown>, uid?: string) =>
|
||||||
|
fnAuthLine.run({ data, ...(uid ? { auth: { uid, token: {} } } : {}) } as Req);
|
||||||
|
const codeOf = async (p: Promise<unknown>) => {
|
||||||
|
try { await p; return 'ok'; } catch (e) {
|
||||||
|
const d = (e as { details?: { code?: string; reason?: string } }).details;
|
||||||
|
return d?.reason ?? d?.code;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const uidOf = (token: string) => JSON.parse(Buffer.from(token.split('.')[1]!, 'base64url').toString()).uid as string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LINE API 흉내. ID 토큰 'good' · 액세스 토큰 'good' 만 우리 채널 것, 'other-app' 은 남의 채널, 'expired' 는 만료.
|
||||||
|
* 실제 LINE 처럼 ID 토큰 검증은 보낸 client_id 와 aud 가 다르면 400 이다.
|
||||||
|
*/
|
||||||
|
function stubLine(email?: string) {
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: { method?: string; body?: string; headers?: Record<string, string> }) => {
|
||||||
|
if (init?.method === 'POST' && url.endsWith('/oauth2/v2.1/verify')) {
|
||||||
|
const form = new URLSearchParams(init.body);
|
||||||
|
const token = form.get('id_token');
|
||||||
|
if (token === 'expired') return Response.json({ error: 'invalid_request' }, { status: 400 });
|
||||||
|
const aud = token === 'other-app' ? '1' : CHANNEL;
|
||||||
|
if (form.get('client_id') !== aud) return Response.json({ error: 'invalid_request' }, { status: 400 });
|
||||||
|
return Response.json({ iss: 'https://access.line.me', sub: LINE_ID, aud, name: 'LINE팬', ...(email ? { email } : {}) });
|
||||||
|
}
|
||||||
|
if (url.includes('/oauth2/v2.1/verify?access_token=')) {
|
||||||
|
const token = new URL(url).searchParams.get('access_token');
|
||||||
|
if (token === 'expired') return Response.json({ error: 'invalid_request' }, { status: 400 });
|
||||||
|
return Response.json({ scope: 'profile openid', client_id: token === 'other-app' ? '1' : CHANNEL, expires_in: 100 });
|
||||||
|
}
|
||||||
|
if (url.endsWith('/v2/profile')) return Response.json({ userId: LINE_ID, displayName: 'LINE팬' });
|
||||||
|
return new Response('{}', { status: 404 });
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wipe() {
|
||||||
|
await getAuth().deleteUsers([OLD_UID, GOOGLE_UID, lineUidOf(LINE_ID)]);
|
||||||
|
for (const uid of [OLD_UID, GOOGLE_UID, lineUidOf(LINE_ID)]) await getFirestore().doc(`users/${uid}`).delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
if (!process.env.FIREBASE_AUTH_EMULATOR_HOST) {
|
||||||
|
throw new Error('FIREBASE_AUTH_EMULATOR_HOST 가 없습니다. `npm run test:emulator` 로 실행하십시오.');
|
||||||
|
}
|
||||||
|
process.env.LINE_CHANNEL_ID = CHANNEL;
|
||||||
|
bootAdmin();
|
||||||
|
});
|
||||||
|
afterAll(async () => { await wipe(); await shutdownAdmin(); });
|
||||||
|
beforeEach(async () => { await wipe(); stubLine(); });
|
||||||
|
afterEach(() => { vi.unstubAllGlobals(); });
|
||||||
|
|
||||||
|
describe('토큰 검증', () => {
|
||||||
|
it('만료 · 남의 채널 토큰(ID · 액세스 모두)과 토큰 없음은 E-AUTH-03', async () => {
|
||||||
|
for (const key of ['idToken', 'accessToken']) {
|
||||||
|
expect(await codeOf(call({ [key]: 'expired' }))).toBe('E-AUTH-03');
|
||||||
|
expect(await codeOf(call({ [key]: 'other-app' }))).toBe('E-AUTH-03');
|
||||||
|
}
|
||||||
|
expect(await codeOf(call({}))).toBe('E-AUTH-03');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ID 토큰이 있으면 ID 토큰으로 검증한다(액세스 토큰이 남의 채널 것이어도 보지 않는다)', async () => {
|
||||||
|
const { token } = await call({ idToken: 'good', accessToken: 'other-app' }) as { token: string };
|
||||||
|
expect(uidOf(token)).toBe(lineUidOf(LINE_ID));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('로그인', () => {
|
||||||
|
it('OIDC 시절 oidc.line 가입자는 같은 UID 로 들어온다', async () => {
|
||||||
|
await getAuth().createUser({ uid: OLD_UID });
|
||||||
|
await getAuth().updateUser(OLD_UID, { providerToLink: { providerId: LINE_PROVIDER_ID, uid: LINE_ID } });
|
||||||
|
const { token } = await call({ idToken: 'good' }) as { token: string };
|
||||||
|
expect(uidOf(token)).toBe(OLD_UID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('신규는 line:<userId> 로 만들고 oidc.line 을 붙인다 · 액세스 토큰만으로도 같은 UID', async () => {
|
||||||
|
const first = await call({ idToken: 'good' }) as { token: string };
|
||||||
|
const second = await call({ accessToken: 'good' }) as { token: string };
|
||||||
|
expect(uidOf(first.token)).toBe(lineUidOf(LINE_ID));
|
||||||
|
expect(uidOf(second.token)).toBe(lineUidOf(LINE_ID));
|
||||||
|
const user = await getAuth().getUser(lineUidOf(LINE_ID));
|
||||||
|
expect(user.displayName).toBe('LINE팬');
|
||||||
|
expect(user.providerData.map((d) => [d.providerId, d.uid])).toEqual([[LINE_PROVIDER_ID, LINE_ID]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('이메일은 LINE 이 줄 때(동의)만 사용자 문서에 남긴다 — 없어도 로그인된다', async () => {
|
||||||
|
await call({ idToken: 'good' });
|
||||||
|
expect((await getFirestore().doc(`users/${lineUidOf(LINE_ID)}`).get()).get('email')).toBeUndefined();
|
||||||
|
stubLine('fan@line.me');
|
||||||
|
await call({ idToken: 'good' });
|
||||||
|
expect((await getFirestore().doc(`users/${lineUidOf(LINE_ID)}`).get()).get('email')).toBe('fan@line.me');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SNS 연동', () => {
|
||||||
|
it('로그인 없이 link 는 E-AUTH-03', async () => {
|
||||||
|
expect(await codeOf(call({ idToken: 'good', link: true }))).toBe('E-AUTH-03');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Google 사용자에 붙인다(이메일이 달라도 · 없어도) · 다른 UID 가 쓰는 LINE 은 E-AUTH-08', async () => {
|
||||||
|
await getAuth().createUser({ uid: GOOGLE_UID, email: 'fan@fanuzu.co.kr' });
|
||||||
|
stubLine('other@line.me');
|
||||||
|
expect(await call({ idToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true });
|
||||||
|
const user = await getAuth().getUser(GOOGLE_UID);
|
||||||
|
expect(user.providerData.some((d) => d.providerId === LINE_PROVIDER_ID && d.uid === LINE_ID)).toBe(true);
|
||||||
|
|
||||||
|
await getAuth().createUser({ uid: OLD_UID });
|
||||||
|
expect(await codeOf(call({ accessToken: 'good', link: true }, OLD_UID))).toBe('E-AUTH-08');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('세션이 폐기된 뒤의 토큰 · 정지된 계정으로는 붙이지 않는다', async () => {
|
||||||
|
await getAuth().createUser({ uid: GOOGLE_UID });
|
||||||
|
const before = Math.floor(Date.now() / 1000) - 60;
|
||||||
|
await getAuth().revokeRefreshTokens(GOOGLE_UID);
|
||||||
|
const linkAs = (authTime: number) => fnAuthLine.run(
|
||||||
|
{ data: { idToken: 'good', link: true }, auth: { uid: GOOGLE_UID, token: { auth_time: authTime } } } as Req,
|
||||||
|
);
|
||||||
|
expect(await codeOf(linkAs(before))).toBe('session');
|
||||||
|
await getAuth().updateUser(GOOGLE_UID, { disabled: true });
|
||||||
|
expect(await codeOf(linkAs(Math.floor(Date.now() / 1000) + 5))).toBe('session');
|
||||||
|
expect((await getAuth().getUser(GOOGLE_UID)).providerData).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('이미 다른 LINE 이 붙어 있으면 E-AUTH-09 · 같은 LINE 이면 그대로 성공', async () => {
|
||||||
|
await getAuth().createUser({ uid: GOOGLE_UID });
|
||||||
|
await getAuth().updateUser(GOOGLE_UID, { providerToLink: { providerId: LINE_PROVIDER_ID, uid: 'U-other' } });
|
||||||
|
expect(await codeOf(call({ idToken: 'good', link: true }, GOOGLE_UID))).toBe('E-AUTH-09');
|
||||||
|
await getAuth().deleteUser(GOOGLE_UID);
|
||||||
|
await getAuth().createUser({ uid: GOOGLE_UID });
|
||||||
|
expect(await call({ idToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true });
|
||||||
|
expect(await call({ idToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { getFirestore } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
|
import { bootAdmin, shutdownAdmin } from './support.js';
|
||||||
|
import { fnSignupConsent } from '../src/account/account.js';
|
||||||
|
|
||||||
|
/** 가입 약관 동의 — 필수 3항 검사 · 항목별 동의 행 · 동의 시각 기록. */
|
||||||
|
|
||||||
|
const U = 'test-consent-user';
|
||||||
|
const db = () => getFirestore();
|
||||||
|
|
||||||
|
type CallReq = Parameters<typeof fnSignupConsent.run>[0];
|
||||||
|
const as = (uid: string, data: Record<string, unknown>) => ({ data, auth: { uid, token: {} } }) as CallReq;
|
||||||
|
const codeOf = async (p: Promise<unknown>) => {
|
||||||
|
try { await p; return 'ok'; } catch (e) { return (e as { code?: string }).code; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const wipe = () => db().recursiveDelete(db().doc(`users/${U}`));
|
||||||
|
|
||||||
|
beforeAll(() => { bootAdmin(); });
|
||||||
|
afterAll(async () => { await wipe(); await shutdownAdmin(); });
|
||||||
|
beforeEach(wipe);
|
||||||
|
|
||||||
|
describe('fnSignupConsent', () => {
|
||||||
|
it('필수(서비스 · 개인정보 수집·이용 · 만 14세) 하나라도 빠지면 거부하고 기록하지 않는다', async () => {
|
||||||
|
expect(await codeOf(fnSignupConsent.run(as(U, { version: 'v1', agreed: ['service', 'age'] }))))
|
||||||
|
.toBe('invalid-argument');
|
||||||
|
expect((await db().doc(`users/${U}`).get()).get('termsAgreedAt')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('필수만 동의 — 항목별 행 · 마케팅 기본 OFF · termsAgreedAt', async () => {
|
||||||
|
await fnSignupConsent.run(as(U, { version: 'v1', agreed: ['service', 'privacy', 'age'] }));
|
||||||
|
const user = (await db().doc(`users/${U}`).get()).data()!;
|
||||||
|
expect(user.termsAgreedAt).toBeDefined();
|
||||||
|
expect(user.termsVersion).toBe('v1');
|
||||||
|
expect(user.notificationPrefs.marketing).toBe(false);
|
||||||
|
const rows = (await db().collection(`users/${U}/consents`).get()).docs.map((d) => d.id).sort();
|
||||||
|
expect(rows).toEqual(['age_v1_grant', 'marketing_v1_revoke', 'privacy_v1_grant', 'service_v1_grant']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('마케팅 선택 동의는 알림 마케팅 구분과 같은 값이 된다', async () => {
|
||||||
|
await fnSignupConsent.run(as(U, { version: 'v1', agreed: ['service', 'privacy', 'age', 'marketing'] }));
|
||||||
|
expect((await db().doc(`users/${U}`).get()).get('notificationPrefs.marketing')).toBe(true);
|
||||||
|
expect((await db().doc(`users/${U}/consents/marketing_v1_grant`).get()).get('granted')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('로그인하지 않은 호출은 거부', async () => {
|
||||||
|
expect(await codeOf(fnSignupConsent.run({ data: { version: 'v1', agreed: [] } } as CallReq)))
|
||||||
|
.not.toBe('ok');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "npm run test:rules && npm run test:functions",
|
"test": "npm run test:rules && npm run test:functions",
|
||||||
"test:rules": "firebase emulators:exec --only firestore --project demo-fanuzu \"npm --prefix test test\"",
|
"test:rules": "firebase emulators:exec --only firestore --project demo-fanuzu \"npm --prefix test test\"",
|
||||||
"test:functions": "firebase emulators:exec --only firestore --project demo-fanuzu \"npm --prefix functions test\""
|
"test:functions": "firebase emulators:exec --only firestore,auth --project demo-fanuzu \"npm --prefix functions test\""
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"firebase-tools": "15.28.2"
|
"firebase-tools": "15.28.2"
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<!--
|
||||||
|
TestFlight · App Store 업로드용 내보내기 설정.
|
||||||
|
|
||||||
|
`xcodebuild -exportArchive` 가 이 파일을 읽는다. 없으면 대화형으로 물어보므로
|
||||||
|
자동 배포가 불가능하다 — 계정이 열린 뒤에 만들면 그날 하루를 여기서 쓴다.
|
||||||
|
|
||||||
|
-->
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>method</key>
|
||||||
|
<string>app-store-connect</string>
|
||||||
|
|
||||||
|
<!-- Fancake Inc. -->
|
||||||
|
<key>teamID</key>
|
||||||
|
<string>V28G543VK5</string>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Xcode 가 프로비저닝 프로파일을 알아서 만들고 갱신한다. 수동으로 두면
|
||||||
|
인증서가 만료될 때마다 배포가 멈춘다.
|
||||||
|
-->
|
||||||
|
<key>signingStyle</key>
|
||||||
|
<string>automatic</string>
|
||||||
|
|
||||||
|
<!-- 심벌을 함께 올려야 크래시 로그가 사람이 읽을 수 있는 형태로 온다. -->
|
||||||
|
<key>uploadSymbols</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- 비트코드는 Xcode 14 부터 폐지되었다. -->
|
||||||
|
<key>uploadBitcode</key>
|
||||||
|
<false/>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
앱 씬 크기 최적화. 기기별로 필요한 자산만 내려가 설치 용량이 줄어든다.
|
||||||
|
-->
|
||||||
|
<key>thinning</key>
|
||||||
|
<string><thin-for-all-variants></string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -1,2 +1,10 @@
|
|||||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||||
#include "Generated.xcconfig"
|
#include "Generated.xcconfig"
|
||||||
|
|
||||||
|
// Google 로그인 콜백 스킴. tool/ios/inject_url_scheme.py 가 만든다.
|
||||||
|
// `#include?` 는 파일이 없어도 빌드를 멈추지 않는다 — 그때는 Google 로그인만 동작하지 않는다.
|
||||||
|
#include? "../config/Google.xcconfig"
|
||||||
|
|
||||||
|
// 카카오 네이티브 앱 키(FANUZU_KAKAO_NATIVE_APP_KEY=...) — Info.plist 의 kakao{키} URL 스킴.
|
||||||
|
// Dart(--dart-define-from-file)·Android 와 같은 파일이다. 없으면 카카오 로그인만 막힌다.
|
||||||
|
#include? "../../config/kakao.env"
|
||||||
|
|||||||
@@ -1,2 +1,10 @@
|
|||||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||||
#include "Generated.xcconfig"
|
#include "Generated.xcconfig"
|
||||||
|
|
||||||
|
// Google 로그인 콜백 스킴. tool/ios/inject_url_scheme.py 가 만든다.
|
||||||
|
// `#include?` 는 파일이 없어도 빌드를 멈추지 않는다 — 그때는 Google 로그인만 동작하지 않는다.
|
||||||
|
#include? "../config/Google.xcconfig"
|
||||||
|
|
||||||
|
// 카카오 네이티브 앱 키(FANUZU_KAKAO_NATIVE_APP_KEY=...) — Info.plist 의 kakao{키} URL 스킴.
|
||||||
|
// Dart(--dart-define-from-file)·Android 와 같은 파일이다. 없으면 카카오 로그인만 막힌다.
|
||||||
|
#include? "../../config/kakao.env"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||||
87BFB00ABC23EA249E841B59 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CBE157220A7C382F845775F0 /* Pods_Runner.framework */; };
|
87BFB00ABC23EA249E841B59 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CBE157220A7C382F845775F0 /* Pods_Runner.framework */; };
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
|
4F2B0E1A2C3D4E5F60718294 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 4F2B0E1A2C3D4E5F60718293 /* GoogleService-Info.plist */; };
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||||
EC46D318C684524E6F9C925F /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AB2C33122C7A95220B242430 /* Pods_RunnerTests.framework */; };
|
EC46D318C684524E6F9C925F /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AB2C33122C7A95220B242430 /* Pods_RunnerTests.framework */; };
|
||||||
@@ -64,6 +65,7 @@
|
|||||||
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
|
4F2B0E1A2C3D4E5F60718293 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
|
||||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
A68C3B80D45981965B89CA40 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
A68C3B80D45981965B89CA40 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
AB2C33122C7A95220B242430 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
AB2C33122C7A95220B242430 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
@@ -149,6 +151,7 @@
|
|||||||
97C146FD1CF9000F007C117D /* Assets.xcassets */,
|
97C146FD1CF9000F007C117D /* Assets.xcassets */,
|
||||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
|
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
|
||||||
97C147021CF9000F007C117D /* Info.plist */,
|
97C147021CF9000F007C117D /* Info.plist */,
|
||||||
|
4F2B0E1A2C3D4E5F60718293 /* GoogleService-Info.plist */,
|
||||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||||
@@ -275,6 +278,7 @@
|
|||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
|
||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
||||||
|
4F2B0E1A2C3D4E5F60718294 /* GoogleService-Info.plist in Resources */,
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -467,7 +471,8 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = P36BV95LP5;
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
|
DEVELOPMENT_TEAM = V28G543VK5;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -650,7 +655,8 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = P36BV95LP5;
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
|
DEVELOPMENT_TEAM = V28G543VK5;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -673,7 +679,8 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = P36BV95LP5;
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
|
DEVELOPMENT_TEAM = V28G543VK5;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
|||||||
@@ -126,6 +126,15 @@
|
|||||||
"version" : "1.22.5"
|
"version" : "1.22.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "line-sdk-ios-swift",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/line/line-sdk-ios-swift.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "230d05aab8ae48b6eeb893b9f257d7f5095d3352",
|
||||||
|
"version" : "5.17.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "nanopb",
|
"identity" : "nanopb",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
@@ -126,6 +126,15 @@
|
|||||||
"version" : "1.22.5"
|
"version" : "1.22.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "line-sdk-ios-swift",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/line/line-sdk-ios-swift.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "230d05aab8ae48b6eeb893b9f257d7f5095d3352",
|
||||||
|
"version" : "5.17.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "nanopb",
|
"identity" : "nanopb",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
@@ -66,5 +66,58 @@
|
|||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
|
<!--
|
||||||
|
암호화 사용 신고. HTTPS 만 쓰므로 면제 대상이다.
|
||||||
|
이 키가 없으면 업로드할 때마다 App Store Connect 가 수동 응답을 요구한다.
|
||||||
|
-->
|
||||||
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
<false/>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Google 로그인 콜백 URL 스킴. 값은 GoogleService-Info.plist 의 REVERSED_CLIENT_ID 이며
|
||||||
|
tool/ios/inject_url_scheme.py 가 ios/config/Google.xcconfig 로 뽑아 둔다.
|
||||||
|
-->
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>Editor</string>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>google-sign-in</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>$(GOOGLE_REVERSED_CLIENT_ID)</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<!-- 카카오 로그인 복귀 스킴 kakao{네이티브 앱 키}. 값은 config/kakao.env(Flutter/*.xcconfig). -->
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>Editor</string>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>kakao-login</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>kakao$(FANUZU_KAKAO_NATIVE_APP_KEY)</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<!-- LINE 로그인 복귀 스킴 line3rdp.{번들 ID}(flutter_line_sdk 공식 설정). -->
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>Editor</string>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>line-login</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>line3rdp.$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<!-- 카카오톡으로 로그인 — 카카오톡 설치 여부 확인·호출(Kakao Flutter SDK 공식 설정). -->
|
||||||
|
<key>LSApplicationQueriesSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>kakaokompassauth</string>
|
||||||
|
<!-- LINE 앱으로 로그인 — LINE 설치 여부 확인·호출(flutter_line_sdk 공식 설정). -->
|
||||||
|
<string>lineauth2</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!--
|
||||||
|
Sign in with Apple. 소셜 로그인을 제공하면 Apple 심사 필수 조건이다.
|
||||||
|
이 Capability 가 없으면 Apple 로그인 코드가 있어도 동작하지 않는다.
|
||||||
|
-->
|
||||||
|
<key>com.apple.developer.applesignin</key>
|
||||||
|
<array>
|
||||||
|
<string>Default</string>
|
||||||
|
</array>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
푸시 환경 선언. Firebase Auth 가 APNs 등록 API 를 링크하므로 선언해 둔다.
|
||||||
|
App Store 내보내기 때 Xcode 가 production 으로 바꿔 서명한다.
|
||||||
|
-->
|
||||||
|
<key>aps-environment</key>
|
||||||
|
<string>development</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -6,6 +6,7 @@ import '../theme/tokens.dart';
|
|||||||
import '../ui/app_icons.dart';
|
import '../ui/app_icons.dart';
|
||||||
import '../../features/auth/auth_repository.dart';
|
import '../../features/auth/auth_repository.dart';
|
||||||
import '../../features/auth/login_screen.dart';
|
import '../../features/auth/login_screen.dart';
|
||||||
|
import '../../features/auth/signup_screens.dart';
|
||||||
import '../../features/galaxy/galaxy_screen.dart';
|
import '../../features/galaxy/galaxy_screen.dart';
|
||||||
import '../../features/home/home_screen.dart';
|
import '../../features/home/home_screen.dart';
|
||||||
import '../../features/inside/inside_screen.dart';
|
import '../../features/inside/inside_screen.dart';
|
||||||
@@ -37,10 +38,9 @@ class AppTab {
|
|||||||
|
|
||||||
/// 계약 고정 5탭.
|
/// 계약 고정 5탭.
|
||||||
///
|
///
|
||||||
/// 근거 3개가 서로 일치한다:
|
/// 근거 2개가 서로 일치한다:
|
||||||
/// - 견적서 v2.1 「5탭 네비게이션」
|
/// - 견적서 v2.1 「5탭 네비게이션」
|
||||||
/// - `design/craft-allowlist.yaml` → `APP_TABS: [HOME, INSIDE, GALAXY, STATION, MY]`
|
/// - `design/craft-allowlist.yaml` → `APP_TABS: [HOME, INSIDE, GALAXY, STATION, MY]`
|
||||||
/// - 시안 `시안/screens/S-*.html` 의 `<nav class="tabbar">` (5개 루트 화면 전부 동일)
|
|
||||||
///
|
///
|
||||||
/// 라벨 문자열과 순서는 스모크 테스트가 정확 일치로 검증한다.
|
/// 라벨 문자열과 순서는 스모크 테스트가 정확 일치로 검증한다.
|
||||||
/// 아이콘은 Figma Iconset(house · message-square · badge · circle-user-round)이다.
|
/// 아이콘은 Figma Iconset(house · message-square · badge · circle-user-round)이다.
|
||||||
@@ -78,19 +78,24 @@ List<AppTab> visibleTabs(FeatureFlags flags) => [
|
|||||||
|
|
||||||
const String loginPath = '/login';
|
const String loginPath = '/login';
|
||||||
|
|
||||||
/// 5탭 셸 라우트 + 온보딩(로그인) 라우트.
|
/// 가입 약관 — 동의 기록(`termsAgreedAt`)이 없는 계정은 셸 대신 여기로 온다.
|
||||||
|
const String termsPath = '/terms';
|
||||||
|
|
||||||
|
/// 5탭 셸 라우트 + 로그인 · 가입 약관 라우트.
|
||||||
///
|
///
|
||||||
/// 온보딩 나머지 화면(S-A-INTRO · S-A-TERMS · S-A-PROFILE · S-A-ARTIST ·
|
/// 로그인 → (동의 기록이 없으면) 약관([termsPath]) → 셸. 가입 · 로그인 버튼이나 제공자와
|
||||||
/// S-A-PLANET-NEW · S-A-PERMS)은 3주차 범위라 아직 라우트가 없다.
|
/// 무관하게 약관 동의 없이 셸에 들어가지 못한다.
|
||||||
GoRouter buildRouter(AuthRepository auth, {FeatureFlags? flags}) {
|
GoRouter buildRouter(AuthRepository auth, {FeatureFlags? flags}) {
|
||||||
final featureFlags = flags ?? FeatureFlags();
|
final featureFlags = flags ?? FeatureFlags();
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
initialLocation: appTabs.first.path,
|
initialLocation: appTabs.first.path,
|
||||||
refreshListenable: auth,
|
refreshListenable: auth,
|
||||||
redirect: (context, state) {
|
redirect: (context, state) {
|
||||||
final atLogin = state.matchedLocation == loginPath;
|
final at = state.matchedLocation;
|
||||||
if (!auth.isSignedIn) return atLogin ? null : loginPath;
|
if (!auth.isSignedIn) return at == loginPath ? null : loginPath;
|
||||||
if (atLogin) return appTabs.first.path;
|
if (auth.terms != TermsGate.done)
|
||||||
|
return at == termsPath ? null : termsPath;
|
||||||
|
if (at == loginPath || at == termsPath) return appTabs.first.path;
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
@@ -98,6 +103,10 @@ GoRouter buildRouter(AuthRepository auth, {FeatureFlags? flags}) {
|
|||||||
path: loginPath,
|
path: loginPath,
|
||||||
builder: (_, _) => LoginScreen(auth: auth),
|
builder: (_, _) => LoginScreen(auth: auth),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: termsPath,
|
||||||
|
builder: (_, _) => SignupTermsGate(auth: auth),
|
||||||
|
),
|
||||||
StatefulShellRoute.indexedStack(
|
StatefulShellRoute.indexedStack(
|
||||||
builder: (_, _, shell) => AppShell(shell: shell, flags: featureFlags),
|
builder: (_, _, shell) => AppShell(shell: shell, flags: featureFlags),
|
||||||
branches: [
|
branches: [
|
||||||
@@ -137,7 +146,7 @@ GoRouter buildRouter(AuthRepository auth, {FeatureFlags? flags}) {
|
|||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: appTabs[4].path,
|
path: appTabs[4].path,
|
||||||
builder: (_, _) => const MyScreen(),
|
builder: (_, _) => MyScreen(auth: auth),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class TabPlaceholder extends StatelessWidget {
|
|||||||
required this.tab,
|
required this.tab,
|
||||||
required this.screenId,
|
required this.screenId,
|
||||||
required this.screenName,
|
required this.screenName,
|
||||||
|
this.footer,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// 계약 고정 탭 라벨(견적서 v2.1 5탭).
|
/// 계약 고정 탭 라벨(견적서 v2.1 5탭).
|
||||||
@@ -20,6 +21,9 @@ class TabPlaceholder extends StatelessWidget {
|
|||||||
final String screenId;
|
final String screenId;
|
||||||
final String screenName;
|
final String screenName;
|
||||||
|
|
||||||
|
/// 자리표시 아래에 붙는 동작(예: MY 의 로그아웃). 없으면 그리지 않는다.
|
||||||
|
final Widget? footer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final text = Theme.of(context).textTheme;
|
final text = Theme.of(context).textTheme;
|
||||||
@@ -55,6 +59,10 @@ class TabPlaceholder extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (footer != null) ...[
|
||||||
|
const SizedBox(height: AppSpace.s5),
|
||||||
|
footer!,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/// 인증 계약 — 제공자 인터페이스와 결과 타입.
|
/// 인증 계약 — 제공자 인터페이스와 결과 타입.
|
||||||
///
|
///
|
||||||
/// 원칙: **"키 미투입"은 예외가 아니라 상태다.** Apple·카카오·LINE 은 발주사 사업자
|
/// 원칙: **"키 미투입"은 예외가 아니라 상태다.** Firebase 설정 파일이나 Apple
|
||||||
/// 정보로 각 개발자 콘솔에 앱을 등록해야 키가 나온다. "아직 준비가 안 됐다"이지
|
/// 웹 인증 값처럼 빌드에 들어가야 할 값이 없을 때가 그렇다. "아직 준비가 안 됐다"이지
|
||||||
/// "지원하지 않는다"가 아니므로 예외를 던지지 않고 [AuthNotConfigured] 로 돌려준다.
|
/// "지원하지 않는다"가 아니므로 예외를 던지지 않고 [AuthNotConfigured] 로 돌려준다.
|
||||||
/// 화면은 이걸 안내 다이얼로그로 보여 준다.
|
/// 화면은 이걸 안내 다이얼로그로 보여 준다.
|
||||||
library;
|
library;
|
||||||
@@ -73,7 +73,7 @@ final class AuthCancelled extends AuthOutcome {
|
|||||||
const AuthCancelled();
|
const AuthCancelled();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 실패. [code] 는 `wireframe/planning/error-catalog.yaml` 의 ID 다(소셜 실패 = E-AUTH-03).
|
/// 실패. [code] 는 에러 카탈로그 ID 다(소셜 실패 = E-AUTH-03).
|
||||||
final class AuthFailed extends AuthOutcome {
|
final class AuthFailed extends AuthOutcome {
|
||||||
const AuthFailed(this.code, this.message);
|
const AuthFailed(this.code, this.message);
|
||||||
|
|
||||||
@@ -133,16 +133,6 @@ abstract interface class AuthSession {
|
|||||||
Future<void> signOut();
|
Future<void> 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종.
|
/// 연동 화면에 거는 제공자 — 별첨 B v1.4 확정 4종.
|
||||||
/// 이메일은 QA 내부 경로라 목록에 없다.
|
/// 이메일은 QA 내부 경로라 목록에 없다.
|
||||||
const List<AuthProviderKind> linkableProviders = [
|
const List<AuthProviderKind> linkableProviders = [
|
||||||
@@ -156,6 +146,11 @@ const List<AuthProviderKind> linkableProviders = [
|
|||||||
///
|
///
|
||||||
/// 별첨 B v1.4 — *하나의 FANUZU UID 에 복수 소셜 Provider 연결은 허용합니다.*
|
/// 별첨 B v1.4 — *하나의 FANUZU UID 에 복수 소셜 Provider 연결은 허용합니다.*
|
||||||
/// Firebase 는 `User.linkWithCredential` / `User.unlink` 로 이걸 지원한다.
|
/// Firebase 는 `User.linkWithCredential` / `User.unlink` 로 이걸 지원한다.
|
||||||
|
///
|
||||||
|
/// 연결은 현재 계정의 유효한 인증 상태와 새 Provider 인증 자격을 **서버가** 모두 확인해 붙인다
|
||||||
|
/// (Firebase Auth `linkWithCredential` · 카카오는 `fnAuthKakao`). 이메일 일치는 소유 확인이 아니어서
|
||||||
|
/// 비교하지 않는다 — 이메일을 주지 않는 카카오 · LINE 계정도 붙는다. 그 Provider 가 다른 FANUZU UID 에
|
||||||
|
/// 붙어 있으면 거부하고(E-AUTH-08) 자동 병합하지 않는다.
|
||||||
/// 저장소와 화면은 이 인터페이스만 알고 SDK 는 어댑터 뒤에 둔다 — 덕분에 설정
|
/// 저장소와 화면은 이 인터페이스만 알고 SDK 는 어댑터 뒤에 둔다 — 덕분에 설정
|
||||||
/// 파일 없이도 연결·해제 규칙을 단위 테스트할 수 있다.
|
/// 파일 없이도 연결·해제 규칙을 단위 테스트할 수 있다.
|
||||||
abstract interface class AccountLinker {
|
abstract interface class AccountLinker {
|
||||||
|
|||||||
@@ -6,21 +6,85 @@ import '../../core/firebase/firebase_boot.dart';
|
|||||||
import 'auth.dart';
|
import 'auth.dart';
|
||||||
import 'social_providers.dart';
|
import 'social_providers.dart';
|
||||||
|
|
||||||
/// 로그인 상태 보관소. 라우터가 이걸 듣고 로그인/셸을 가른다.
|
/// 가입 약관 관문 — 로그인한 계정이 약관 동의를 마쳤는가(`users/{uid}.termsAgreedAt`).
|
||||||
|
enum TermsGate { checking, required, done }
|
||||||
|
|
||||||
|
/// 로그인 상태 보관소. 라우터가 이걸 듣고 로그인/약관/셸을 가른다.
|
||||||
class AuthRepository extends ChangeNotifier {
|
class AuthRepository extends ChangeNotifier {
|
||||||
AuthRepository({
|
AuthRepository({
|
||||||
List<SocialAuthProvider>? providers,
|
List<SocialAuthProvider>? providers,
|
||||||
AuthSession? session,
|
AuthSession? session,
|
||||||
EmailAuthProvider? qaEmailProvider,
|
EmailAuthProvider? qaEmailProvider,
|
||||||
bool? releaseMode,
|
bool? releaseMode,
|
||||||
|
Future<bool> Function(String uid)? needsTerms,
|
||||||
|
Future<void> Function(Set<String> agreed)? recordTerms,
|
||||||
}) : providers = providers ?? _defaultProviders(),
|
}) : providers = providers ?? _defaultProviders(),
|
||||||
_qaEmail = qaEmailProvider ?? const EmailPasswordProvider(),
|
_qaEmail = qaEmailProvider ?? const EmailPasswordProvider(),
|
||||||
_session = session,
|
_session = session,
|
||||||
|
_needsTerms = needsTerms,
|
||||||
|
_recordTerms = recordTerms,
|
||||||
_account = session?.currentAccount,
|
_account = session?.currentAccount,
|
||||||
// 기본값은 실제 빌드 모드. 테스트에서만 주입해 릴리스 동작을 재현한다
|
// 기본값은 실제 빌드 모드. 테스트에서만 주입해 릴리스 동작을 재현한다
|
||||||
// (kReleaseMode 는 컴파일 상수라 테스트에서 뒤집을 수 없다).
|
// (kReleaseMode 는 컴파일 상수라 테스트에서 뒤집을 수 없다).
|
||||||
_releaseMode = releaseMode ?? (kReleaseMode || kProfileMode) {
|
_releaseMode = releaseMode ?? (kReleaseMode || kProfileMode) {
|
||||||
_sessionSubscription = session?.accountChanges.listen(_replaceAccount);
|
_sessionSubscription = session?.accountChanges.listen(_replaceAccount);
|
||||||
|
final initial = _account;
|
||||||
|
if (initial != null) unawaited(_checkTerms(initial));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 이 계정이 약관 동의를 거쳐야 하는가. null 이면 판정하지 않는다(목 모드 · 테스트).
|
||||||
|
///
|
||||||
|
/// **「로그인」 버튼으로 처음 들어온 소셜 계정도 여기서 걸린다** — 가입/로그인 버튼이나
|
||||||
|
/// 제공자(카카오 · LINE 커스텀 토큰 포함)와 무관하게, 서버에 동의 기록이 없으면 셸에 들어가지 못한다.
|
||||||
|
final Future<bool> Function(String uid)? _needsTerms;
|
||||||
|
|
||||||
|
/// 동의 기록(`fnSignupConsent`). 필수 누락은 서버가 거부한다.
|
||||||
|
final Future<void> Function(Set<String> agreed)? _recordTerms;
|
||||||
|
|
||||||
|
TermsGate _gate = TermsGate.done;
|
||||||
|
TermsGate get terms => _gate;
|
||||||
|
|
||||||
|
/// 가입 모드에서 제공자 인증 **전에** 동의한 약관. 인증 뒤 동의가 필요한 계정이면
|
||||||
|
/// 이 값으로 기록하고 약관 화면을 다시 띄우지 않는다.
|
||||||
|
Set<String>? pendingConsent;
|
||||||
|
|
||||||
|
/// 약관 동의를 서버에 기록하고 셸로 보낸다. 실패하면 예외를 그대로 던진다(화면이 안내).
|
||||||
|
Future<void> agreeTerms(Set<String> agreed) async {
|
||||||
|
await _recordTerms?.call(agreed);
|
||||||
|
_gate = TermsGate.done;
|
||||||
|
pendingConsent = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkTerms(AuthAccount account) async {
|
||||||
|
final check = _needsTerms;
|
||||||
|
if (check == null || account.isMock) {
|
||||||
|
_gate = TermsGate.done;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_gate = TermsGate.checking;
|
||||||
|
bool required;
|
||||||
|
try {
|
||||||
|
required = await check(account.uid);
|
||||||
|
} catch (e) {
|
||||||
|
// 판정하지 못하면 약관부터 다시 받는다 — 동의 없는 계정이 셸로 새는 쪽이 더 나쁘다.
|
||||||
|
debugPrint('[AuthRepository] terms check failed: ${e.runtimeType}');
|
||||||
|
required = true;
|
||||||
|
}
|
||||||
|
if (_account?.uid != account.uid) return;
|
||||||
|
final pending = pendingConsent;
|
||||||
|
pendingConsent = null;
|
||||||
|
if (required && pending != null) {
|
||||||
|
try {
|
||||||
|
await agreeTerms(pending);
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[AuthRepository] pending consent failed: ${e.runtimeType}');
|
||||||
|
}
|
||||||
|
if (_account?.uid != account.uid) return;
|
||||||
|
}
|
||||||
|
_gate = required ? TermsGate.required : TermsGate.done;
|
||||||
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
final bool _releaseMode;
|
final bool _releaseMode;
|
||||||
@@ -44,18 +108,24 @@ class AuthRepository extends ChangeNotifier {
|
|||||||
/// 사용자가 닿는 경로가 없다 — 읽는 분기가 없는 기능 플래그로 닫는 것보다 확실하다.
|
/// 사용자가 닿는 경로가 없다 — 읽는 분기가 없는 기능 플래그로 닫는 것보다 확실하다.
|
||||||
/// QA 경로는 [qaEmailProvider] 하나뿐이다.
|
/// QA 경로는 [qaEmailProvider] 하나뿐이다.
|
||||||
///
|
///
|
||||||
/// Apple·카카오·LINE 은 발주사 키가 없어 「키 대기」 상태다. 눌러도 예외가 아니라
|
/// 카카오 · LINE 은 공식 SDK + 서버 커스텀 토큰([KakaoSignInProvider] · [LineSignInProvider])이다.
|
||||||
/// [AuthNotConfigured] 가 돌아오고 화면이 안내 다이얼로그를 띄운다.
|
|
||||||
static List<SocialAuthProvider> _defaultProviders() => [
|
static List<SocialAuthProvider> _defaultProviders() => [
|
||||||
const GoogleSignInProvider(),
|
const GoogleSignInProvider(),
|
||||||
AppleSignInProvider(
|
// Android 는 Apple SDK 가 없어 Firebase 웹 흐름(`apple.com` 제공자)으로 간다 —
|
||||||
webAuthenticationOptions: appleWebAuthenticationOptionsFromEnvironment(),
|
// 서비스 ID · 키는 Firebase 콘솔 Apple 제공자 설정에 두고 앱에는 값이 없다.
|
||||||
),
|
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android)
|
||||||
const KeylessSocialProvider(AuthProviderKind.kakao, [
|
const OidcSignInProvider(
|
||||||
'카카오 네이티브 앱 키',
|
AuthProviderKind.apple,
|
||||||
'카카오 개발자 콘솔 앱 등록(사업자 정보 필요)',
|
providerId: 'apple.com',
|
||||||
]),
|
scopes: ['email', 'name'],
|
||||||
lineProvider,
|
)
|
||||||
|
else
|
||||||
|
const AppleSignInProvider(),
|
||||||
|
// 동의 항목은 카카오 콘솔 설정을 따른다(지금은 닉네임). 이메일(account_email)은 비즈 앱
|
||||||
|
// 전환 후 콘솔에서 켜면 서버가 동의·인증된 값만 받는다.
|
||||||
|
KakaoSignInProvider(),
|
||||||
|
// LINE 동의 항목: profile · openid · email(이메일 권한 신청 승인 후에만 온다 — 없어도 로그인된다).
|
||||||
|
LineSignInProvider(),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// QA 계정(qa-test@fanuzu.co.kr) 전용 이메일 로그인 — **디버그 빌드에서만**.
|
/// QA 계정(qa-test@fanuzu.co.kr) 전용 이메일 로그인 — **디버그 빌드에서만**.
|
||||||
@@ -166,7 +236,14 @@ class AuthRepository extends ChangeNotifier {
|
|||||||
|
|
||||||
void _replaceAccount(AuthAccount? next) {
|
void _replaceAccount(AuthAccount? next) {
|
||||||
if (_sameAccount(_account, next)) return;
|
if (_sameAccount(_account, next)) return;
|
||||||
|
final uidChanged = _account?.uid != next?.uid;
|
||||||
_account = next;
|
_account = next;
|
||||||
|
if (next == null) {
|
||||||
|
_gate = TermsGate.done;
|
||||||
|
pendingConsent = null;
|
||||||
|
} else if (uidChanged) {
|
||||||
|
unawaited(_checkTerms(next));
|
||||||
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ final class FirebaseAccountLinker implements AccountLinker {
|
|||||||
AuthProviderKind.google: 'google.com',
|
AuthProviderKind.google: 'google.com',
|
||||||
AuthProviderKind.apple: 'apple.com',
|
AuthProviderKind.apple: 'apple.com',
|
||||||
// 카카오·LINE 은 Firebase 기본 제공자가 아니라 OIDC 커스텀 제공자로 붙는다.
|
// 카카오·LINE 은 Firebase 기본 제공자가 아니라 OIDC 커스텀 제공자로 붙는다.
|
||||||
// 접두사 `oidc.` 는 Firebase 콘솔 등록 시 강제되는 형식이다.
|
// 접두사 `oidc.` 는 Firebase 콘솔 등록 시 강제되는 형식이다. 카카오는 로그인을
|
||||||
|
// 서버 커스텀 토큰으로 옮겼지만, 서버(fnAuthKakao · fnAuthLine)가 같은 ID 로 연결을 남긴다.
|
||||||
AuthProviderKind.kakao: 'oidc.kakao',
|
AuthProviderKind.kakao: 'oidc.kakao',
|
||||||
AuthProviderKind.line: 'oidc.line',
|
AuthProviderKind.line: 'oidc.line',
|
||||||
AuthProviderKind.email: 'password',
|
AuthProviderKind.email: 'password',
|
||||||
@@ -55,36 +56,31 @@ final class FirebaseAccountLinker implements AccountLinker {
|
|||||||
}
|
}
|
||||||
final missing = impl.missingConfig;
|
final missing = impl.missingConfig;
|
||||||
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
|
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
|
||||||
// 키 대기 제공자(카카오·LINE)는 자격증명을 만들 수 없다.
|
if (impl is! CredentialProvider &&
|
||||||
if (impl is! CredentialProvider) {
|
impl is! OidcSignInProvider &&
|
||||||
|
impl is! KakaoSignInProvider &&
|
||||||
|
impl is! LineSignInProvider) {
|
||||||
return AuthNotConfigured(provider, const ['자격증명 구현']);
|
return AuthNotConfigured(provider, const ['자격증명 구현']);
|
||||||
}
|
}
|
||||||
final source = impl as CredentialProvider;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (impl is KakaoSignInProvider) return await impl.link(user);
|
||||||
|
if (impl is LineSignInProvider) return await impl.link(user);
|
||||||
|
if (impl is OidcSignInProvider) return await _linkOidc(user, impl);
|
||||||
|
final source = impl as CredentialProvider;
|
||||||
final obtained = await source.obtainCredential();
|
final obtained = await source.obtainCredential();
|
||||||
// 사용자가 소셜 창을 닫았다. 실패로 세지 않는다.
|
// 사용자가 소셜 창을 닫았다. 실패로 세지 않는다.
|
||||||
if (obtained == null) return const AuthCancelled();
|
if (obtained == null) return const AuthCancelled();
|
||||||
|
|
||||||
// 명세 `554:22797` — *동일 이메일만 연동 가능*. 붙이기 **전에** 막는다.
|
// 이메일을 비교하지 않는다 — 이메일 일치는 소유 확인이 아니다. Firebase 가 지금 로그인 토큰과
|
||||||
// 붙인 뒤 되돌리는 방식은 unlink 가 실패하면 남의 계정이 붙은 채로 남는다.
|
// 새 자격증명을 서버에서 함께 검증하고, 그 Provider 가 다른 UID 에 붙어 있으면
|
||||||
//
|
// credential-already-in-use 로 거부한다.
|
||||||
// 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);
|
final result = await user.linkWithCredential(obtained.credential);
|
||||||
return AuthSucceeded(_accountOf(result.user, provider));
|
return AuthSucceeded(_accountOf(result.user, provider));
|
||||||
} on fb.FirebaseAuthException catch (error) {
|
} on fb.FirebaseAuthException catch (error) {
|
||||||
|
if (isWebFlowCancelled(error)) return const AuthCancelled();
|
||||||
return AuthFailed(
|
return AuthFailed(
|
||||||
authCatalogCode(error),
|
authCatalogCode(error, flow: AuthFlow.link),
|
||||||
authMessage(error, flow: AuthFlow.link),
|
authMessage(error, flow: AuthFlow.link),
|
||||||
);
|
);
|
||||||
} on Object catch (error) {
|
} on Object catch (error) {
|
||||||
@@ -114,12 +110,20 @@ final class FirebaseAccountLinker implements AccountLinker {
|
|||||||
return AuthSucceeded(_accountOf(_firebaseAuth.currentUser, provider));
|
return AuthSucceeded(_accountOf(_firebaseAuth.currentUser, provider));
|
||||||
} on fb.FirebaseAuthException catch (error) {
|
} on fb.FirebaseAuthException catch (error) {
|
||||||
return AuthFailed(
|
return AuthFailed(
|
||||||
authCatalogCode(error),
|
authCatalogCode(error, flow: AuthFlow.link),
|
||||||
authMessage(error, flow: AuthFlow.link),
|
authMessage(error, flow: AuthFlow.link),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 웹 흐름(OIDC — Android Apple) 연결 — 웹 흐름이 인증과 연결을 한 번에 끝낸다. 검증 · 충돌 거부는 Firebase 가 한다.
|
||||||
|
Future<AuthOutcome> _linkOidc(fb.User user, OidcSignInProvider impl) async {
|
||||||
|
final result = await retryStaleWebContext(
|
||||||
|
() => untilAbandoned(user.linkWithProvider(impl.authProvider)),
|
||||||
|
);
|
||||||
|
return AuthSucceeded(_accountOf(result.user, impl.provider));
|
||||||
|
}
|
||||||
|
|
||||||
SocialAuthProvider? _implOf(AuthProviderKind provider) {
|
SocialAuthProvider? _implOf(AuthProviderKind provider) {
|
||||||
for (final candidate in _providers) {
|
for (final candidate in _providers) {
|
||||||
if (candidate.provider == provider) return candidate;
|
if (candidate.provider == provider) return candidate;
|
||||||
|
|||||||
@@ -209,14 +209,20 @@ class _SimpleLoginScreenState extends State<SimpleLoginScreen> {
|
|||||||
bool _busy = false;
|
bool _busy = false;
|
||||||
|
|
||||||
Future<void> _signIn(SocialProvider provider) async {
|
Future<void> _signIn(SocialProvider provider) async {
|
||||||
|
if (_busy) return;
|
||||||
if (widget.signUp) {
|
if (widget.signUp) {
|
||||||
final agreed = await Navigator.of(
|
final agreed = await Navigator.of(context).push<Set<String>>(
|
||||||
context,
|
MaterialPageRoute(builder: (_) => const TermsScreen()),
|
||||||
).push<bool>(MaterialPageRoute(builder: (_) => const TermsScreen()));
|
);
|
||||||
if (agreed != true || !mounted) return;
|
if (agreed == null || !mounted) return;
|
||||||
|
// 인증 뒤 동의가 필요한 계정이면 이 동의를 서버에 기록한다(약관을 다시 묻지 않는다).
|
||||||
|
widget.auth.pendingConsent = agreed;
|
||||||
}
|
}
|
||||||
|
// 제공자 화면에서 돌아온 뒤에도 서버 토큰 교환 · 로그인에 몇 초 걸린다(Kakao · LINE).
|
||||||
|
// 끝날 때까지 화면 전체를 막고 진행 표시를 띄운다 — 다시 누르지 못하게.
|
||||||
setState(() => _busy = true);
|
setState(() => _busy = true);
|
||||||
final outcome = await widget.auth.signIn(provider);
|
final outcome = await widget.auth.signIn(provider);
|
||||||
|
if (outcome is! AuthSucceeded) widget.auth.pendingConsent = null;
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _busy = false);
|
setState(() => _busy = false);
|
||||||
// 키 대기 제공자는 안내 다이얼로그로 끊는다 — 눌렀는데 아무 일도 안 일어나면
|
// 키 대기 제공자는 안내 다이얼로그로 끊는다 — 눌렀는데 아무 일도 안 일어나면
|
||||||
@@ -266,7 +272,7 @@ class _SimpleLoginScreenState extends State<SimpleLoginScreen> {
|
|||||||
for (final kind in _figmaOrder)
|
for (final kind in _figmaOrder)
|
||||||
...widget.auth.providers.where((p) => p.provider == kind),
|
...widget.auth.providers.where((p) => p.provider == kind),
|
||||||
];
|
];
|
||||||
return Scaffold(
|
final scaffold = Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
actions: [
|
actions: [
|
||||||
@@ -345,6 +351,10 @@ class _SimpleLoginScreenState extends State<SimpleLoginScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
return PopScope(
|
||||||
|
canPop: !_busy,
|
||||||
|
child: Stack(children: [scaffold, if (_busy) const BlockingProgress()]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,9 +379,11 @@ const _names = {
|
|||||||
AuthProviderKind.email: '이메일로',
|
AuthProviderKind.email: '이메일로',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// 로그인 결과 안내. 취소 · 중단도 안내한다 — 로그인 성공으로 처리하지 않고 다시 시도하게 한다.
|
||||||
|
/// 아무 말 없이 화면이 그대로면 사용자는 앱이 멈춘 줄 안다.
|
||||||
String? authNotice(AuthOutcome outcome) => switch (outcome) {
|
String? authNotice(AuthOutcome outcome) => switch (outcome) {
|
||||||
AuthSucceeded() => null,
|
AuthSucceeded() => null,
|
||||||
AuthCancelled() => null,
|
AuthCancelled() => '로그인이 완료되지 않았습니다. 다시 시도해 주세요.',
|
||||||
AuthFailed(:final code, :final message) => '[$code] $message',
|
AuthFailed(:final code, :final message) => '[$code] $message',
|
||||||
AuthNotConfigured(:final provider, :final missing) =>
|
AuthNotConfigured(:final provider, :final missing) =>
|
||||||
'${provider.shortName} 로그인은 아직 준비 중입니다.\n'
|
'${provider.shortName} 로그인은 아직 준비 중입니다.\n'
|
||||||
|
|||||||
@@ -1,36 +1,90 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../core/data/backend.dart';
|
||||||
import '../../core/theme/tokens.dart';
|
import '../../core/theme/tokens.dart';
|
||||||
|
import 'auth_repository.dart';
|
||||||
|
|
||||||
/// 회원가입 약관 — Figma `179:24230`(미동의) · `179:24324`(전체 동의).
|
/// 회원가입 약관 — Figma `179:24230`(미동의) · `179:24324`(전체 동의).
|
||||||
///
|
///
|
||||||
/// 필수 3항(서비스 · 위치기반 · 만 14세)을 모두 체크해야 「완료」가 켜진다. 완료하면 `true` 를
|
/// 필수 항목을 모두 체크해야 「완료」가 켜진다. 완료하면 동의한 항목 키([signupTermKeys] 중)를
|
||||||
/// 돌려주고, 부른 쪽([SimpleLoginScreen] 가입 모드)이 제공자 인증으로 넘어간다.
|
/// 돌려준다 — 가입 모드([SimpleLoginScreen])는 `pop` 결과로, 로그인 직후 약관 단계는 [onAgreed] 로 받는다.
|
||||||
|
/// 서버(`fnSignupConsent`)가 같은 필수 목록을 다시 검사하고 항목별 동의 행을 남긴다.
|
||||||
///
|
///
|
||||||
/// 약관 본문은 시안의 자리 표시 문구다. 확정 약관이 오면 [_body] 만 바꾼다.
|
/// - 필수: 서비스 이용약관 · 개인정보 수집·이용 동의 · 만 14세 이상.
|
||||||
|
/// - 선택: 마케팅 수신(기본 OFF).
|
||||||
|
/// - 위치기반 서비스 약관은 여기서 받지 않는다 — 위치기능 최초 사용 직전에 따로 받는다.
|
||||||
|
/// - 개인정보처리방침은 동의 항목이 아니라 고지다 — 체크 칸 없이 하단 버튼 위에 안내와 보기 링크만 둔다.
|
||||||
|
///
|
||||||
|
/// 약관 본문은 확정 문구를 받기 전까지 시안의 자리 표시 문구다 — 받으면
|
||||||
|
/// 항목별 본문 · [privacyPolicyPlaceholderBody] 와 [signupTermsVersion] 을 함께 바꾼다(동의 기록에 버전이 남는다).
|
||||||
class TermsScreen extends StatefulWidget {
|
class TermsScreen extends StatefulWidget {
|
||||||
const TermsScreen({super.key});
|
const TermsScreen({super.key, this.onAgreed, this.onBack, this.busy = false});
|
||||||
|
|
||||||
|
/// 로그인 직후 약관 단계에서 쓸 때 — 「완료」가 화면을 닫지 않고 이걸 부른다.
|
||||||
|
final ValueChanged<Set<String>>? onAgreed;
|
||||||
|
final VoidCallback? onBack;
|
||||||
|
|
||||||
|
/// 동의를 기록하는 중 — 버튼을 막고 진행 표시를 띄운다.
|
||||||
|
final bool busy;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<TermsScreen> createState() => _TermsScreenState();
|
State<TermsScreen> createState() => _TermsScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
enum _Term {
|
/// 동의 기록에 남는 약관 버전. 본문이 자리 표시 문구인 동안은 `draft` 다.
|
||||||
service('서비스 이용약관 (필수)', required: true, body: true),
|
const signupTermsVersion = 'draft-1';
|
||||||
location('위치기반 서비스 이용약관 (필수)', required: true, body: true),
|
|
||||||
age('만 14세 이상입니다. (필수)', required: true, body: false),
|
|
||||||
marketing('마케팅 정보 및 이벤트 알림 수신 동의 (선택)', required: false, body: true);
|
|
||||||
|
|
||||||
const _Term(this.label, {required this.required, required this.body});
|
/// 서버 `SIGNUP_REQUIRED_TERMS` + 선택 `marketing` 과 같은 키.
|
||||||
|
List<String> get signupTermKeys => [for (final t in _Term.values) t.name];
|
||||||
|
|
||||||
|
enum _Term {
|
||||||
|
service('서비스 이용약관 (필수)', required: true, body: _serviceBody),
|
||||||
|
privacy('개인정보 수집·이용 동의 (필수)', required: true, body: _privacyBody),
|
||||||
|
age('만 14세 이상입니다. (필수)', required: true),
|
||||||
|
marketing(
|
||||||
|
'마케팅 정보 및 이벤트 알림 수신 동의 (선택)',
|
||||||
|
required: false,
|
||||||
|
body: _marketingBody,
|
||||||
|
);
|
||||||
|
|
||||||
|
const _Term(this.label, {required this.required, this.body});
|
||||||
|
|
||||||
final String label;
|
final String label;
|
||||||
final bool required;
|
final bool required;
|
||||||
final bool body;
|
|
||||||
|
/// 항목 아래에 보이는 본문. 없으면 체크 줄만 그린다.
|
||||||
|
final String? body;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _body =
|
// 약관·방침 본문 자리 표시 문구 — 항목마다 제목이 다르다(한 문구를 돌려 쓰면 방침 창에 약관 제목이 뜬다).
|
||||||
|
// 정식 문안을 받으면 이 문구와 [signupTermsVersion] 을 함께 바꾼다.
|
||||||
|
const _serviceBody =
|
||||||
'FANUZU 서비스 이용약관\n\n제1조 (적용범위)\n서비스 이용약관에 대한 더미 텍스트 입니다.\n'
|
'FANUZU 서비스 이용약관\n\n제1조 (적용범위)\n서비스 이용약관에 대한 더미 텍스트 입니다.\n'
|
||||||
'추후 작성해주시기 바랍니다. 서비스 이용약관에 대한 더미 텍스트 입니다.';
|
'추후 작성해주시기 바랍니다. 서비스 이용약관에 대한 더미 텍스트 입니다.';
|
||||||
|
const _privacyBody =
|
||||||
|
'FANUZU 개인정보 수집·이용 동의\n\n정식 문안을 받기 전 임시 문구입니다.\n\n'
|
||||||
|
'수집 항목\n정식 문안을 받으면 기재합니다.\n\n'
|
||||||
|
'이용 목적\n정식 문안을 받으면 기재합니다.\n\n'
|
||||||
|
'보유 및 이용 기간\n정식 문안을 받으면 기재합니다.\n\n'
|
||||||
|
'동의를 거부할 권리와 거부 시 불이익\n정식 문안을 받으면 기재합니다.';
|
||||||
|
const _marketingBody =
|
||||||
|
'FANUZU 마케팅 정보 수신 동의\n\n정식 문안을 받기 전 임시 문구입니다.\n\n'
|
||||||
|
'수신 내용\n정식 문안을 받으면 기재합니다.\n\n'
|
||||||
|
'수신 방법\n정식 문안을 받으면 기재합니다.\n\n'
|
||||||
|
'보유 및 이용 기간\n정식 문안을 받으면 기재합니다.\n\n'
|
||||||
|
'동의 철회 방법\n정식 문안을 받으면 기재합니다.';
|
||||||
|
|
||||||
|
/// 개인정보처리방침 본문 자리 표시 문구 — 동의 항목이 아니라 하단 고지의 「보기」로 연다.
|
||||||
|
const privacyPolicyPlaceholderBody =
|
||||||
|
'FANUZU 개인정보처리방침\n\n정식 문안을 받기 전 임시 문구입니다.\n\n'
|
||||||
|
'1. 수집하는 개인정보 항목\n정식 문안을 받으면 기재합니다.\n\n'
|
||||||
|
'2. 개인정보의 이용 목적\n정식 문안을 받으면 기재합니다.\n\n'
|
||||||
|
'3. 개인정보의 보유 및 이용 기간\n정식 문안을 받으면 기재합니다.\n\n'
|
||||||
|
'4. 개인정보 처리 위탁 및 국외 이전\n정식 문안을 받으면 기재합니다.\n\n'
|
||||||
|
'5. 개인정보의 파기\n정식 문안을 받으면 기재합니다.\n\n'
|
||||||
|
'6. 이용자의 권리와 행사 방법\n정식 문안을 받으면 기재합니다.\n\n'
|
||||||
|
'7. 개인정보 보호책임자 및 문의처\n정식 문안을 받으면 기재합니다.\n\n'
|
||||||
|
'8. 시행일\n정식 문안을 받으면 기재합니다.';
|
||||||
|
|
||||||
const _stroke = Color(0xFF545454);
|
const _stroke = Color(0xFF545454);
|
||||||
|
|
||||||
@@ -41,13 +95,122 @@ class _TermsScreenState extends State<TermsScreen> {
|
|||||||
bool get _ready =>
|
bool get _ready =>
|
||||||
_Term.values.where((t) => t.required).every(_agreed.contains);
|
_Term.values.where((t) => t.required).every(_agreed.contains);
|
||||||
|
|
||||||
|
void _done() {
|
||||||
|
final agreed = {for (final t in _agreed) t.name};
|
||||||
|
final cb = widget.onAgreed;
|
||||||
|
cb != null ? cb(agreed) : Navigator.of(context).pop(agreed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _back() => (widget.onBack ?? () => Navigator.of(context).pop())();
|
||||||
|
|
||||||
void _toggle(_Term t) =>
|
void _toggle(_Term t) =>
|
||||||
setState(() => _agreed.contains(t) ? _agreed.remove(t) : _agreed.add(t));
|
setState(() => _agreed.contains(t) ? _agreed.remove(t) : _agreed.add(t));
|
||||||
|
|
||||||
|
void _showPrivacyPolicy() => showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('개인정보처리방침'),
|
||||||
|
content: const SingleChildScrollView(
|
||||||
|
child: Text(privacyPolicyPlaceholderBody),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('확인'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _buttons() {
|
||||||
|
final shape = RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||||
|
);
|
||||||
|
final busy = widget.busy;
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: busy ? null : _back,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
minimumSize: const Size.fromHeight(56),
|
||||||
|
shape: shape,
|
||||||
|
side: const BorderSide(color: _stroke),
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
foregroundColor: AppColors.onCosmic,
|
||||||
|
),
|
||||||
|
child: const Text('이전'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: _ready && !busy ? _done : null,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
minimumSize: const Size.fromHeight(56),
|
||||||
|
shape: shape,
|
||||||
|
side: const BorderSide(color: _stroke),
|
||||||
|
backgroundColor: AppColors.cosmicAccent,
|
||||||
|
foregroundColor: AppColors.onCosmicAccent,
|
||||||
|
disabledBackgroundColor: const Color(0xFF333333),
|
||||||
|
disabledForegroundColor: const Color(0xFF777777),
|
||||||
|
),
|
||||||
|
child: const Text('완료'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
final scaffold = Scaffold(
|
||||||
appBar: AppBar(title: const Text('이용 약관')),
|
// Figma `179:24230` 머리줄 좌측 「<」 — 「이전」과 같은 동작.
|
||||||
|
appBar: AppBar(
|
||||||
|
automaticallyImplyLeading: false,
|
||||||
|
leading: BackButton(onPressed: widget.busy ? null : _back),
|
||||||
|
title: const Text('이용 약관'),
|
||||||
|
centerTitle: true,
|
||||||
|
),
|
||||||
|
// 「이전 · 완료」는 화면 하단 고정(56 높이 · 간격 10). 시스템 내비게이션 바 위로 올린다.
|
||||||
|
bottomNavigationBar: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(AppSpace.s4),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// 개인정보처리방침 — 동의 항목과 구분한 고지. 체크 칸이 없고 「보기」로 본문을 연다.
|
||||||
|
// 목록 끝에 두면 iPhone 11 높이에서 하단 버튼 뒤로 가려지므로 버튼 위에 고정한다.
|
||||||
|
Padding(
|
||||||
|
key: const ValueKey('privacy-policy-notice'),
|
||||||
|
padding: const EdgeInsets.only(bottom: AppSpace.s2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'개인정보 처리 내용은 개인정보처리방침에서 확인할 수 있습니다.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: AppFonts.display,
|
||||||
|
fontFamilyFallback: [AppFonts.body],
|
||||||
|
fontSize: 12,
|
||||||
|
height: 1.36,
|
||||||
|
color: AppColors.onCosmicMuted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
key: const ValueKey('privacy-policy-link'),
|
||||||
|
onPressed: _showPrivacyPolicy,
|
||||||
|
child: const Text('보기'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buttons(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.all(AppSpace.s4),
|
padding: const EdgeInsets.all(AppSpace.s4),
|
||||||
children: [
|
children: [
|
||||||
@@ -64,7 +227,7 @@ class _TermsScreenState extends State<TermsScreen> {
|
|||||||
for (final t in _Term.values) ...[
|
for (final t in _Term.values) ...[
|
||||||
const SizedBox(height: AppSpace.s3),
|
const SizedBox(height: AppSpace.s3),
|
||||||
_Box(
|
_Box(
|
||||||
child: t.body
|
child: t.body != null
|
||||||
? Column(
|
? Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
@@ -79,15 +242,17 @@ class _TermsScreenState extends State<TermsScreen> {
|
|||||||
onTap: () => _toggle(t),
|
onTap: () => _toggle(t),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.symmetric(vertical: AppSpace.s4),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: AppSpace.s4,
|
||||||
|
),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 80,
|
height: 80,
|
||||||
child: ClipRect(
|
child: ClipRect(
|
||||||
child: Text(
|
child: Text(
|
||||||
_body,
|
t.body!,
|
||||||
overflow: TextOverflow.clip,
|
overflow: TextOverflow.clip,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontFamily: AppFonts.display,
|
fontFamily: AppFonts.display,
|
||||||
fontFamilyFallback: [AppFonts.body],
|
fontFamilyFallback: [AppFonts.body],
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -108,42 +273,76 @@ class _TermsScreenState extends State<TermsScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: AppSpace.s6),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: FilledButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(false),
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
backgroundColor: Colors.black,
|
|
||||||
foregroundColor: AppColors.onCosmic,
|
|
||||||
),
|
|
||||||
child: const Text('이전'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(
|
|
||||||
child: FilledButton(
|
|
||||||
onPressed: _ready
|
|
||||||
? () => Navigator.of(context).pop(true)
|
|
||||||
: null,
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
backgroundColor: AppColors.cosmicAccent,
|
|
||||||
foregroundColor: AppColors.onCosmicAccent,
|
|
||||||
disabledBackgroundColor: const Color(0xFF333333),
|
|
||||||
disabledForegroundColor: const Color(0xFF777777),
|
|
||||||
),
|
|
||||||
child: const Text('완료'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (!widget.busy) return scaffold;
|
||||||
|
return Stack(children: [scaffold, const BlockingProgress()]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 진행 중 화면 전체를 막는 반투명 막 + 가운데 진행 표시.
|
||||||
|
class BlockingProgress extends StatelessWidget {
|
||||||
|
const BlockingProgress({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => const Stack(
|
||||||
|
children: [
|
||||||
|
ModalBarrier(dismissible: false, color: Color(0x8A000000)),
|
||||||
|
Center(child: CircularProgressIndicator()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 로그인 직후 약관 단계 — 동의 기록이 없는 계정은 HOME 대신 여기로 온다.
|
||||||
|
///
|
||||||
|
/// 판정 중(또는 이미 동의한 계정이 셸로 넘어가는 동안)에는 진행 표시만 그린다.
|
||||||
|
/// 「이전」은 로그아웃하고 로그인 화면으로 돌아간다 — 동의 없이 셸에 들어가는 길이 없다.
|
||||||
|
class SignupTermsGate extends StatefulWidget {
|
||||||
|
const SignupTermsGate({super.key, required this.auth});
|
||||||
|
|
||||||
|
final AuthRepository auth;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SignupTermsGate> createState() => _SignupTermsGateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SignupTermsGateState extends State<SignupTermsGate> {
|
||||||
|
bool _busy = false;
|
||||||
|
|
||||||
|
Future<void> _agree(Set<String> agreed) async {
|
||||||
|
setState(() => _busy = true);
|
||||||
|
try {
|
||||||
|
await widget.auth.agreeTerms(agreed);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.maybeOf(context)
|
||||||
|
?..hideCurrentSnackBar()
|
||||||
|
..showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
e is BackendException ? e.message : '요청을 처리하지 못했습니다. 다시 시도해 주세요.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _busy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => ListenableBuilder(
|
||||||
|
listenable: widget.auth,
|
||||||
|
builder: (context, _) => widget.auth.terms == TermsGate.required
|
||||||
|
? TermsScreen(
|
||||||
|
busy: _busy,
|
||||||
|
onAgreed: _agree,
|
||||||
|
onBack: widget.auth.signOut,
|
||||||
|
)
|
||||||
|
: const Scaffold(body: Center(child: CircularProgressIndicator())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 약관 칸 — #545454 획 · 모서리 8 · 좌우 16.
|
/// 약관 칸 — #545454 획 · 모서리 8 · 좌우 16.
|
||||||
class _Box extends StatelessWidget {
|
class _Box extends StatelessWidget {
|
||||||
const _Box({required this.child});
|
const _Box({required this.child});
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:cloud_functions/cloud_functions.dart';
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:firebase_auth/firebase_auth.dart' as fb;
|
import 'package:firebase_auth/firebase_auth.dart' as fb;
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:google_sign_in/google_sign_in.dart' as gsi;
|
import 'package:google_sign_in/google_sign_in.dart' as gsi;
|
||||||
|
import 'package:flutter_line_sdk/flutter_line_sdk.dart' as line;
|
||||||
|
import 'package:kakao_flutter_sdk_user/kakao_flutter_sdk_user.dart' as kakao;
|
||||||
import 'package:sign_in_with_apple/sign_in_with_apple.dart' as apple;
|
import 'package:sign_in_with_apple/sign_in_with_apple.dart' as apple;
|
||||||
|
|
||||||
|
import '../../core/data/backend.dart';
|
||||||
import '../../core/firebase/firebase_boot.dart';
|
import '../../core/firebase/firebase_boot.dart';
|
||||||
import 'auth.dart';
|
import 'auth.dart';
|
||||||
|
|
||||||
@@ -19,12 +26,12 @@ abstract interface class CredentialProvider {
|
|||||||
/// 사용자가 취소하면 null. 쓸 수 없는 상태면 [AuthNotConfigured] 를 던지는 대신
|
/// 사용자가 취소하면 null. 쓸 수 없는 상태면 [AuthNotConfigured] 를 던지는 대신
|
||||||
/// 호출부가 [SocialAuthProvider.missingConfig] 를 먼저 본다.
|
/// 호출부가 [SocialAuthProvider.missingConfig] 를 먼저 본다.
|
||||||
///
|
///
|
||||||
/// [SocialCredential.email] 은 「동일 이메일만 연동 가능」(명세 `554:22797`)
|
/// [SocialCredential.email] 은 제공자가 알려 줄 때만 있다(null 가능). 계정 연결의 소유 확인에는
|
||||||
/// 판정에 쓴다. 제공자가 알려 주지 않으면 null 이다.
|
/// 쓰지 않는다(이메일 일치는 소유 확인이 아니다).
|
||||||
Future<SocialCredential?> obtainCredential();
|
Future<SocialCredential?> obtainCredential();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 자격증명 한 벌과, 판정에 쓸 그 계정의 이메일.
|
/// 자격증명 한 벌과 그 계정의 이메일(제공자가 줄 때만).
|
||||||
typedef SocialCredential = ({fb.AuthCredential credential, String? email});
|
typedef SocialCredential = ({fb.AuthCredential credential, String? email});
|
||||||
|
|
||||||
/// Google 로그인 — 실 코드 경로.
|
/// Google 로그인 — 실 코드 경로.
|
||||||
@@ -84,7 +91,7 @@ final class GoogleSignInProvider
|
|||||||
return AuthFailed('E-AUTH-03', error.description ?? error.code.name);
|
return AuthFailed('E-AUTH-03', error.description ?? error.code.name);
|
||||||
} on fb.FirebaseAuthException catch (error) {
|
} on fb.FirebaseAuthException catch (error) {
|
||||||
return AuthFailed(
|
return AuthFailed(
|
||||||
authCatalogCode(error),
|
authCatalogCode(error, flow: AuthFlow.signIn),
|
||||||
authMessage(error, flow: AuthFlow.signIn),
|
authMessage(error, flow: AuthFlow.signIn),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -150,8 +157,7 @@ final class AppleSignInProvider
|
|||||||
accessToken: credential.authorizationCode,
|
accessToken: credential.authorizationCode,
|
||||||
rawNonce: rawNonce,
|
rawNonce: rawNonce,
|
||||||
),
|
),
|
||||||
// Apple 은 **최초 인가 때만** 이메일을 준다. 두 번째부터는 null 이라
|
// Apple 은 **최초 인가 때만** 이메일을 준다. 두 번째부터는 null 이다.
|
||||||
// 「동일 이메일만 연동」 판정을 할 수 없다 — 그때는 통과시킨다.
|
|
||||||
email: credential.email,
|
email: credential.email,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -183,7 +189,7 @@ final class AppleSignInProvider
|
|||||||
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
|
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
|
||||||
} on fb.FirebaseAuthException catch (error) {
|
} on fb.FirebaseAuthException catch (error) {
|
||||||
return AuthFailed(
|
return AuthFailed(
|
||||||
authCatalogCode(error),
|
authCatalogCode(error, flow: AuthFlow.signIn),
|
||||||
authMessage(error, flow: AuthFlow.signIn),
|
authMessage(error, flow: AuthFlow.signIn),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -198,48 +204,453 @@ final class AppleSignInProvider
|
|||||||
kIsWeb || defaultTargetPlatform == TargetPlatform.android;
|
kIsWeb || defaultTargetPlatform == TargetPlatform.android;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 발주사 키를 기다리는 제공자 — 카카오·LINE.
|
/// Firebase Auth 웹 흐름 제공자 — 지금은 Android 의 Apple(`apple.com`)만 쓴다. 카카오 · LINE 도 이
|
||||||
|
/// 경로(`oidc.kakao` · `oidc.line`)였으나 앱 전환 · 「missing initial state」 문제로 공식 SDK + 서버 커스텀
|
||||||
|
/// 토큰([KakaoSignInProvider] · [LineSignInProvider])으로 옮겼다(클래스는 제공자 무관이라 그대로 둔다).
|
||||||
///
|
///
|
||||||
/// 예외를 던지지 않는다. [AuthNotConfigured] 로 "무엇이 없어서 못 쓰는지"를 돌려주고
|
/// 두 회사 모두 OIDC 표준(issuer `https://kauth.kakao.com` · `https://access.line.me`)을
|
||||||
/// 로그인 화면이 그걸 안내 다이얼로그로 띄운다. 키가 들어오면 이 자리에 실 구현체를
|
/// 지원해 Firebase 가 코드 교환·ID 토큰 검증까지 맡는다. 앱은 SDK 없이
|
||||||
/// 넣고 [AuthRepository] 목록만 바꾸면 된다 — 화면·라우터는 그대로다.
|
/// `signInWithProvider` 하나로 끝난다 — Android 는 Custom Tabs, iOS 는
|
||||||
final class KeylessSocialProvider implements SocialAuthProvider {
|
/// SFSafariViewController 로 인가 화면을 띄우고 Firebase 핸들러
|
||||||
const KeylessSocialProvider(this.provider, this.missingConfig);
|
/// (`https://<프로젝트>.firebaseapp.com/__/auth/handler`)로 돌아온다.
|
||||||
|
/// iOS 콜백 스킴은 Google 로그인용 REVERSED_CLIENT_ID 를 같이 쓴다.
|
||||||
|
///
|
||||||
|
/// 콘솔에서 제공자를 켜지 않았으면 Firebase 가 `operation-not-allowed` 를 돌려주고
|
||||||
|
/// [authCatalogCode] 가 E-AUTH-11 로 옮긴다 — 앱이 따로 플래그를 들 필요가 없다.
|
||||||
|
///
|
||||||
|
/// [CredentialProvider] 가 아니다. 웹 흐름이 자격증명 발급과 로그인·연결을 한 번에
|
||||||
|
/// 끝내서 자격증명만 먼저 받아 볼 수 없다. 계정 연결은 [FirebaseAccountLinker] 가
|
||||||
|
/// [authProvider] 로 `linkWithProvider` 를 부른다.
|
||||||
|
final class OidcSignInProvider implements SocialAuthProvider {
|
||||||
|
const OidcSignInProvider(
|
||||||
|
this.provider, {
|
||||||
|
required this.providerId,
|
||||||
|
this.scopes = const [],
|
||||||
|
this.firebaseAuth,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final SocialProvider provider;
|
final SocialProvider provider;
|
||||||
|
|
||||||
@override
|
/// Firebase 콘솔에 등록한 제공자 ID. `oidc.` 접두는 콘솔이 강제한다.
|
||||||
final List<String> missingConfig;
|
final String providerId;
|
||||||
|
|
||||||
|
/// `openid` 외에 더 받을 항목. Firebase 가 `openid` 는 알아서 붙인다.
|
||||||
|
/// **콘솔에 켠 동의 항목과 맞아야 한다** — 카카오는 설정에 없는 scope 를 요청하면
|
||||||
|
/// KOE205 로 로그인 자체를 막는다.
|
||||||
|
final List<String> scopes;
|
||||||
|
|
||||||
|
/// 테스트 주입용. 기본은 [fb.FirebaseAuth.instance].
|
||||||
|
final fb.FirebaseAuth? firebaseAuth;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get isLive => false;
|
bool get isLive => true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<AuthOutcome> signIn() async =>
|
List<String> get missingConfig =>
|
||||||
AuthNotConfigured(provider, missingConfig);
|
FirebaseBoot.isLive ? const [] : FirebaseBoot.missingLabels;
|
||||||
|
|
||||||
@override
|
fb.OAuthProvider get authProvider {
|
||||||
Future<void> signOut() async {}
|
final p = fb.OAuthProvider(providerId);
|
||||||
|
for (final scope in scopes) {
|
||||||
|
p.addScope(scope);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apple Service ID와 callback URL은 비밀값이 아니지만 환경별 값이다.
|
@override
|
||||||
/// 저장소에 임시 상수를 박지 않고 빌드 시 `--dart-define`으로만 받는다.
|
Future<AuthOutcome> signIn() async {
|
||||||
apple.WebAuthenticationOptions? appleWebAuthenticationOptionsFromEnvironment() {
|
final missing = missingConfig;
|
||||||
const clientId = String.fromEnvironment('FANUZU_APPLE_SERVICE_ID');
|
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
|
||||||
const redirect = String.fromEnvironment('FANUZU_APPLE_REDIRECT_URI');
|
try {
|
||||||
if (clientId.isEmpty || redirect.isEmpty) return null;
|
final result = await retryStaleWebContext(
|
||||||
|
() => untilAbandoned(
|
||||||
final redirectUri = Uri.tryParse(redirect);
|
(firebaseAuth ?? fb.FirebaseAuth.instance).signInWithProvider(
|
||||||
if (redirectUri == null ||
|
authProvider,
|
||||||
!redirectUri.hasScheme ||
|
),
|
||||||
!redirectUri.hasAuthority) {
|
),
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return apple.WebAuthenticationOptions(
|
|
||||||
clientId: clientId,
|
|
||||||
redirectUri: redirectUri,
|
|
||||||
);
|
);
|
||||||
|
return AuthSucceeded(_toAccount(result.user, provider));
|
||||||
|
} on fb.FirebaseAuthException catch (error) {
|
||||||
|
if (isWebFlowCancelled(error)) return const AuthCancelled();
|
||||||
|
return AuthFailed(
|
||||||
|
authCatalogCode(error, flow: AuthFlow.signIn),
|
||||||
|
authMessage(error, flow: AuthFlow.signIn),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> signOut() async {
|
||||||
|
// 카카오·LINE 쪽 세션은 브라우저 쿠키라 앱이 지울 수 없다. Firebase 세션은
|
||||||
|
// AuthSession 이 닫는다.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 카카오 네이티브 앱 키. `config/kakao.env` 를 `--dart-define-from-file` 로 넘긴다
|
||||||
|
/// (같은 파일을 Android manifestPlaceholders · iOS xcconfig 가 읽는다).
|
||||||
|
const kakaoNativeAppKey = String.fromEnvironment('FANUZU_KAKAO_NATIVE_APP_KEY');
|
||||||
|
|
||||||
|
/// 카카오 — 공식 SDK 로그인 + 서버 커스텀 토큰(`fnAuthKakao`). iOS·Android 같은 경로다.
|
||||||
|
///
|
||||||
|
/// Firebase OIDC(`oidc.kakao`) 웹 흐름은 Android 에서 「카카오톡으로 로그인」을 고르면
|
||||||
|
/// 콜백이 Custom Tab 밖(일반 Chrome)에서 열려 「missing initial state」로 깨진다. 카카오에는
|
||||||
|
/// 앱 전환을 막는 파라미터가 없어서, SDK 가 카카오톡(없으면 카카오계정)으로 로그인해 받은
|
||||||
|
/// 액세스 토큰을 서버가 카카오 API 로 검증하고 커스텀 토큰을 내준다.
|
||||||
|
///
|
||||||
|
/// 서버는 사용자 제공자 정보에 `oidc.kakao` 를 그대로 남긴다 — OIDC 시절 가입자는 같은 UID 로
|
||||||
|
/// 들어오고, [FirebaseAccountLinker.linkedAccounts] 도 지금처럼 「연결됨」을 그린다.
|
||||||
|
final class KakaoSignInProvider implements SocialAuthProvider {
|
||||||
|
KakaoSignInProvider({
|
||||||
|
Future<String> Function()? accessToken,
|
||||||
|
Future<Map<String, dynamic>> Function(Map<String, dynamic> payload)?
|
||||||
|
exchange,
|
||||||
|
this.firebaseAuth,
|
||||||
|
this.nativeAppKey = kakaoNativeAppKey,
|
||||||
|
}) : _accessToken = accessToken ?? _sdkAccessToken,
|
||||||
|
_exchange = exchange ?? _callServer;
|
||||||
|
|
||||||
|
final Future<String> Function() _accessToken;
|
||||||
|
final Future<Map<String, dynamic>> Function(Map<String, dynamic>) _exchange;
|
||||||
|
|
||||||
|
/// 테스트 주입용. 기본은 [fb.FirebaseAuth.instance].
|
||||||
|
final fb.FirebaseAuth? firebaseAuth;
|
||||||
|
final String nativeAppKey;
|
||||||
|
|
||||||
|
static Future<void>? _init;
|
||||||
|
|
||||||
|
@override
|
||||||
|
SocialProvider get provider => SocialProvider.kakao;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isLive => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> get missingConfig => [
|
||||||
|
if (!FirebaseBoot.isLive) ...FirebaseBoot.missingLabels,
|
||||||
|
if (nativeAppKey.isEmpty) '카카오 네이티브 앱 키',
|
||||||
|
];
|
||||||
|
|
||||||
|
fb.FirebaseAuth get _auth => firebaseAuth ?? fb.FirebaseAuth.instance;
|
||||||
|
|
||||||
|
/// 카카오톡이 있으면 카카오톡, 없거나 실패하면 카카오계정(공식 가이드 순서).
|
||||||
|
/// 사용자 취소(`CANCELED`)는 계정 로그인으로 넘기지 않고 그대로 던진다.
|
||||||
|
static Future<String> _sdkAccessToken() async {
|
||||||
|
await (_init ??= kakao.KakaoSdk.init(nativeAppKey: kakaoNativeAppKey));
|
||||||
|
final api = kakao.UserApi.instance;
|
||||||
|
if (await kakao.isKakaoTalkInstalled()) {
|
||||||
|
try {
|
||||||
|
return (await api.loginWithKakaoTalk()).accessToken;
|
||||||
|
} on PlatformException catch (error) {
|
||||||
|
if (error.code == 'CANCELED') rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (await api.loginWithKakaoAccount()).accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>> _callServer(
|
||||||
|
Map<String, dynamic> payload,
|
||||||
|
) async {
|
||||||
|
final result = await FirebaseFunctions.instanceFor(
|
||||||
|
region: Backend.region,
|
||||||
|
).httpsCallable('fnAuthKakao').call<Object?>(payload);
|
||||||
|
final data = result.data;
|
||||||
|
return data is Map ? Map<String, dynamic>.from(data) : const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AuthOutcome> signIn() => _run(AuthFlow.signIn, () async {
|
||||||
|
final data = await _exchange({'accessToken': await _accessToken()});
|
||||||
|
final result = await _auth.signInWithCustomToken(data['token'] as String);
|
||||||
|
return AuthSucceeded(_toAccount(result.user, provider));
|
||||||
|
});
|
||||||
|
|
||||||
|
/// SNS 연동 — 로그인한 UID 에 카카오를 붙인다. 현재 세션 · 카카오 토큰 검증과 다른 UID 연결 거부는
|
||||||
|
/// 서버(`fnAuthKakao` link)가 한다. 이메일은 보지 않는다.
|
||||||
|
Future<AuthOutcome> link(fb.User user) => _run(AuthFlow.link, () async {
|
||||||
|
await _exchange({'accessToken': await _accessToken(), 'link': true});
|
||||||
|
await user.reload();
|
||||||
|
return AuthSucceeded(_toAccount(_auth.currentUser ?? user, provider));
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<AuthOutcome> _run(
|
||||||
|
AuthFlow flow,
|
||||||
|
Future<AuthOutcome> Function() body,
|
||||||
|
) async {
|
||||||
|
final missing = missingConfig;
|
||||||
|
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
|
||||||
|
try {
|
||||||
|
return await body();
|
||||||
|
} on PlatformException catch (error) {
|
||||||
|
if (error.code == 'CANCELED') return const AuthCancelled();
|
||||||
|
debugPrint('[KakaoSignInProvider] sdk failed: ${error.code}');
|
||||||
|
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
|
||||||
|
} on kakao.KakaoAuthException catch (error) {
|
||||||
|
// 동의 화면에서 「취소」.
|
||||||
|
if (error.error == kakao.AuthErrorCause.accessDenied) {
|
||||||
|
return const AuthCancelled();
|
||||||
|
}
|
||||||
|
// 카카오톡 왕복 뒤 인가 코드 · 토큰 단계 실패(KOE · misconfigured 등). 원인을 로그로만 남긴다.
|
||||||
|
debugPrint(
|
||||||
|
'[KakaoSignInProvider] auth failed: ${error.error} ${error.errorDescription}',
|
||||||
|
);
|
||||||
|
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
|
||||||
|
} on kakao.KakaoException catch (error) {
|
||||||
|
debugPrint('[KakaoSignInProvider] sdk error: $error');
|
||||||
|
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
|
||||||
|
} on FirebaseFunctionsException catch (error) {
|
||||||
|
return _serverFailure(error, flow);
|
||||||
|
} on fb.FirebaseAuthException catch (error) {
|
||||||
|
return AuthFailed(
|
||||||
|
authCatalogCode(error, flow: flow),
|
||||||
|
authMessage(error, flow: flow),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> signOut() async {
|
||||||
|
// SDK 를 한 번도 띄우지 않았으면 지울 카카오 토큰도 없다.
|
||||||
|
if (_init == null) return;
|
||||||
|
try {
|
||||||
|
await kakao.UserApi.instance.logout();
|
||||||
|
} on Object catch (error) {
|
||||||
|
// 토큰이 이미 만료·폐기됐어도 SDK 는 로컬 토큰을 지운다. 로그아웃을 막을 이유가 없다.
|
||||||
|
debugPrint('[KakaoSignInProvider] logout: ${error.runtimeType}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 커스텀 토큰 교환 서버(`fnAuthKakao` · `fnAuthLine`)의 거부를 카탈로그 코드 · 문구로 옮긴다.
|
||||||
|
AuthFailed _serverFailure(FirebaseFunctionsException error, AuthFlow flow) {
|
||||||
|
final details = error.details;
|
||||||
|
final code = details is Map && details['code'] is String
|
||||||
|
? details['code'] as String
|
||||||
|
: 'E-AUTH-03';
|
||||||
|
return AuthFailed(code, switch (code) {
|
||||||
|
_ when details is Map && details['reason'] == 'session' =>
|
||||||
|
'보안을 위해 다시 로그인한 뒤 시도해 주세요.',
|
||||||
|
'E-AUTH-08' => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.',
|
||||||
|
'E-AUTH-09' => '이미 연결된 계정입니다.',
|
||||||
|
_ => flow == AuthFlow.link ? '계정 연결에 실패했습니다.' : '소셜 로그인에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// LINE Login 채널 ID. 카카오 앱 키와 같은 `config/kakao.env` 를 `--dart-define-from-file` 로 넘긴다.
|
||||||
|
const lineChannelId = String.fromEnvironment('FANUZU_LINE_CHANNEL_ID');
|
||||||
|
|
||||||
|
/// LINE 이 돌려준 토큰. ID 토큰은 `openid` 동의가 있을 때만 온다 — 서버는 있으면 ID 토큰으로 검증한다.
|
||||||
|
typedef LineTokens = ({String? idToken, String accessToken});
|
||||||
|
|
||||||
|
/// LINE — 공식 SDK 로그인 + 서버 커스텀 토큰(`fnAuthLine`). [KakaoSignInProvider] 와 같은 구조다.
|
||||||
|
///
|
||||||
|
/// Firebase OIDC(`oidc.line`) 웹 흐름은 iOS 에서 첫 시도에 「missing initial state」로 깨진다
|
||||||
|
/// (`disable_ios_auto_login` 을 붙여도 같다). SDK 가 LINE 앱(없으면 SDK 웹 화면)으로 로그인해 받은
|
||||||
|
/// 토큰을 서버가 LINE API 로 검증하고 커스텀 토큰을 내준다.
|
||||||
|
///
|
||||||
|
/// 서버는 사용자 제공자 정보에 `oidc.line` 을 그대로 남긴다 — OIDC 시절 가입자는 같은 UID 로 들어오고,
|
||||||
|
/// [FirebaseAccountLinker.linkedAccounts] 도 지금처럼 「연결됨」을 그린다.
|
||||||
|
///
|
||||||
|
/// 동의 항목: profile · openid · email. 이메일은 LINE 이메일 권한 승인 · 사용자 동의가 있을 때만 ID 토큰에
|
||||||
|
/// 오고, 서버가 그때만 남긴다. 없어도 로그인 · 연결이 된다.
|
||||||
|
final class LineSignInProvider implements SocialAuthProvider {
|
||||||
|
LineSignInProvider({
|
||||||
|
Future<LineTokens> Function()? login,
|
||||||
|
Future<Map<String, dynamic>> Function(Map<String, dynamic> payload)?
|
||||||
|
exchange,
|
||||||
|
this.firebaseAuth,
|
||||||
|
this.channelId = lineChannelId,
|
||||||
|
}) : _login = login ?? _sdkLogin,
|
||||||
|
_exchange = exchange ?? _callServer;
|
||||||
|
|
||||||
|
final Future<LineTokens> Function() _login;
|
||||||
|
final Future<Map<String, dynamic>> Function(Map<String, dynamic>) _exchange;
|
||||||
|
|
||||||
|
/// 테스트 주입용. 기본은 [fb.FirebaseAuth.instance].
|
||||||
|
final fb.FirebaseAuth? firebaseAuth;
|
||||||
|
final String channelId;
|
||||||
|
|
||||||
|
static Future<void>? _setup;
|
||||||
|
|
||||||
|
/// 사용자 취소 — Android `CANCEL`(LineApiResponseCode) · iOS `3003`(LineSDKError userCancelled).
|
||||||
|
static const _cancelCodes = {'CANCEL', '3003'};
|
||||||
|
|
||||||
|
@override
|
||||||
|
SocialProvider get provider => SocialProvider.line;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isLive => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> get missingConfig => [
|
||||||
|
if (!FirebaseBoot.isLive) ...FirebaseBoot.missingLabels,
|
||||||
|
if (channelId.isEmpty) 'LINE 채널 ID',
|
||||||
|
];
|
||||||
|
|
||||||
|
fb.FirebaseAuth get _auth => firebaseAuth ?? fb.FirebaseAuth.instance;
|
||||||
|
|
||||||
|
static Future<LineTokens> _sdkLogin() async {
|
||||||
|
await (_setup ??= line.LineSDK.instance.setup(lineChannelId));
|
||||||
|
final result = await line.LineSDK.instance.login(
|
||||||
|
scopes: const ['profile', 'openid', 'email'],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
idToken: result.accessToken.idTokenRaw,
|
||||||
|
accessToken: result.accessToken.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>> _callServer(
|
||||||
|
Map<String, dynamic> payload,
|
||||||
|
) async {
|
||||||
|
final result = await FirebaseFunctions.instanceFor(
|
||||||
|
region: Backend.region,
|
||||||
|
).httpsCallable('fnAuthLine').call<Object?>(payload);
|
||||||
|
final data = result.data;
|
||||||
|
return data is Map ? Map<String, dynamic>.from(data) : const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> _tokens() async {
|
||||||
|
final t = await _login();
|
||||||
|
return {
|
||||||
|
if (t.idToken != null) 'idToken': t.idToken,
|
||||||
|
'accessToken': t.accessToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AuthOutcome> signIn() => _run(AuthFlow.signIn, () async {
|
||||||
|
final data = await _exchange(await _tokens());
|
||||||
|
final result = await _auth.signInWithCustomToken(data['token'] as String);
|
||||||
|
return AuthSucceeded(_toAccount(result.user, provider));
|
||||||
|
});
|
||||||
|
|
||||||
|
/// SNS 연동 — 로그인한 UID 에 LINE 을 붙인다. 현재 세션 · LINE 토큰 검증과 다른 UID 연결 거부는
|
||||||
|
/// 서버(`fnAuthLine` link)가 한다. 이메일은 보지 않는다.
|
||||||
|
Future<AuthOutcome> link(fb.User user) => _run(AuthFlow.link, () async {
|
||||||
|
await _exchange({...await _tokens(), 'link': true});
|
||||||
|
await user.reload();
|
||||||
|
return AuthSucceeded(_toAccount(_auth.currentUser ?? user, provider));
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<AuthOutcome> _run(
|
||||||
|
AuthFlow flow,
|
||||||
|
Future<AuthOutcome> Function() body,
|
||||||
|
) async {
|
||||||
|
final missing = missingConfig;
|
||||||
|
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
|
||||||
|
try {
|
||||||
|
return await body();
|
||||||
|
} on PlatformException catch (error) {
|
||||||
|
if (_cancelCodes.contains(error.code)) return const AuthCancelled();
|
||||||
|
debugPrint(
|
||||||
|
'[LineSignInProvider] sdk failed: ${error.code} ${error.message}',
|
||||||
|
);
|
||||||
|
return AuthFailed(
|
||||||
|
'E-AUTH-03',
|
||||||
|
flow == AuthFlow.link ? '계정 연결에 실패했습니다.' : '소셜 로그인에 실패했습니다.',
|
||||||
|
);
|
||||||
|
} on FirebaseFunctionsException catch (error) {
|
||||||
|
return _serverFailure(error, flow);
|
||||||
|
} on fb.FirebaseAuthException catch (error) {
|
||||||
|
return AuthFailed(
|
||||||
|
authCatalogCode(error, flow: flow),
|
||||||
|
authMessage(error, flow: flow),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> signOut() async {
|
||||||
|
// SDK 를 한 번도 띄우지 않았으면 지울 LINE 토큰도 없다.
|
||||||
|
if (_setup == null) return;
|
||||||
|
try {
|
||||||
|
await line.LineSDK.instance.logout();
|
||||||
|
} on Object catch (error) {
|
||||||
|
// 토큰이 이미 만료 · 폐기됐어도 로그아웃을 막을 이유가 없다.
|
||||||
|
debugPrint('[LineSignInProvider] logout: ${error.runtimeType}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 사용자가 인가 웹 화면을 닫았다. Android 는 `canceled`, iOS 는 `cancelled` 철자다.
|
||||||
|
/// `user-cancelled` 는 동의 화면에서 거절한 경우다(웹 흐름).
|
||||||
|
bool isWebFlowCancelled(fb.FirebaseAuthException error) =>
|
||||||
|
error.code == 'web-context-canceled' ||
|
||||||
|
error.code == 'web-context-cancelled' ||
|
||||||
|
error.code == 'user-cancelled';
|
||||||
|
|
||||||
|
const _authChannel = MethodChannel('kr.co.fanuzu.app/auth');
|
||||||
|
|
||||||
|
/// Android — 결과 없이 남은 Firebase 웹 인가 대기 작업을 취소로 끝낸다(MainActivity `cancelPendingWebSignIn`).
|
||||||
|
///
|
||||||
|
/// LINE 로그인 도중 본인 확인을 하러 LINE 앱으로 갔다 돌아오는 식으로 흐름이 끊기면, Firebase 는 대기 작업을
|
||||||
|
/// 들고 있어 다음 시도를 `web-context-already-presented` 로 막는다(다시 눌러도 로그인이 안 됨).
|
||||||
|
/// 대기 작업이 없으면 아무 일도 하지 않는다. iOS · 웹은 SDK 가 스스로 정리한다.
|
||||||
|
Future<void> clearStaleWebSignIn() async {
|
||||||
|
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return;
|
||||||
|
try {
|
||||||
|
await _authChannel.invokeMethod<void>('cancelPendingWebSignIn');
|
||||||
|
} on Object catch (error) {
|
||||||
|
debugPrint('[Auth] clear stale web sign-in skipped: $error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 웹 인가가 앞선 흐름에 막혔으면(`web-context-already-presented`) 남은 대기를 정리하고 **한 번만** 다시 연다.
|
||||||
|
/// 취소 · 실패 뒤에도 정상적으로 다시 시도할 수 있어야 한다.
|
||||||
|
Future<T> retryStaleWebContext<T>(Future<T> Function() attempt) async {
|
||||||
|
try {
|
||||||
|
return await attempt();
|
||||||
|
} on fb.FirebaseAuthException catch (error) {
|
||||||
|
if (error.code != 'web-context-already-presented') rethrow;
|
||||||
|
await clearStaleWebSignIn();
|
||||||
|
return attempt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Android 에서 웹 인가 흐름이 결과 없이 버려졌으면 취소(`web-context-canceled`)로 끝낸다.
|
||||||
|
///
|
||||||
|
/// 콜백이 Custom Tab 밖(일반 Chrome)에서 열리면 Firebase 의 대기 Task 가 영영
|
||||||
|
/// 끝나지 않아 로그인 버튼이 잠긴다. 앱이 포그라운드로 돌아온 뒤 [grace] 안에
|
||||||
|
/// 결과가 없으면 사용자가 흐름을 버린 것으로 보고 기존 취소 경로([isWebFlowCancelled])로
|
||||||
|
/// 넘긴다. 정상 완료는 돌아온 직후 토큰 교환 한 번이라 [grace] 안에 끝난다.
|
||||||
|
/// iOS 는 SFSafariViewController 가 취소를 스스로 알려 주므로 그대로 둔다.
|
||||||
|
///
|
||||||
|
/// ponytail: 고정 대기 시간. 망이 아주 느려 [grace] 를 넘기면 취소로 보이지만
|
||||||
|
/// Firebase 세션은 나중에 열리므로 authStateChanges 가 따라잡는다.
|
||||||
|
Future<T> untilAbandoned<T>(
|
||||||
|
Future<T> flow, {
|
||||||
|
Duration grace = const Duration(seconds: 8),
|
||||||
|
}) {
|
||||||
|
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return flow;
|
||||||
|
final done = Completer<T>();
|
||||||
|
Timer? timer;
|
||||||
|
final listener = AppLifecycleListener(
|
||||||
|
onStateChange: (state) {
|
||||||
|
timer?.cancel();
|
||||||
|
if (state != AppLifecycleState.resumed) return;
|
||||||
|
timer = Timer(grace, () {
|
||||||
|
if (done.isCompleted) return;
|
||||||
|
// Firebase 쪽 대기 작업도 끝낸다 — 남기면 다음 시도가 web-context-already-presented 로 막힌다.
|
||||||
|
unawaited(clearStaleWebSignIn());
|
||||||
|
done.completeError(
|
||||||
|
fb.FirebaseAuthException(code: 'web-context-canceled'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
flow.then(
|
||||||
|
(value) {
|
||||||
|
if (!done.isCompleted) done.complete(value);
|
||||||
|
},
|
||||||
|
onError: (Object error, StackTrace stack) {
|
||||||
|
if (!done.isCompleted) done.completeError(error, stack);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return done.future.whenComplete(() {
|
||||||
|
timer?.cancel();
|
||||||
|
listener.dispose();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
AuthAccount _toAccount(fb.User? user, SocialProvider provider) => AuthAccount(
|
AuthAccount _toAccount(fb.User? user, SocialProvider provider) => AuthAccount(
|
||||||
@@ -310,8 +721,8 @@ final class EmailPasswordProvider implements EmailAuthProvider {
|
|||||||
return const AuthCancelled();
|
return const AuthCancelled();
|
||||||
} on fb.FirebaseAuthException catch (error) {
|
} on fb.FirebaseAuthException catch (error) {
|
||||||
return AuthFailed(
|
return AuthFailed(
|
||||||
authCatalogCode(error),
|
authCatalogCode(error, flow: AuthFlow.email),
|
||||||
authMessage(error, flow: AuthFlow.signIn),
|
authMessage(error, flow: AuthFlow.email),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,8 +735,8 @@ final class EmailPasswordProvider implements EmailAuthProvider {
|
|||||||
return AuthSucceeded(_toAccount(result.user, provider));
|
return AuthSucceeded(_toAccount(result.user, provider));
|
||||||
} on fb.FirebaseAuthException catch (error) {
|
} on fb.FirebaseAuthException catch (error) {
|
||||||
return AuthFailed(
|
return AuthFailed(
|
||||||
authCatalogCode(error),
|
authCatalogCode(error, flow: AuthFlow.email),
|
||||||
authMessage(error, flow: AuthFlow.signIn),
|
authMessage(error, flow: AuthFlow.email),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -336,19 +747,18 @@ final class EmailPasswordProvider implements EmailAuthProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// LINE — 발주사 키 대기.
|
/// SDK 코드를 에러 카탈로그 ID 로 옮긴다.
|
||||||
///
|
|
||||||
/// 별첨 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) {
|
///
|
||||||
|
/// 알 수 없는 코드는 경로별로 갈린다 — 이메일 폼([AuthFlow.email])은 E-AUTH-01, 소셜 로그인 · 연결은
|
||||||
|
/// 소셜 실패 E-AUTH-03 이다. 하나로 E-AUTH-01 이면 LINE 웹 로그인이 끊길 때(`web-*`)
|
||||||
|
/// 「이메일 또는 비밀번호를 확인해 주세요」가 뜬다.
|
||||||
|
String authCatalogCode(
|
||||||
|
fb.FirebaseAuthException error, {
|
||||||
|
required AuthFlow flow,
|
||||||
|
}) => switch (error.code) {
|
||||||
'email-already-in-use' => 'E-AUTH-04',
|
'email-already-in-use' => 'E-AUTH-04',
|
||||||
'weak-password' => 'E-AUTH-05',
|
'weak-password' => 'E-AUTH-05',
|
||||||
'invalid-email' => 'E-AUTH-06',
|
'invalid-email' => 'E-AUTH-06',
|
||||||
@@ -362,8 +772,13 @@ String authCatalogCode(fb.FirebaseAuthException error) => switch (error.code) {
|
|||||||
'no-such-provider' => 'E-AUTH-10',
|
'no-such-provider' => 'E-AUTH-10',
|
||||||
// Firebase 콘솔에서 제공자를 켜지 않았을 때. 사유가 사용자가 아니라 설정에 있다.
|
// Firebase 콘솔에서 제공자를 켜지 않았을 때. 사유가 사용자가 아니라 설정에 있다.
|
||||||
'operation-not-allowed' => 'E-AUTH-11',
|
'operation-not-allowed' => 'E-AUTH-11',
|
||||||
'requires-recent-login' => 'E-AUTH-12',
|
// 현재 계정의 세션이 만료 · 폐기됐다. 다시 로그인해야 한다.
|
||||||
_ => 'E-AUTH-01',
|
'requires-recent-login' ||
|
||||||
|
'user-token-expired' ||
|
||||||
|
'invalid-user-token' ||
|
||||||
|
'user-mismatch' ||
|
||||||
|
'user-disabled' => 'E-AUTH-12',
|
||||||
|
_ => flow == AuthFlow.email ? 'E-AUTH-01' : 'E-AUTH-03',
|
||||||
};
|
};
|
||||||
|
|
||||||
/// 오류 문구가 갈리는 경로.
|
/// 오류 문구가 갈리는 경로.
|
||||||
@@ -372,9 +787,12 @@ String authCatalogCode(fb.FirebaseAuthException error) => switch (error.code) {
|
|||||||
/// 밝힌다. 기본값을 두지 않는 것은 의도한 것이다 — 새 호출부가 경로를 밝히지
|
/// 밝힌다. 기본값을 두지 않는 것은 의도한 것이다 — 새 호출부가 경로를 밝히지
|
||||||
/// 않고 지나가면 다시 한쪽 문구가 양쪽에 나간다.
|
/// 않고 지나가면 다시 한쪽 문구가 양쪽에 나간다.
|
||||||
enum AuthFlow {
|
enum AuthFlow {
|
||||||
/// 로그인 화면에서 계정으로 들어가려는 경로(`signInWithCredential`).
|
/// 소셜 로그인 경로(`signInWithCredential` · `signInWithProvider`).
|
||||||
signIn,
|
signIn,
|
||||||
|
|
||||||
|
/// 이메일 · 비밀번호 폼(QA 내부 경로). 알 수 없는 실패를 「이메일 또는 비밀번호」로 안내하는 유일한 경로다.
|
||||||
|
email,
|
||||||
|
|
||||||
/// 이미 로그인한 UID 에 소셜 제공자를 붙이거나 떼는 경로
|
/// 이미 로그인한 UID 에 소셜 제공자를 붙이거나 떼는 경로
|
||||||
/// (`linkWithCredential` · `unlink`) — 별첨 B v1.4.
|
/// (`linkWithCredential` · `unlink`) — 별첨 B v1.4.
|
||||||
link,
|
link,
|
||||||
@@ -394,7 +812,8 @@ String authMessage(fb.FirebaseAuthException error, {required AuthFlow flow}) =>
|
|||||||
'invalid-email' => '이메일 형식이 올바르지 않습니다.',
|
'invalid-email' => '이메일 형식이 올바르지 않습니다.',
|
||||||
'too-many-requests' => '잠시 후 다시 시도해 주세요.',
|
'too-many-requests' => '잠시 후 다시 시도해 주세요.',
|
||||||
'account-exists-with-different-credential' => switch (flow) {
|
'account-exists-with-different-credential' => switch (flow) {
|
||||||
AuthFlow.signIn => _otherProviderSignInGuide(error.email),
|
AuthFlow.signIn ||
|
||||||
|
AuthFlow.email => _otherProviderSignInGuide(error.email),
|
||||||
AuthFlow.link => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.',
|
AuthFlow.link => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.',
|
||||||
},
|
},
|
||||||
// 붙이려는 자격증명이 남의 UID 에 이미 달려 있다. 연결 경로에서만 난다.
|
// 붙이려는 자격증명이 남의 UID 에 이미 달려 있다. 연결 경로에서만 난다.
|
||||||
@@ -402,8 +821,20 @@ String authMessage(fb.FirebaseAuthException error, {required AuthFlow flow}) =>
|
|||||||
'provider-already-linked' => '이미 연결된 계정입니다.',
|
'provider-already-linked' => '이미 연결된 계정입니다.',
|
||||||
'no-such-provider' => '연결되어 있지 않은 계정입니다.',
|
'no-such-provider' => '연결되어 있지 않은 계정입니다.',
|
||||||
'operation-not-allowed' => '이 로그인 수단은 아직 사용할 수 없습니다.',
|
'operation-not-allowed' => '이 로그인 수단은 아직 사용할 수 없습니다.',
|
||||||
'requires-recent-login' => '보안을 위해 다시 로그인한 뒤 시도해 주세요.',
|
'requires-recent-login' ||
|
||||||
_ => '이메일 또는 비밀번호를 확인해 주세요.',
|
'user-token-expired' ||
|
||||||
|
'invalid-user-token' ||
|
||||||
|
'user-mismatch' ||
|
||||||
|
'user-disabled' => '보안을 위해 다시 로그인한 뒤 시도해 주세요.',
|
||||||
|
// 웹 인가 흐름이 끊기거나 망이 끊겼다 — 소셜 경로의 사실대로 알리고 다시 시도하게 한다.
|
||||||
|
'web-context-already-presented' => '로그인이 완료되지 않았습니다. 다시 시도해 주세요.',
|
||||||
|
'web-network-request-failed' ||
|
||||||
|
'network-request-failed' => '네트워크 연결을 확인한 뒤 다시 시도해 주세요.',
|
||||||
|
_ => switch (flow) {
|
||||||
|
AuthFlow.email => '이메일 또는 비밀번호를 확인해 주세요.',
|
||||||
|
AuthFlow.signIn => '소셜 로그인에 실패했습니다.',
|
||||||
|
AuthFlow.link => '계정 연결에 실패했습니다.',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// 「먼저 쓰던 수단으로 로그인한 뒤 SNS 연동에서 붙이라」는 안내.
|
/// 「먼저 쓰던 수단으로 로그인한 뒤 SNS 연동에서 붙이라」는 안내.
|
||||||
|
|||||||
@@ -1,15 +1,48 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../core/ui/tab_placeholder.dart';
|
import '../../core/ui/tab_placeholder.dart';
|
||||||
|
import '../auth/auth_repository.dart';
|
||||||
|
|
||||||
/// MY 탭 루트 — S-M-HOME 마이 페이지.
|
/// MY 탭 루트 — S-M-HOME 마이 페이지.
|
||||||
|
///
|
||||||
|
/// 화면 구현 전이라도 로그아웃은 여기서 할 수 있어야 한다 — 다른 계정 · 다른 로그인
|
||||||
|
/// 방법으로 다시 들어가 볼 길이 없으면 소셜 로그인 4종을 한 기기에서 확인할 수 없다.
|
||||||
|
/// 로그아웃하면 인증 상태가 바뀌고 라우터가 로그인 화면으로 보낸다.
|
||||||
class MyScreen extends StatelessWidget {
|
class MyScreen extends StatelessWidget {
|
||||||
const MyScreen({super.key});
|
const MyScreen({super.key, required this.auth});
|
||||||
|
|
||||||
|
final AuthRepository auth;
|
||||||
|
|
||||||
|
Future<void> _logout(BuildContext context) async {
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
content: const Text('로그아웃 하시겠습니까?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text('취소'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
child: const Text('로그아웃'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok == true) await auth.signOut();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => const TabPlaceholder(
|
Widget build(BuildContext context) => TabPlaceholder(
|
||||||
tab: 'MY',
|
tab: 'MY',
|
||||||
screenId: 'S-M-HOME',
|
screenId: 'S-M-HOME',
|
||||||
screenName: '마이 페이지',
|
screenName: '마이 페이지',
|
||||||
|
footer: Builder(
|
||||||
|
builder: (context) => OutlinedButton(
|
||||||
|
onPressed: () => _logout(context),
|
||||||
|
child: const Text('로그아웃'),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-2
@@ -1,14 +1,37 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
|
import 'core/data/backend.dart';
|
||||||
import 'core/firebase/firebase_boot.dart';
|
import 'core/firebase/firebase_boot.dart';
|
||||||
import 'features/auth/auth_repository.dart';
|
import 'features/auth/auth_repository.dart';
|
||||||
import 'features/auth/firebase_auth_session.dart';
|
import 'features/auth/firebase_auth_session.dart';
|
||||||
|
import 'features/auth/signup_screens.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
// 설정 파일이 없으면 목 모드로 내려간다. 여기서 throw 하지 않는다 — FirebaseBoot 참고.
|
// 설정 파일이 없으면 목 모드로 내려간다. 여기서 throw 하지 않는다 — FirebaseBoot 참고.
|
||||||
await FirebaseBoot.init();
|
await FirebaseBoot.init();
|
||||||
final session = FirebaseBoot.isLive ? FirebaseAuthSession() : null;
|
final live = FirebaseBoot.isLive;
|
||||||
runApp(FancakeApp(auth: AuthRepository(session: session)));
|
runApp(
|
||||||
|
FancakeApp(
|
||||||
|
auth: AuthRepository(
|
||||||
|
session: live ? FirebaseAuthSession() : null,
|
||||||
|
needsTerms: live ? _needsTerms : null,
|
||||||
|
recordTerms: live
|
||||||
|
? (agreed) => Backend().call('fnSignupConsent', {
|
||||||
|
'agreed': agreed.toList(),
|
||||||
|
'version': signupTermsVersion,
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 동의 기록(`termsAgreedAt`)이 없으면 약관을 받아야 한다. 갓 생성된 계정은 트리거가 아직
|
||||||
|
/// 사용자 문서를 만들지 않았을 수 있다 — 그래도 동의가 필요하다.
|
||||||
|
Future<bool> _needsTerms(String uid) async {
|
||||||
|
final snap = await FirebaseFirestore.instance.doc('users/$uid').get();
|
||||||
|
return snap.data()?['termsAgreedAt'] == null;
|
||||||
}
|
}
|
||||||
|
|||||||
+231
-7
@@ -9,6 +9,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.76"
|
version: "1.3.76"
|
||||||
|
adaptive_number:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: adaptive_number
|
||||||
|
sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
antlr4:
|
antlr4:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -33,6 +41,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.7.0"
|
version: "2.7.0"
|
||||||
|
asn1lib:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: asn1lib
|
||||||
|
sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.6.5"
|
||||||
async:
|
async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -162,13 +178,45 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
version: "3.0.7"
|
||||||
dart_jsonwebtoken:
|
dart_jsonwebtoken:
|
||||||
dependency: transitive
|
dependency: "direct overridden"
|
||||||
description:
|
description:
|
||||||
name: dart_jsonwebtoken
|
name: dart_jsonwebtoken
|
||||||
sha256: ad84e60181696513d04d5f2078e0bbc20365b911f46f647797317414bdc88fbe
|
sha256: "00a0812d2aeaeb0d30bcbc4dd3cee57971dbc0ab2216adf4f0247f37793f15ef"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.4.1"
|
version: "2.17.0"
|
||||||
|
dio:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dio
|
||||||
|
sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.11.1"
|
||||||
|
dio_web_adapter:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dio_web_adapter
|
||||||
|
sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.2"
|
||||||
|
ed25519_edwards:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: ed25519_edwards
|
||||||
|
sha256: f3c3b109a0e470ee35f6346a155cab290578660852237fce10df6bbd92758534
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.2"
|
||||||
|
encrypt:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: encrypt
|
||||||
|
sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.0.3"
|
||||||
equatable:
|
equatable:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -299,6 +347,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.14.4"
|
version: "0.14.4"
|
||||||
|
flutter_line_sdk:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_line_sdk
|
||||||
|
sha256: a311289a2f38d1c1c5ea05fc6b421185e571752f51b84df8e01c195e8e890d02
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.2"
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@@ -307,6 +363,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.0.0"
|
||||||
|
flutter_svg:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_svg
|
||||||
|
sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.0"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -407,6 +471,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
js:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: js
|
||||||
|
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.2"
|
||||||
json_annotation:
|
json_annotation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -415,6 +487,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.12.0"
|
version: "4.12.0"
|
||||||
|
kakao_flutter_sdk_auth:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: kakao_flutter_sdk_auth
|
||||||
|
sha256: b9d982569462f6d31f97460f1d6f51acdc290f318967ee14009d20edc3006a12
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.1"
|
||||||
|
kakao_flutter_sdk_common:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: kakao_flutter_sdk_common
|
||||||
|
sha256: "6a89e80ef6c080f434b05df87d9dd83323ff6d10ea34d9e0a6cd90d2015f7e48"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.1"
|
||||||
|
kakao_flutter_sdk_user:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: kakao_flutter_sdk_user
|
||||||
|
sha256: "047ff5a82f52a7e1558086acd87b89b941b072e6029de3eee0b96dbce4ca1e48"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.1"
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -487,6 +583,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.0"
|
version: "1.19.0"
|
||||||
|
mime:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: mime
|
||||||
|
sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.0"
|
||||||
mock_exceptions:
|
mock_exceptions:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -511,6 +615,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.1"
|
||||||
|
path_parsing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_parsing
|
||||||
|
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
|
path_provider_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_linux
|
||||||
|
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.2"
|
||||||
|
path_provider_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_platform_interface
|
||||||
|
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.3"
|
||||||
|
path_provider_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_windows
|
||||||
|
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.0"
|
||||||
petitparser:
|
petitparser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -539,10 +675,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: pointycastle
|
name: pointycastle
|
||||||
sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5"
|
sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.0"
|
version: "3.9.1"
|
||||||
posix:
|
posix:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -575,6 +711,62 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.28.0"
|
version: "0.28.0"
|
||||||
|
shared_preferences:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences
|
||||||
|
sha256: "1e85e0dccf5b4a016077fea7dfbb4a7cb39c759ac1596fcf60c06deadb7da91f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.6"
|
||||||
|
shared_preferences_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_android
|
||||||
|
sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.28"
|
||||||
|
shared_preferences_foundation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_foundation
|
||||||
|
sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.7"
|
||||||
|
shared_preferences_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_linux
|
||||||
|
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.1"
|
||||||
|
shared_preferences_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_platform_interface
|
||||||
|
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.2"
|
||||||
|
shared_preferences_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_web
|
||||||
|
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.3"
|
||||||
|
shared_preferences_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_windows
|
||||||
|
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.1"
|
||||||
sign_in_with_apple:
|
sign_in_with_apple:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -676,6 +868,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.6.0"
|
version: "4.6.0"
|
||||||
|
vector_graphics:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vector_graphics
|
||||||
|
sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.3"
|
||||||
|
vector_graphics_codec:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vector_graphics_codec
|
||||||
|
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.13"
|
||||||
|
vector_graphics_compiler:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vector_graphics_compiler
|
||||||
|
sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -708,6 +924,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.0"
|
version: "3.2.0"
|
||||||
|
xdg_directories:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xdg_directories
|
||||||
|
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
xml:
|
xml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -725,5 +949,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.11.5 <4.0.0"
|
dart: ">=3.12.0 <4.0.0"
|
||||||
flutter: ">=3.41.0"
|
flutter: ">=3.44.0"
|
||||||
|
|||||||
+18
-2
@@ -1,7 +1,7 @@
|
|||||||
name: fancake
|
name: fancake
|
||||||
description: "FANUZU — 팬덤 플랫폼 앱 (골격). 발주사 (주)팬케이크 / 수행사 스위치 주식회사"
|
description: "FANUZU — 팬덤 플랫폼 앱 (골격). 발주사 (주)팬케이크 / 수행사 스위치 주식회사"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.1.0+8
|
version: 0.1.1+12
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.5
|
sdk: ^3.11.5
|
||||||
@@ -29,9 +29,19 @@ dependencies:
|
|||||||
# Apple Sign-In nonce를 SHA-256으로 해시해 replay 공격을 막는다.
|
# Apple Sign-In nonce를 SHA-256으로 해시해 replay 공격을 막는다.
|
||||||
crypto: ^3.0.7
|
crypto: ^3.0.7
|
||||||
|
|
||||||
# 소셜 로그인 — Google·Apple 만 실 코드 경로. 카카오·네이버는 앱 키가 없어 스텁이다(Q3).
|
# 소셜 로그인 — Google·Apple 은 각 SDK, LINE 은 Firebase OIDC(SDK 없음), 카카오는 아래 SDK.
|
||||||
google_sign_in: ^7.2.0
|
google_sign_in: ^7.2.0
|
||||||
sign_in_with_apple: ^8.1.0
|
sign_in_with_apple: ^8.1.0
|
||||||
|
# 카카오 로그인 — Kakao 공식 SDK. 카카오톡이 있으면 앱으로, 없으면 카카오계정 웹으로 로그인해
|
||||||
|
# 액세스 토큰을 받고 서버(fnAuthKakao)가 Firebase 커스텀 토큰으로 바꿔 준다. Android 에서
|
||||||
|
# Firebase OIDC 웹 흐름이 카카오톡 앱 전환 뒤 「missing initial state」로 깨져서 SDK 를 쓴다.
|
||||||
|
# 최소 OS 하한 영향 없음: Android minSdk 24(앱 29) · iOS 13.0(앱 15.0).
|
||||||
|
kakao_flutter_sdk_user: ^2.0.1
|
||||||
|
# LINE 로그인 — LINE 공식 SDK. LINE 앱이 있으면 앱으로, 없으면 SDK 웹 화면으로 로그인해 ID · 액세스 토큰을
|
||||||
|
# 받고 서버(fnAuthLine)가 Firebase 커스텀 토큰으로 바꿔 준다. iOS 에서 Firebase OIDC 웹 흐름이 첫 시도에
|
||||||
|
# 「missing initial state」로 깨져서 SDK 를 쓴다. 3.x 는 AGP 9 · Dart 3.12 전제라 2.x 를 쓴다.
|
||||||
|
# 최소 OS 하한 영향 없음: Android minSdk 24(앱 29) · iOS 13.0(앱 15.0).
|
||||||
|
flutter_line_sdk: ^2.7.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@@ -49,6 +59,12 @@ dev_dependencies:
|
|||||||
# 재생성: dart run flutter_launcher_icons
|
# 재생성: dart run flutter_launcher_icons
|
||||||
flutter_launcher_icons: ^0.14.4
|
flutter_launcher_icons: ^0.14.4
|
||||||
|
|
||||||
|
# 카카오 SDK(encrypt → pointycastle 3)와 firebase_auth_mocks 0.15(dart_jsonwebtoken 3 →
|
||||||
|
# pointycastle 4)가 갈린다. 테스트 전용인 쪽을 내린다 — 앱에 실리는 암호 라이브러리는 그대로 둔다.
|
||||||
|
# 둘 중 하나가 pointycastle 범위를 맞추면 지운다.
|
||||||
|
dependency_overrides:
|
||||||
|
dart_jsonwebtoken: ^2.17.0
|
||||||
|
|
||||||
# 앱 아이콘 — 2026-08-13 수령한 공식 `fanuzu logo only.png`의 중앙 1:1 영역을
|
# 앱 아이콘 — 2026-08-13 수령한 공식 `fanuzu logo only.png`의 중앙 1:1 영역을
|
||||||
# 픽셀 변경 없이 사용한다. `assets/brand/` 두 원본은 같은 공식 마크다.
|
# 픽셀 변경 없이 사용한다. `assets/brand/` 두 원본은 같은 공식 마크다.
|
||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
|
|||||||
@@ -314,10 +314,8 @@ Future<void> _enterShell(WidgetTester tester) async {
|
|||||||
/// `design/craft-allowlist.yaml` 의 `APP_TABS` 를 읽어 온다.
|
/// `design/craft-allowlist.yaml` 의 `APP_TABS` 를 읽어 온다.
|
||||||
/// 앱 코드가 계약 표기에서 벗어나면 여기서 깨진다.
|
/// 앱 코드가 계약 표기에서 벗어나면 여기서 깨진다.
|
||||||
///
|
///
|
||||||
/// 계약 정본은 상위 저장소(p-fancake)에 있고 이 저장소(p-fanuzu)는 분리돼 있어
|
/// 계약 파일은 저장소 루트 `design/` 에 있고, `app/design/` 에 같은 사본을 둔다.
|
||||||
/// 상대 경로가 체크아웃 위치를 탄다. 그래서 저장소 안에 사본을 두고
|
/// **FANUZU_DESIGN_DIR → app/design/ → 저장소 루트 design/** 순으로 본다.
|
||||||
/// **FANUZU_DESIGN_DIR → 저장소 내 design/ → 형제 경로** 순으로 본다.
|
|
||||||
/// 사본이 있으므로 git worktree 에서도 환경 변수 없이 통과한다.
|
|
||||||
///
|
///
|
||||||
/// 하나도 없으면 **통과가 아니라 실패**로 끝낸다 — 못 읽은 것을 「맞다」로 세면
|
/// 하나도 없으면 **통과가 아니라 실패**로 끝낸다 — 못 읽은 것을 「맞다」로 세면
|
||||||
/// 계약 대조가 조용히 사라진다.
|
/// 계약 대조가 조용히 사라진다.
|
||||||
@@ -327,7 +325,6 @@ List<String> _contractTabs() {
|
|||||||
'$dir/craft-allowlist.yaml',
|
'$dir/craft-allowlist.yaml',
|
||||||
'design/craft-allowlist.yaml',
|
'design/craft-allowlist.yaml',
|
||||||
'../design/craft-allowlist.yaml',
|
'../design/craft-allowlist.yaml',
|
||||||
'../p-fancake/design/craft-allowlist.yaml',
|
|
||||||
];
|
];
|
||||||
final file = candidates
|
final file = candidates
|
||||||
.map(File.new)
|
.map(File.new)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'package:fancake/features/auth/auth.dart';
|
||||||
|
import 'package:fancake/features/auth/login_screen.dart';
|
||||||
import 'package:fancake/features/auth/social_providers.dart';
|
import 'package:fancake/features/auth/social_providers.dart';
|
||||||
import 'package:firebase_auth/firebase_auth.dart' as fb;
|
import 'package:firebase_auth/firebase_auth.dart' as fb;
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
@@ -61,7 +63,10 @@ void main() {
|
|||||||
test('에러 카탈로그 ID 는 경로와 무관하게 E-AUTH-08 이다', () {
|
test('에러 카탈로그 ID 는 경로와 무관하게 E-AUTH-08 이다', () {
|
||||||
// 문구만 갈랐다. 카탈로그를 손대면 Admin·QA 의 코드 대조가 어긋난다.
|
// 문구만 갈랐다. 카탈로그를 손대면 Admin·QA 의 코드 대조가 어긋난다.
|
||||||
expect(
|
expect(
|
||||||
authCatalogCode(_exception('account-exists-with-different-credential')),
|
authCatalogCode(
|
||||||
|
_exception('account-exists-with-different-credential'),
|
||||||
|
flow: AuthFlow.signIn,
|
||||||
|
),
|
||||||
'E-AUTH-08',
|
'E-AUTH-08',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -85,13 +90,54 @@ void main() {
|
|||||||
// 「등록되지 않은 이메일」과 「비밀번호 오류」가 같은 문구로 나가야 한다.
|
// 「등록되지 않은 이메일」과 「비밀번호 오류」가 같은 문구로 나가야 한다.
|
||||||
for (final code in ['user-not-found', 'wrong-password']) {
|
for (final code in ['user-not-found', 'wrong-password']) {
|
||||||
expect(
|
expect(
|
||||||
authMessage(_exception(code), flow: AuthFlow.signIn),
|
authMessage(_exception(code), flow: AuthFlow.email),
|
||||||
'이메일 또는 비밀번호를 확인해 주세요.',
|
'이메일 또는 비밀번호를 확인해 주세요.',
|
||||||
reason: code,
|
reason: code,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('소셜 웹 로그인 실패 · 중단 — 이메일 문구를 쓰지 않는다', () {
|
||||||
|
const webCodes = [
|
||||||
|
'web-context-already-presented',
|
||||||
|
'web-network-request-failed',
|
||||||
|
'web-internal-error',
|
||||||
|
'internal-error',
|
||||||
|
'invalid-credential',
|
||||||
|
];
|
||||||
|
|
||||||
|
test('소셜 로그인 · 연결의 알 수 없는 실패는 E-AUTH-03 · 소셜 문구', () {
|
||||||
|
for (final code in webCodes) {
|
||||||
|
for (final flow in [AuthFlow.signIn, AuthFlow.link]) {
|
||||||
|
expect(authCatalogCode(_exception(code), flow: flow), 'E-AUTH-03', reason: '$code $flow');
|
||||||
|
expect(
|
||||||
|
authMessage(_exception(code), flow: flow),
|
||||||
|
isNot('이메일 또는 비밀번호를 확인해 주세요.'),
|
||||||
|
reason: '$code $flow',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
authMessage(_exception('web-context-already-presented'), flow: AuthFlow.signIn),
|
||||||
|
'로그인이 완료되지 않았습니다. 다시 시도해 주세요.',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
authMessage(_exception('web-network-request-failed'), flow: AuthFlow.signIn),
|
||||||
|
'네트워크 연결을 확인한 뒤 다시 시도해 주세요.',
|
||||||
|
);
|
||||||
|
expect(authMessage(_exception('internal-error'), flow: AuthFlow.signIn), '소셜 로그인에 실패했습니다.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('이메일 폼만 E-AUTH-01 · 「이메일 또는 비밀번호」', () {
|
||||||
|
expect(authCatalogCode(_exception('internal-error'), flow: AuthFlow.email), 'E-AUTH-01');
|
||||||
|
expect(authMessage(_exception('internal-error'), flow: AuthFlow.email), '이메일 또는 비밀번호를 확인해 주세요.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('취소는 성공이 아니고 다시 시도하라고 안내한다', () {
|
||||||
|
expect(authNotice(const AuthCancelled()), '로그인이 완료되지 않았습니다. 다시 시도해 주세요.');
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `FirebaseAuthException` 의 생성자가 `@protected` 라 밖에서 직접 부르면
|
/// `FirebaseAuthException` 의 생성자가 `@protected` 라 밖에서 직접 부르면
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import 'package:cloud_functions/cloud_functions.dart';
|
||||||
|
import 'package:fancake/core/firebase/firebase_boot.dart';
|
||||||
|
import 'package:fancake/features/auth/auth.dart';
|
||||||
|
import 'package:fancake/features/auth/firebase_account_linker.dart';
|
||||||
|
import 'package:fancake/features/auth/social_providers.dart';
|
||||||
|
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:kakao_flutter_sdk_user/kakao_flutter_sdk_user.dart'
|
||||||
|
as kakao_sdk;
|
||||||
|
|
||||||
|
/// 카카오 — SDK 로그인(주입) → 서버 교환(주입) → signInWithCustomToken 계약.
|
||||||
|
/// 토큰 검증·기존 oidc.kakao 사용자 매핑은 서버 테스트(functions/test/auth_kakao.test.ts)가 본다.
|
||||||
|
void main() {
|
||||||
|
setUp(() => FirebaseBoot.debugSetMode(FirebaseMode.live, reason: '카카오 테스트'));
|
||||||
|
tearDown(
|
||||||
|
() => FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: '카카오 테스트 끝'),
|
||||||
|
);
|
||||||
|
|
||||||
|
final sent = <Map<String, dynamic>>[];
|
||||||
|
setUp(sent.clear);
|
||||||
|
|
||||||
|
KakaoSignInProvider kakao(
|
||||||
|
MockFirebaseAuth auth, {
|
||||||
|
Future<String> Function()? token,
|
||||||
|
Object? serverError,
|
||||||
|
String appKey = 'test-key',
|
||||||
|
}) => KakaoSignInProvider(
|
||||||
|
accessToken: token ?? () async => 'kakao-access',
|
||||||
|
exchange: (payload) async {
|
||||||
|
sent.add(payload);
|
||||||
|
if (serverError != null) throw serverError;
|
||||||
|
return {
|
||||||
|
'token': 'custom-token',
|
||||||
|
if (payload['link'] == true) 'linked': true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
firebaseAuth: auth,
|
||||||
|
nativeAppKey: appKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
test('성공 — 카카오 토큰을 서버에 넘기고 커스텀 토큰으로 로그인한다', () async {
|
||||||
|
final auth = MockFirebaseAuth(mockUser: MockUser(uid: 'kakao:1'));
|
||||||
|
final outcome = await kakao(auth).signIn();
|
||||||
|
expect(sent.single, {'accessToken': 'kakao-access'});
|
||||||
|
expect((outcome as AuthSucceeded).account.uid, 'kakao:1');
|
||||||
|
expect(outcome.account.provider, AuthProviderKind.kakao);
|
||||||
|
expect(auth.currentUser?.uid, 'kakao:1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('앱 키가 없으면 SDK·서버를 부르지 않고 미설정', () async {
|
||||||
|
final outcome = await kakao(MockFirebaseAuth(), appKey: '').signIn();
|
||||||
|
expect(outcome, isA<AuthNotConfigured>());
|
||||||
|
expect(sent, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('카카오 로그인 취소(CANCELED)는 취소 · 서버를 부르지 않는다', () async {
|
||||||
|
final outcome = await kakao(
|
||||||
|
MockFirebaseAuth(),
|
||||||
|
token: () async => throw PlatformException(code: 'CANCELED'),
|
||||||
|
).signIn();
|
||||||
|
expect(outcome, isA<AuthCancelled>());
|
||||||
|
expect(sent, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('카카오 인가 · 토큰 단계 실패는 로그인 실패(E-AUTH-03) · 서버를 부르지 않는다', () async {
|
||||||
|
for (final error in [
|
||||||
|
kakao_sdk.KakaoAuthException(
|
||||||
|
kakao_sdk.AuthErrorCause.misconfigured,
|
||||||
|
'KOE',
|
||||||
|
),
|
||||||
|
kakao_sdk.KakaoClientException(kakao_sdk.ClientErrorCause.unknown, 'sdk'),
|
||||||
|
]) {
|
||||||
|
final outcome = await kakao(
|
||||||
|
MockFirebaseAuth(),
|
||||||
|
token: () async => throw error,
|
||||||
|
).signIn();
|
||||||
|
expect((outcome as AuthFailed).code, 'E-AUTH-03', reason: '$error');
|
||||||
|
}
|
||||||
|
expect(sent, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('서버 오류는 카탈로그 코드로 · 로그인하지 않는다', () async {
|
||||||
|
final auth = MockFirebaseAuth();
|
||||||
|
final outcome = await kakao(
|
||||||
|
auth,
|
||||||
|
serverError: FirebaseFunctionsException(
|
||||||
|
code: 'permission-denied',
|
||||||
|
message: '카카오 인증을 확인하지 못했습니다.',
|
||||||
|
details: {'code': 'E-AUTH-03'},
|
||||||
|
),
|
||||||
|
).signIn();
|
||||||
|
expect((outcome as AuthFailed).code, 'E-AUTH-03');
|
||||||
|
expect(auth.currentUser, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('SNS 연동', () {
|
||||||
|
test('로그인한 사용자에 link: true 로 붙인다', () async {
|
||||||
|
final auth = MockFirebaseAuth(
|
||||||
|
signedIn: true,
|
||||||
|
mockUser: MockUser(uid: 'u-1'),
|
||||||
|
);
|
||||||
|
final linker = FirebaseAccountLinker(
|
||||||
|
providers: [kakao(auth)],
|
||||||
|
firebaseAuth: auth,
|
||||||
|
);
|
||||||
|
final outcome = await linker.link(AuthProviderKind.kakao);
|
||||||
|
expect(sent.single, {'accessToken': 'kakao-access', 'link': true});
|
||||||
|
expect((outcome as AuthSucceeded).account.uid, 'u-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('다른 계정에 붙은 카카오면 E-AUTH-08 · 세션 만료면 다시 로그인 안내', () async {
|
||||||
|
final auth = MockFirebaseAuth(
|
||||||
|
signedIn: true,
|
||||||
|
mockUser: MockUser(uid: 'u-1'),
|
||||||
|
);
|
||||||
|
final inUse = await kakao(
|
||||||
|
auth,
|
||||||
|
serverError: FirebaseFunctionsException(
|
||||||
|
code: 'already-exists',
|
||||||
|
message: 'x',
|
||||||
|
details: {'code': 'E-AUTH-08'},
|
||||||
|
),
|
||||||
|
).link(auth.currentUser!);
|
||||||
|
expect((inUse as AuthFailed).code, 'E-AUTH-08');
|
||||||
|
expect(inUse.message, '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.');
|
||||||
|
|
||||||
|
final expired = await kakao(
|
||||||
|
auth,
|
||||||
|
serverError: FirebaseFunctionsException(
|
||||||
|
code: 'permission-denied',
|
||||||
|
message: 'x',
|
||||||
|
details: {'code': 'E-AUTH-03', 'reason': 'session'},
|
||||||
|
),
|
||||||
|
).link(auth.currentUser!);
|
||||||
|
expect((expired as AuthFailed).message, '보안을 위해 다시 로그인한 뒤 시도해 주세요.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import 'package:cloud_functions/cloud_functions.dart';
|
||||||
|
import 'package:fancake/core/firebase/firebase_boot.dart';
|
||||||
|
import 'package:fancake/features/auth/auth.dart';
|
||||||
|
import 'package:fancake/features/auth/firebase_account_linker.dart';
|
||||||
|
import 'package:fancake/features/auth/social_providers.dart';
|
||||||
|
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
/// LINE — SDK 로그인(주입) → 서버 교환(주입) → signInWithCustomToken 계약.
|
||||||
|
/// 토큰 검증·기존 oidc.line 사용자 매핑은 서버 테스트(functions/test/auth_line.test.ts)가 본다.
|
||||||
|
void main() {
|
||||||
|
setUp(() => FirebaseBoot.debugSetMode(FirebaseMode.live, reason: 'LINE 테스트'));
|
||||||
|
tearDown(
|
||||||
|
() => FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: 'LINE 테스트 끝'),
|
||||||
|
);
|
||||||
|
|
||||||
|
final sent = <Map<String, dynamic>>[];
|
||||||
|
setUp(sent.clear);
|
||||||
|
|
||||||
|
LineSignInProvider line(
|
||||||
|
MockFirebaseAuth auth, {
|
||||||
|
Future<LineTokens> Function()? login,
|
||||||
|
Object? serverError,
|
||||||
|
String channelId = '2000000001',
|
||||||
|
}) => LineSignInProvider(
|
||||||
|
login:
|
||||||
|
login ?? () async => (idToken: 'line-id', accessToken: 'line-access'),
|
||||||
|
exchange: (payload) async {
|
||||||
|
sent.add(payload);
|
||||||
|
if (serverError != null) throw serverError;
|
||||||
|
return {
|
||||||
|
'token': 'custom-token',
|
||||||
|
if (payload['link'] == true) 'linked': true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
firebaseAuth: auth,
|
||||||
|
channelId: channelId,
|
||||||
|
);
|
||||||
|
|
||||||
|
test('성공 — LINE 토큰을 서버에 넘기고 커스텀 토큰으로 로그인한다', () async {
|
||||||
|
final auth = MockFirebaseAuth(mockUser: MockUser(uid: 'line:U1'));
|
||||||
|
final outcome = await line(auth).signIn();
|
||||||
|
expect(sent.single, {'idToken': 'line-id', 'accessToken': 'line-access'});
|
||||||
|
expect((outcome as AuthSucceeded).account.uid, 'line:U1');
|
||||||
|
expect(outcome.account.provider, AuthProviderKind.line);
|
||||||
|
expect(auth.currentUser?.uid, 'line:U1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ID 토큰이 없으면(openid 미동의) 액세스 토큰만 넘긴다', () async {
|
||||||
|
await line(
|
||||||
|
MockFirebaseAuth(),
|
||||||
|
login: () async => (idToken: null, accessToken: 'line-access'),
|
||||||
|
).signIn();
|
||||||
|
expect(sent.single, {'accessToken': 'line-access'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('채널 ID 가 없으면 SDK·서버를 부르지 않고 미설정', () async {
|
||||||
|
final outcome = await line(MockFirebaseAuth(), channelId: '').signIn();
|
||||||
|
expect(outcome, isA<AuthNotConfigured>());
|
||||||
|
expect(sent, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'LINE 로그인 취소(Android CANCEL · iOS 3003)는 취소 · 서버를 부르지 않는다 · 다시 시도하면 된다',
|
||||||
|
() async {
|
||||||
|
for (final code in ['CANCEL', '3003']) {
|
||||||
|
final outcome = await line(
|
||||||
|
MockFirebaseAuth(),
|
||||||
|
login: () async => throw PlatformException(code: code),
|
||||||
|
).signIn();
|
||||||
|
expect(outcome, isA<AuthCancelled>(), reason: code);
|
||||||
|
}
|
||||||
|
expect(sent, isEmpty);
|
||||||
|
final retry = await line(
|
||||||
|
MockFirebaseAuth(mockUser: MockUser(uid: 'line:U1')),
|
||||||
|
).signIn();
|
||||||
|
expect(retry, isA<AuthSucceeded>());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('SDK 실패는 소셜 문구 E-AUTH-03', () async {
|
||||||
|
final outcome = await line(
|
||||||
|
MockFirebaseAuth(),
|
||||||
|
login: () async =>
|
||||||
|
throw PlatformException(code: 'AUTHENTICATION_AGENT_ERROR'),
|
||||||
|
).signIn();
|
||||||
|
expect((outcome as AuthFailed).code, 'E-AUTH-03');
|
||||||
|
expect(outcome.message, '소셜 로그인에 실패했습니다.');
|
||||||
|
expect(sent, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('서버 오류는 카탈로그 코드로 · 로그인하지 않는다', () async {
|
||||||
|
final auth = MockFirebaseAuth();
|
||||||
|
final outcome = await line(
|
||||||
|
auth,
|
||||||
|
serverError: FirebaseFunctionsException(
|
||||||
|
code: 'permission-denied',
|
||||||
|
message: 'LINE 인증을 확인하지 못했습니다.',
|
||||||
|
details: {'code': 'E-AUTH-03'},
|
||||||
|
),
|
||||||
|
).signIn();
|
||||||
|
expect((outcome as AuthFailed).code, 'E-AUTH-03');
|
||||||
|
expect(outcome.message, '소셜 로그인에 실패했습니다.');
|
||||||
|
expect(auth.currentUser, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('SNS 연동', () {
|
||||||
|
test('로그인한 사용자에 link: true 로 붙인다', () async {
|
||||||
|
final auth = MockFirebaseAuth(
|
||||||
|
signedIn: true,
|
||||||
|
mockUser: MockUser(uid: 'u-1'),
|
||||||
|
);
|
||||||
|
final linker = FirebaseAccountLinker(
|
||||||
|
providers: [line(auth)],
|
||||||
|
firebaseAuth: auth,
|
||||||
|
);
|
||||||
|
final outcome = await linker.link(AuthProviderKind.line);
|
||||||
|
expect(sent.single, {
|
||||||
|
'idToken': 'line-id',
|
||||||
|
'accessToken': 'line-access',
|
||||||
|
'link': true,
|
||||||
|
});
|
||||||
|
expect((outcome as AuthSucceeded).account.uid, 'u-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('연결 중 취소는 취소 · 서버를 부르지 않는다', () async {
|
||||||
|
final auth = MockFirebaseAuth(
|
||||||
|
signedIn: true,
|
||||||
|
mockUser: MockUser(uid: 'u-1'),
|
||||||
|
);
|
||||||
|
final outcome = await line(
|
||||||
|
auth,
|
||||||
|
login: () async => throw PlatformException(code: 'CANCEL'),
|
||||||
|
).link(auth.currentUser!);
|
||||||
|
expect(outcome, isA<AuthCancelled>());
|
||||||
|
expect(sent, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'다른 계정에 붙은 LINE 이면 E-AUTH-08 · 이미 다른 LINE 이면 E-AUTH-09 · 세션 만료면 다시 로그인 안내',
|
||||||
|
() async {
|
||||||
|
final auth = MockFirebaseAuth(
|
||||||
|
signedIn: true,
|
||||||
|
mockUser: MockUser(uid: 'u-1'),
|
||||||
|
);
|
||||||
|
Future<AuthOutcome> failWith(Map<String, String> details) => line(
|
||||||
|
auth,
|
||||||
|
serverError: FirebaseFunctionsException(
|
||||||
|
code: 'x',
|
||||||
|
message: 'x',
|
||||||
|
details: details,
|
||||||
|
),
|
||||||
|
).link(auth.currentUser!);
|
||||||
|
|
||||||
|
final inUse = await failWith({'code': 'E-AUTH-08'});
|
||||||
|
expect((inUse as AuthFailed).code, 'E-AUTH-08');
|
||||||
|
expect(inUse.message, '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.');
|
||||||
|
expect(
|
||||||
|
((await failWith({'code': 'E-AUTH-09'})) as AuthFailed).code,
|
||||||
|
'E-AUTH-09',
|
||||||
|
);
|
||||||
|
final expired = await failWith({
|
||||||
|
'code': 'E-AUTH-03',
|
||||||
|
'reason': 'session',
|
||||||
|
});
|
||||||
|
expect((expired as AuthFailed).message, '보안을 위해 다시 로그인한 뒤 시도해 주세요.');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:fancake/core/firebase/firebase_boot.dart';
|
||||||
|
import 'package:fancake/features/auth/auth.dart';
|
||||||
|
import 'package:fancake/features/auth/auth_repository.dart';
|
||||||
|
import 'package:fancake/features/auth/firebase_account_linker.dart';
|
||||||
|
import 'package:fancake/features/auth/social_providers.dart';
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart' as fb;
|
||||||
|
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
/// Firebase 웹 흐름(OIDC) 제공자 — 지금은 Android Apple 이 쓴다(LINE 을 예시 제공자로 둔다). 실 로그인은 못 하고,
|
||||||
|
/// Firebase 호출 전후 계약(결과 매핑·취소·연결)만 확인한다.
|
||||||
|
void main() {
|
||||||
|
// Android 에서 signIn 이 앱 생명주기를 듣는다(untilAbandoned).
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
setUp(() => FirebaseBoot.debugSetMode(FirebaseMode.live, reason: 'OIDC 테스트'));
|
||||||
|
tearDown(
|
||||||
|
() => FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: 'OIDC 테스트 끝'),
|
||||||
|
);
|
||||||
|
|
||||||
|
OidcSignInProvider line(fb.FirebaseAuth auth) => OidcSignInProvider(
|
||||||
|
AuthProviderKind.line,
|
||||||
|
providerId: 'oidc.line',
|
||||||
|
firebaseAuth: auth,
|
||||||
|
);
|
||||||
|
|
||||||
|
test('기본 목록: 카카오 · LINE 은 SDK + 서버 커스텀 토큰', () {
|
||||||
|
final providers = {
|
||||||
|
for (final p in AuthRepository().providers) p.provider: p,
|
||||||
|
};
|
||||||
|
expect(providers[AuthProviderKind.line], isA<LineSignInProvider>());
|
||||||
|
expect(providers[AuthProviderKind.kakao], isA<KakaoSignInProvider>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Apple: Android 는 Firebase 웹 흐름(apple.com) · iOS 는 Apple SDK', () {
|
||||||
|
final android = AuthRepository().providers.firstWhere(
|
||||||
|
(p) => p.provider == AuthProviderKind.apple,
|
||||||
|
);
|
||||||
|
expect(android, isA<OidcSignInProvider>());
|
||||||
|
expect(
|
||||||
|
(android as OidcSignInProvider).authProvider.providerId,
|
||||||
|
'apple.com',
|
||||||
|
);
|
||||||
|
expect(android.missingConfig, isEmpty);
|
||||||
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||||
|
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||||
|
final ios = AuthRepository().providers.firstWhere(
|
||||||
|
(p) => p.provider == AuthProviderKind.apple,
|
||||||
|
);
|
||||||
|
expect(ios, isA<AppleSignInProvider>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Firebase 설정이 없으면 SDK 를 부르지 않고 미설정으로 닫는다', () async {
|
||||||
|
FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: '설정 없음');
|
||||||
|
final outcome = await line(_ThrowingAuth('should-not-call')).signIn();
|
||||||
|
expect(outcome, isA<AuthNotConfigured>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('성공하면 LINE 계정으로 로그인된다', () async {
|
||||||
|
final auth = MockFirebaseAuth(mockUser: MockUser(uid: 'k-1'));
|
||||||
|
final outcome = await line(auth).signIn();
|
||||||
|
expect(outcome, isA<AuthSucceeded>());
|
||||||
|
expect((outcome as AuthSucceeded).account.provider, AuthProviderKind.line);
|
||||||
|
expect(outcome.account.uid, 'k-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('인가 화면을 닫으면 Android·iOS 철자 모두 취소로 본다', () async {
|
||||||
|
for (final code in ['web-context-canceled', 'web-context-cancelled']) {
|
||||||
|
final outcome = await line(_ThrowingAuth(code)).signIn();
|
||||||
|
expect(outcome, isA<AuthCancelled>(), reason: code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('콘솔에서 제공자를 안 켰으면 E-AUTH-11', () async {
|
||||||
|
final outcome = await line(_ThrowingAuth('operation-not-allowed')).signIn();
|
||||||
|
expect((outcome as AuthFailed).code, 'E-AUTH-11');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Android: 앱으로 돌아왔는데 결과가 없으면 취소로 풀린다', (tester) async {
|
||||||
|
late AuthOutcome outcome;
|
||||||
|
line(_HangingAuth()).signIn().then((o) => outcome = o);
|
||||||
|
void go(List<AppLifecycleState> states) =>
|
||||||
|
states.forEach(tester.binding.handleAppLifecycleStateChanged);
|
||||||
|
const inactive = AppLifecycleState.inactive;
|
||||||
|
const hidden = AppLifecycleState.hidden;
|
||||||
|
go([inactive, hidden, AppLifecycleState.paused]); // Custom Tab 으로 나감
|
||||||
|
await tester.pump(const Duration(minutes: 1)); // 브라우저에 오래 있어도 그대로
|
||||||
|
go([hidden, inactive, AppLifecycleState.resumed]); // 앱으로 돌아옴
|
||||||
|
await tester.pump(const Duration(seconds: 9));
|
||||||
|
expect(outcome, isA<AuthCancelled>());
|
||||||
|
});
|
||||||
|
|
||||||
|
group('웹 흐름 중단 뒤 재시도', () {
|
||||||
|
late List<String> cleared;
|
||||||
|
setUp(() {
|
||||||
|
cleared = [];
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(const MethodChannel('kr.co.fanuzu.app/auth'), (call) async {
|
||||||
|
cleared.add(call.method);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
tearDown(() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(const MethodChannel('kr.co.fanuzu.app/auth'), null));
|
||||||
|
|
||||||
|
test('앞선 흐름이 남아 막히면(web-context-already-presented) 정리하고 한 번 다시 열어 로그인된다', () async {
|
||||||
|
final auth = _StaleThenOkAuth();
|
||||||
|
final outcome = await line(auth).signIn();
|
||||||
|
expect(outcome, isA<AuthSucceeded>());
|
||||||
|
expect(auth.calls, 2);
|
||||||
|
expect(cleared, ['cancelPendingWebSignIn']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('정리 뒤에도 막히면 소셜 문구로 실패 — 「이메일 또는 비밀번호」가 아니다', () async {
|
||||||
|
final outcome = await line(_ThrowingAuth('web-context-already-presented')).signIn();
|
||||||
|
expect((outcome as AuthFailed).code, 'E-AUTH-03');
|
||||||
|
expect(outcome.message, '로그인이 완료되지 않았습니다. 다시 시도해 주세요.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('망 오류도 소셜 경로 문구', () async {
|
||||||
|
final outcome = await line(_ThrowingAuth('web-network-request-failed')).signIn();
|
||||||
|
expect((outcome as AuthFailed).code, 'E-AUTH-03');
|
||||||
|
expect(outcome.message, '네트워크 연결을 확인한 뒤 다시 시도해 주세요.');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Android: 결과 없이 돌아와 취소로 풀릴 때 Firebase 대기도 정리한다', (tester) async {
|
||||||
|
late AuthOutcome outcome;
|
||||||
|
line(_HangingAuth()).signIn().then((o) => outcome = o);
|
||||||
|
for (final s in [AppLifecycleState.inactive, AppLifecycleState.hidden, AppLifecycleState.paused,
|
||||||
|
AppLifecycleState.hidden, AppLifecycleState.inactive, AppLifecycleState.resumed]) {
|
||||||
|
tester.binding.handleAppLifecycleStateChanged(s);
|
||||||
|
}
|
||||||
|
await tester.pump(const Duration(seconds: 9));
|
||||||
|
expect(outcome, isA<AuthCancelled>());
|
||||||
|
expect(cleared, ['cancelPendingWebSignIn']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('계정 연결', () {
|
||||||
|
test('같은 UID 에 oidc.line 을 붙인다', () async {
|
||||||
|
final user = _LinkingUser(linkedEmail: 'fan@fanuzu.co.kr');
|
||||||
|
final auth = MockFirebaseAuth(signedIn: true, mockUser: user);
|
||||||
|
final linker = FirebaseAccountLinker(
|
||||||
|
providers: [line(auth)],
|
||||||
|
firebaseAuth: auth,
|
||||||
|
);
|
||||||
|
|
||||||
|
final outcome = await linker.link(AuthProviderKind.line);
|
||||||
|
|
||||||
|
expect(outcome, isA<AuthSucceeded>());
|
||||||
|
expect(linker.linkedAccounts.keys, contains(AuthProviderKind.line));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('이메일이 다르거나 없는 LINE 계정도 붙는다 — 이메일 일치는 소유 확인이 아니다', () async {
|
||||||
|
for (final email in ['someone@else.com', null]) {
|
||||||
|
final user = _LinkingUser(linkedEmail: email);
|
||||||
|
final auth = MockFirebaseAuth(signedIn: true, mockUser: user);
|
||||||
|
final linker = FirebaseAccountLinker(
|
||||||
|
providers: [line(auth)],
|
||||||
|
firebaseAuth: auth,
|
||||||
|
);
|
||||||
|
|
||||||
|
final outcome = await linker.link(AuthProviderKind.line);
|
||||||
|
|
||||||
|
expect(outcome, isA<AuthSucceeded>(), reason: '$email');
|
||||||
|
expect(linker.linkedAccounts.keys, contains(AuthProviderKind.line));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('다른 FANUZU 계정에 붙은 LINE 은 Firebase 거부를 E-AUTH-08 로 · 연결되지 않는다', () async {
|
||||||
|
final user = _LinkingUser(
|
||||||
|
linkedEmail: null,
|
||||||
|
error: fb.FirebaseAuthException(code: 'credential-already-in-use'),
|
||||||
|
);
|
||||||
|
final auth = MockFirebaseAuth(signedIn: true, mockUser: user);
|
||||||
|
final linker = FirebaseAccountLinker(
|
||||||
|
providers: [line(auth)],
|
||||||
|
firebaseAuth: auth,
|
||||||
|
);
|
||||||
|
|
||||||
|
final outcome = await linker.link(AuthProviderKind.line);
|
||||||
|
|
||||||
|
expect((outcome as AuthFailed).code, 'E-AUTH-08');
|
||||||
|
expect(outcome.message, '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.');
|
||||||
|
expect(linker.linkedAccounts.keys, isNot(contains(AuthProviderKind.line)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Google · Apple(자격증명 경로)도 이메일이 달라도 붙는다', () async {
|
||||||
|
final auth = MockFirebaseAuth(
|
||||||
|
signedIn: true,
|
||||||
|
mockUser: MockUser(uid: 'u-1', email: 'fan@fanuzu.co.kr'),
|
||||||
|
);
|
||||||
|
final outcome = await FirebaseAccountLinker(
|
||||||
|
providers: [const _FakeCredential('other@gmail.com')],
|
||||||
|
firebaseAuth: auth,
|
||||||
|
).link(AuthProviderKind.google);
|
||||||
|
expect(outcome, isA<AuthSucceeded>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('세션이 만료됐으면 다시 로그인 안내(E-AUTH-12)', () async {
|
||||||
|
final user = _LinkingUser(
|
||||||
|
linkedEmail: null,
|
||||||
|
error: fb.FirebaseAuthException(code: 'user-token-expired'),
|
||||||
|
);
|
||||||
|
final auth = MockFirebaseAuth(signedIn: true, mockUser: user);
|
||||||
|
final outcome = await FirebaseAccountLinker(
|
||||||
|
providers: [line(auth)],
|
||||||
|
firebaseAuth: auth,
|
||||||
|
).link(AuthProviderKind.line);
|
||||||
|
expect((outcome as AuthFailed).code, 'E-AUTH-12');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 이메일을 함께 주는 자격증명 제공자(Google 흉내).
|
||||||
|
final class _FakeCredential implements SocialAuthProvider, CredentialProvider {
|
||||||
|
const _FakeCredential(this.email);
|
||||||
|
final String email;
|
||||||
|
|
||||||
|
@override
|
||||||
|
AuthProviderKind get provider => AuthProviderKind.google;
|
||||||
|
@override
|
||||||
|
bool get isLive => true;
|
||||||
|
@override
|
||||||
|
List<String> get missingConfig => const [];
|
||||||
|
@override
|
||||||
|
Future<AuthOutcome> signIn() async => const AuthCancelled();
|
||||||
|
@override
|
||||||
|
Future<void> signOut() async {}
|
||||||
|
@override
|
||||||
|
Future<SocialCredential?> obtainCredential() async => (
|
||||||
|
credential: fb.GoogleAuthProvider.credential(idToken: 'id'),
|
||||||
|
email: email,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ThrowingAuth extends MockFirebaseAuth {
|
||||||
|
_ThrowingAuth(this.code);
|
||||||
|
final String code;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<fb.UserCredential> signInWithProvider(fb.AuthProvider provider) =>
|
||||||
|
throw fb.FirebaseAuthException(code: code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 앞선 웹 흐름이 남아 첫 시도가 막히는 Firebase(Android `web-context-already-presented`).
|
||||||
|
class _StaleThenOkAuth extends MockFirebaseAuth {
|
||||||
|
_StaleThenOkAuth() : super(mockUser: MockUser(uid: 'line-1'));
|
||||||
|
var calls = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<fb.UserCredential> signInWithProvider(fb.AuthProvider provider) {
|
||||||
|
calls += 1;
|
||||||
|
if (calls == 1) throw fb.FirebaseAuthException(code: 'web-context-already-presented');
|
||||||
|
return super.signInWithProvider(provider);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 콜백을 잃어 영영 끝나지 않는 Firebase 대기 Task.
|
||||||
|
class _HangingAuth extends MockFirebaseAuth {
|
||||||
|
@override
|
||||||
|
Future<fb.UserCredential> signInWithProvider(fb.AuthProvider provider) =>
|
||||||
|
Completer<fb.UserCredential>().future;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Google 로 가입한 사용자. 연결하면 [linkedEmail] 을 가진 제공자 정보가 붙는다.
|
||||||
|
// ignore: must_be_immutable
|
||||||
|
class _LinkingUser extends MockUser {
|
||||||
|
_LinkingUser({required this.linkedEmail, this.error})
|
||||||
|
: super(
|
||||||
|
uid: 'u-1',
|
||||||
|
email: 'fan@fanuzu.co.kr',
|
||||||
|
providerData: [_info('google.com', 'fan@fanuzu.co.kr')],
|
||||||
|
);
|
||||||
|
|
||||||
|
final String? linkedEmail;
|
||||||
|
final fb.FirebaseAuthException? error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<fb.UserCredential> linkWithProvider(fb.AuthProvider provider) async {
|
||||||
|
if (error case final e?) throw e;
|
||||||
|
final result = await super.linkWithProvider(provider);
|
||||||
|
providerData
|
||||||
|
..removeLast()
|
||||||
|
..add(_info(provider.providerId, linkedEmail));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fb.UserInfo _info(String providerId, String? email) =>
|
||||||
|
// ignore: invalid_use_of_protected_member
|
||||||
|
fb.UserInfo.fromJson({
|
||||||
|
'uid': 'p-$providerId',
|
||||||
|
'email': email,
|
||||||
|
'providerId': providerId,
|
||||||
|
'isAnonymous': false,
|
||||||
|
'isEmailVerified': true,
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:fancake/app.dart';
|
||||||
|
import 'package:fancake/features/auth/auth_repository.dart';
|
||||||
|
|
||||||
|
/// MY 에서 로그아웃하면 로그인 화면으로 돌아간다 — 한 기기에서 로그인 방법을 바꿔 확인할 수 있어야 한다.
|
||||||
|
void main() {
|
||||||
|
testWidgets('MY → 로그아웃 → 확인 → 로그인 화면, 취소면 그대로', (tester) async {
|
||||||
|
final auth = AuthRepository(releaseMode: false)..signInWithMockSession();
|
||||||
|
await tester.pumpWidget(FancakeApp(auth: auth));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('MY'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(OutlinedButton, '로그아웃'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('취소'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(auth.isSignedIn, isTrue);
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(OutlinedButton, '로그아웃'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, '로그아웃'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(auth.isSignedIn, isFalse);
|
||||||
|
expect(find.byType(NavigationBar), findsNothing);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:fancake/app.dart';
|
||||||
|
import 'package:fancake/core/firebase/firebase_boot.dart';
|
||||||
|
import 'package:fancake/features/auth/auth.dart';
|
||||||
|
import 'package:fancake/features/auth/auth_repository.dart';
|
||||||
|
import 'package:fancake/features/auth/login_screen.dart';
|
||||||
|
import 'package:fancake/features/auth/signup_screens.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
/// 가입 약관 — 동의 기록이 없는 계정은 HOME 대신 약관으로 가고, 동의를 기록한 뒤에만 HOME 에 들어간다.
|
||||||
|
/// 소셜 로그인 진행 중에는 화면 전체를 막고 진행 표시를 띄운다.
|
||||||
|
void main() {
|
||||||
|
setUp(() {
|
||||||
|
FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: '가입 약관 위젯 테스트');
|
||||||
|
});
|
||||||
|
|
||||||
|
const newUser = AuthAccount(
|
||||||
|
uid: 'new-user',
|
||||||
|
provider: AuthProviderKind.kakao,
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('로그인 진행 중 로딩 · 중복 입력 막음 → 동의 없는 새 계정은 약관 → 필수 동의 → HOME', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final signIn = Completer<AuthOutcome>();
|
||||||
|
final provider = _Provider(AuthProviderKind.kakao, () => signIn.future);
|
||||||
|
Set<String>? recorded;
|
||||||
|
final auth = AuthRepository(
|
||||||
|
providers: [provider],
|
||||||
|
releaseMode: false,
|
||||||
|
needsTerms: (_) async => recorded == null,
|
||||||
|
recordTerms: (agreed) async => recorded = agreed,
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(FancakeApp(auth: auth));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, '로그인'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final kakao = find.text(providerButtonLabel(AuthProviderKind.kakao));
|
||||||
|
await tester.tap(kakao);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byType(BlockingProgress), findsOneWidget);
|
||||||
|
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||||
|
await tester.tap(kakao, warnIfMissed: false);
|
||||||
|
await tester.pump();
|
||||||
|
expect(provider.calls, 1);
|
||||||
|
|
||||||
|
signIn.complete(const AuthSucceeded(newUser));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(NavigationBar), findsNothing);
|
||||||
|
expect(find.text('이용 약관'), findsOneWidget);
|
||||||
|
expect(find.text('위치기반 서비스 이용약관 (필수)'), findsNothing);
|
||||||
|
final done = find.widgetWithText(FilledButton, '완료');
|
||||||
|
expect(tester.widget<FilledButton>(done).onPressed, isNull);
|
||||||
|
for (final label in [
|
||||||
|
'서비스 이용약관 (필수)',
|
||||||
|
'개인정보 수집·이용 동의 (필수)',
|
||||||
|
'만 14세 이상입니다. (필수)',
|
||||||
|
]) {
|
||||||
|
await tester.scrollUntilVisible(find.text(label), 100);
|
||||||
|
await tester.tap(find.text(label));
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
// 개인정보처리방침은 체크 칸 없는 고지 · 보기 링크.
|
||||||
|
final policy = find.byKey(const ValueKey('privacy-policy-link'));
|
||||||
|
await tester.tap(policy);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('개인정보처리방침'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('확인'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.widget<FilledButton>(done).onPressed, isNotNull);
|
||||||
|
await tester.tap(done);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(recorded, {'service', 'privacy', 'age'});
|
||||||
|
expect(find.byType(NavigationBar), findsOneWidget);
|
||||||
|
expect(find.byType(BlockingProgress), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('개인정보처리방침 고지는 스크롤 없이 하단 버튼 위에 보이고 체크 칸이 없다', (tester) async {
|
||||||
|
// iPhone 11 — 414x896pt · 상단 48 · 하단 34.
|
||||||
|
tester.view.physicalSize = const Size(828, 1792);
|
||||||
|
tester.view.devicePixelRatio = 2;
|
||||||
|
tester.view.padding = const FakeViewPadding(top: 96, bottom: 68);
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(const MaterialApp(home: TermsScreen()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final notice = find.byKey(const ValueKey('privacy-policy-notice'));
|
||||||
|
expect(
|
||||||
|
find.descendant(
|
||||||
|
of: notice,
|
||||||
|
matching: find.text('개인정보 처리 내용은 개인정보처리방침에서 확인할 수 있습니다.'),
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
final link = find.descendant(of: notice, matching: find.text('보기'));
|
||||||
|
expect(link, findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.descendant(of: notice, matching: find.byType(ImageIcon)),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
// 화면 안 · 「이전」 버튼 위.
|
||||||
|
expect(tester.getRect(link).bottom, lessThanOrEqualTo(896 - 34));
|
||||||
|
expect(
|
||||||
|
tester.getRect(notice).bottom,
|
||||||
|
lessThanOrEqualTo(
|
||||||
|
tester.getRect(find.widgetWithText(FilledButton, '이전')).top,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(link);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('개인정보처리방침'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('개인정보처리방침 보기는 약관 문구가 아닌 처리방침 문구를 보여 준다', (tester) async {
|
||||||
|
await tester.pumpWidget(const MaterialApp(home: TermsScreen()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const ValueKey('privacy-policy-link')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final body = tester
|
||||||
|
.widget<Text>(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byType(AlertDialog),
|
||||||
|
matching: find.textContaining('임시 문구'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.data!;
|
||||||
|
expect(body, contains('개인정보처리방침'));
|
||||||
|
expect(body, isNot(contains('서비스 이용약관')));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('이미 동의한 계정은 약관 없이 HOME', (tester) async {
|
||||||
|
final auth = AuthRepository(
|
||||||
|
providers: [
|
||||||
|
_Provider(
|
||||||
|
AuthProviderKind.google,
|
||||||
|
() async => const AuthSucceeded(newUser),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
releaseMode: false,
|
||||||
|
needsTerms: (_) async => false,
|
||||||
|
recordTerms: (_) async => fail('기록하지 않는다'),
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(FancakeApp(auth: auth));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, '로그인'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text(providerButtonLabel(AuthProviderKind.google)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(NavigationBar), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('가입 모드 — 인증 전에 받은 동의를 기록하고 약관을 다시 묻지 않는다', (tester) async {
|
||||||
|
Set<String>? recorded;
|
||||||
|
final auth = AuthRepository(
|
||||||
|
providers: [
|
||||||
|
_Provider(
|
||||||
|
AuthProviderKind.line,
|
||||||
|
() async => const AuthSucceeded(newUser),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
releaseMode: false,
|
||||||
|
needsTerms: (_) async => recorded == null,
|
||||||
|
recordTerms: (agreed) async => recorded = agreed,
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(FancakeApp(auth: auth));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('회원가입'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(
|
||||||
|
find.text(providerButtonLabel(AuthProviderKind.line, signUp: true)),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('전체 동의'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, '완료'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(recorded, {'service', 'privacy', 'age', 'marketing'});
|
||||||
|
expect(find.byType(NavigationBar), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _Provider implements SocialAuthProvider {
|
||||||
|
_Provider(this.provider, this._signIn);
|
||||||
|
|
||||||
|
@override
|
||||||
|
final AuthProviderKind provider;
|
||||||
|
final Future<AuthOutcome> Function() _signIn;
|
||||||
|
int calls = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isLive => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> get missingConfig => const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AuthOutcome> signIn() {
|
||||||
|
calls += 1;
|
||||||
|
return _signIn();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> signOut() async {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
/// iOS 스토어 제출 구성 검사.
|
||||||
|
///
|
||||||
|
/// 여기 있는 것들은 사라져도 앱이 멀쩡히 빌드된다. 문제는 업로드·심사·로그인
|
||||||
|
/// 시점에야 드러나고, 그때는 원인이 코드처럼 보인다. 파일을 문자열로 읽어
|
||||||
|
/// 키가 있는지만 본다 — 값의 해석은 빌드가 한다.
|
||||||
|
|
||||||
|
const _teamId = 'V28G543VK5'; // Fancake Inc.
|
||||||
|
|
||||||
|
String _read(String path) {
|
||||||
|
final file = File(path);
|
||||||
|
expect(file.existsSync(), isTrue, reason: '$path 가 없습니다');
|
||||||
|
return file.readAsStringSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('iOS — 서명', () {
|
||||||
|
test('Xcode 프로젝트와 내보내기 설정이 발주사 팀으로 서명한다', () {
|
||||||
|
final pbx = _read('ios/Runner.xcodeproj/project.pbxproj');
|
||||||
|
final teams = RegExp(r'DEVELOPMENT_TEAM = (\w+);')
|
||||||
|
.allMatches(pbx)
|
||||||
|
.map((m) => m.group(1))
|
||||||
|
.toSet();
|
||||||
|
expect(teams, {_teamId});
|
||||||
|
expect(_read('ios/ExportOptions.plist'), contains(_teamId));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Sign in with Apple Capability 가 서명에 연결되어 있다', () {
|
||||||
|
expect(
|
||||||
|
_read('ios/Runner/Runner.entitlements'),
|
||||||
|
contains('com.apple.developer.applesignin'),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
'CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;'
|
||||||
|
.allMatches(_read('ios/Runner.xcodeproj/project.pbxproj'))
|
||||||
|
.length,
|
||||||
|
3, // Debug · Release · Profile
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('iOS — 제출 선언과 Firebase 설정', () {
|
||||||
|
late final String plist = _read('ios/Runner/Info.plist');
|
||||||
|
|
||||||
|
test('암호화 사용 신고가 있어 업로드가 멈추지 않는다', () {
|
||||||
|
expect(plist, contains('ITSAppUsesNonExemptEncryption'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Google 로그인 콜백 스킴이 설정 파일 값으로 채워진다', () {
|
||||||
|
expect(plist, contains(r'$(GOOGLE_REVERSED_CLIENT_ID)'));
|
||||||
|
for (final c in const ['Debug', 'Release']) {
|
||||||
|
expect(
|
||||||
|
_read('ios/Flutter/$c.xcconfig'),
|
||||||
|
contains('#include? "../config/Google.xcconfig"'),
|
||||||
|
reason: c,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GoogleService-Info.plist 가 앱 번들 리소스로 들어간다', () {
|
||||||
|
// 번들에 없으면 Firebase 가 목 모드로 뜨고 Google 로그인 client ID 도 못 읽는다.
|
||||||
|
expect(
|
||||||
|
_read('ios/Runner.xcodeproj/project.pbxproj'),
|
||||||
|
contains('/* GoogleService-Info.plist in Resources */,'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('설정·서명 자산을 git 이 추적하지 않는다', () {
|
||||||
|
final result = Process.runSync('git', [
|
||||||
|
'ls-files',
|
||||||
|
'--',
|
||||||
|
'ios/Runner/GoogleService-Info.plist',
|
||||||
|
'ios/config/Google.xcconfig',
|
||||||
|
'android/app/google-services.json',
|
||||||
|
'*.p12',
|
||||||
|
'*.p8',
|
||||||
|
'*.mobileprovision',
|
||||||
|
]);
|
||||||
|
expect(result.exitCode, 0, reason: 'git 을 실행하지 못했습니다');
|
||||||
|
expect((result.stdout as String).trim(), isEmpty);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""design/design-tokens.md 의 머신블록(<!-- TOKENS --> 아래 yaml) → lib/core/theme/tokens.dart 생성기.
|
"""design/design-tokens.md 의 머신블록(<!-- TOKENS --> 아래 yaml) → lib/core/theme/tokens.dart 생성기.
|
||||||
|
|
||||||
- SoT 는 design/design-tokens.md 하나다. tokens.dart 를 손으로 고치면 시안(시안/assets/ds.css)과 어긋난다.
|
- SoT 는 design/design-tokens.md 하나다. tokens.dart 를 손으로 고치면 토큰 문서와 어긋난다.
|
||||||
- 재실행 가능(멱등). 실행: python3 tool/gen_tokens.py (앱 루트 app/ 에서)
|
- 재실행 가능(멱등). 실행: python3 tool/gen_tokens.py (앱 루트 app/ 에서)
|
||||||
- 부수 기능: design-tokens.md 의 contrast_checks 쌍을 WCAG 로 재계산해서 표로 찍고,
|
- 부수 기능: design-tokens.md 의 contrast_checks 쌍을 WCAG 로 재계산해서 표로 찍고,
|
||||||
AA(4.5:1) 미달이 하나라도 있으면 exit 1. 테마가 대비를 깎았는지 여기서 걸린다.
|
AA(4.5:1) 미달이 하나라도 있으면 exit 1. 테마가 대비를 깎았는지 여기서 걸린다.
|
||||||
|
|||||||
Executable
+58
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""GoogleService-Info.plist 의 REVERSED_CLIENT_ID 를 Xcode 빌드 설정에 넣는다.
|
||||||
|
|
||||||
|
**왜 스크립트인가.** Google 로그인 콜백 스킴은 Firebase 프로젝트마다 다르고,
|
||||||
|
Info.plist 에 손으로 옮기면 오타 한 글자에 로그인이 **조용히** 실패한다.
|
||||||
|
오류도 뜨지 않고 콜백만 돌아오지 않아 원인을 찾는 데 한나절이 든다.
|
||||||
|
|
||||||
|
설정 파일이 투입될 때 한 번 돌리면 `ios/config/Google.xcconfig` 가 만들어지고,
|
||||||
|
Info.plist 의 `$(GOOGLE_REVERSED_CLIENT_ID)` 가 그 값으로 치환된다.
|
||||||
|
|
||||||
|
실행: python3 tool/ios/inject_url_scheme.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import plistlib
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
SOURCE = ROOT / "ios/Runner/GoogleService-Info.plist"
|
||||||
|
OUT = ROOT / "ios/config/Google.xcconfig"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if not SOURCE.exists():
|
||||||
|
print(
|
||||||
|
f"설정 파일이 아직 없습니다: {SOURCE.relative_to(ROOT)}\n"
|
||||||
|
"Firebase 콘솔에서 iOS 앱 설정 파일을 받아 그 자리에 두십시오.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
with SOURCE.open("rb") as f:
|
||||||
|
data = plistlib.load(f)
|
||||||
|
|
||||||
|
reversed_id = data.get("REVERSED_CLIENT_ID")
|
||||||
|
if not reversed_id:
|
||||||
|
# Firebase 콘솔에서 Google 로그인을 켜지 않으면 이 키가 빠진 채로 나온다.
|
||||||
|
print(
|
||||||
|
"REVERSED_CLIENT_ID 가 없습니다. Firebase 콘솔에서 "
|
||||||
|
"Authentication → Google 제공자를 켠 뒤 설정 파일을 다시 받으십시오.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
OUT.write_text(
|
||||||
|
"// 자동 생성 — tool/ios/inject_url_scheme.py\n"
|
||||||
|
"// GoogleService-Info.plist 가 바뀌면 다시 돌리십시오.\n"
|
||||||
|
f"GOOGLE_REVERSED_CLIENT_ID = {reversed_id}\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
print(f"기록: {OUT.relative_to(ROOT)}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
# craft-allowlist.yaml — 크래프트 체커의 "분류 결정"을 적는 곳.
|
# craft-allowlist.yaml — 크래프트 체커의 "분류 결정"을 적는 곳.
|
||||||
# 경고를 끄는 곳이 아니다. 각 항목에 근거(reason)가 없으면 킷이 읽지 않는다.
|
# 경고를 끄는 곳이 아니다. 각 항목에 근거(reason)가 없으면 킷이 읽지 않는다.
|
||||||
# 계약: 하네스/디자인/resources/codex/check_craft_render.py C2
|
|
||||||
|
|
||||||
CAPS_OK:
|
CAPS_OK:
|
||||||
# ── 5탭 라벨 — 견적서 v2.1 「5탭 네비게이션」으로 고정된 화면 이름이다.
|
# ── 5탭 라벨 — 견적서 v2.1 「5탭 네비게이션」으로 고정된 화면 이름이다.
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
## 로고 결정
|
## 로고 결정
|
||||||
- 발주사 공식 자산 `고객사 준비 데모/LOGO/fanuzu logo only.png`를 수령했고, PNG의 알파 채널도 확인했다.
|
- 발주사 공식 자산 `고객사 준비 데모/LOGO/fanuzu logo only.png`를 수령했고, PNG의 알파 채널도 확인했다.
|
||||||
- 시안은 이 공식 심볼을 `시안/assets/logo.png`에 반영했다. 공식 앱 아이콘 원천도 같은 심볼이며
|
- 공식 앱 아이콘 원천도 같은 심볼이며
|
||||||
`app/assets/brand/app-icon.png`와 `app/assets/brand/app-icon-foreground.png`에 반영돼 있다.
|
`app/assets/brand/app-icon.png`와 `app/assets/brand/app-icon-foreground.png`에 반영돼 있다.
|
||||||
- 따라서 현재 로고는 AI 생성 제안본이나 임시 Fancake 브랜드가 아니다. 별도 제안 로고 승인 대기도 없다.
|
- 따라서 현재 로고는 AI 생성 제안본이나 임시 Fancake 브랜드가 아니다. 별도 제안 로고 승인 대기도 없다.
|
||||||
- **서비스명은 `FANUZU`, 국문 병행 표기는 `팬우주`로 확정됐다.** 발주사 공식 검토 회신서의
|
- **서비스명은 `FANUZU`, 국문 병행 표기는 `팬우주`로 확정됐다.** 발주사 공식 검토 회신서의
|
||||||
@@ -173,7 +173,7 @@ font:
|
|||||||
font-direction: "geometric"
|
font-direction: "geometric"
|
||||||
font-source: "42dot Sans = Google Fonts OFL 가변 ttf · ABeeZee = Google Fonts OFL (app/assets/fonts/, 2026-09-02)"
|
font-source: "42dot Sans = Google Fonts OFL 가변 ttf · ABeeZee = Google Fonts OFL (app/assets/fonts/, 2026-09-02)"
|
||||||
type:
|
type:
|
||||||
# --- 발주사 Figma 계약 시점 사본 실측 (2026-09-26, _작업/design-match/foundation.json · 앱 93화면 텍스트 1,868건 집계) ---
|
# --- 발주사 Figma 계약 시점 사본 실측 (2026-09-26, 앱 93화면 텍스트 1,868건 집계) ---
|
||||||
# 42dot Sans 가 81%, ABeeZee 가 14%(Regular 한 벌뿐이라 굵기는 400). 본문 자간은 -2.5% 가 표준이다.
|
# 42dot Sans 가 81%, ABeeZee 가 14%(Regular 한 벌뿐이라 굵기는 400). 본문 자간은 -2.5% 가 표준이다.
|
||||||
# 종전 값(Gmarket/포카 온 스테이지 재설계 — 44·32 Bold, 본문 400·행간 1.6)은 Figma 와 맞지 않아 교체한다.
|
# 종전 값(Gmarket/포카 온 스테이지 재설계 — 44·32 Bold, 본문 400·행간 1.6)은 Figma 와 맞지 않아 교체한다.
|
||||||
type-display-l: {family: font-display, size: 46, weight: 400, leading: 1.0}
|
type-display-l: {family: font-display, size: 46, weight: 400, leading: 1.0}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user