diff --git a/app/firebase/functions/src/account/account.ts b/app/firebase/functions/src/account/account.ts index 953c049..d3200df 100644 --- a/app/firebase/functions/src/account/account.ts +++ b/app/firebase/functions/src/account/account.ts @@ -1,5 +1,5 @@ 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 { getAuth } from 'firebase-admin/auth'; @@ -85,6 +85,57 @@ export const fnConsentRecord = onCall(async (req) => { 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)?.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` — 마스터 전용. 운영자 등급을 커스텀 클레임으로 심는다. * diff --git a/app/firebase/functions/src/index.ts b/app/firebase/functions/src/index.ts index 92913ce..288845b 100644 --- a/app/firebase/functions/src/index.ts +++ b/app/firebase/functions/src/index.ts @@ -4,7 +4,7 @@ import './core/bootstrap.js'; export { - fnAuthOnCreate, fnConsentRecord, fnAdminSetRole, + fnAuthOnCreate, fnConsentRecord, fnSignupConsent, fnAdminSetRole, } from './account/account.js'; export { fnAuthKakao } from './account/kakao.js'; export { fnAuthLine } from './account/line.js'; diff --git a/app/firebase/functions/test/signup_consent.test.ts b/app/firebase/functions/test/signup_consent.test.ts new file mode 100644 index 0000000..f0c6b28 --- /dev/null +++ b/app/firebase/functions/test/signup_consent.test.ts @@ -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[0]; +const as = (uid: string, data: Record) => ({ data, auth: { uid, token: {} } }) as CallReq; +const codeOf = async (p: Promise) => { + 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'); + }); +}); diff --git a/app/lib/core/router/app_router.dart b/app/lib/core/router/app_router.dart index 5ca939e..ca7904f 100644 --- a/app/lib/core/router/app_router.dart +++ b/app/lib/core/router/app_router.dart @@ -6,6 +6,7 @@ import '../theme/tokens.dart'; import '../ui/app_icons.dart'; import '../../features/auth/auth_repository.dart'; import '../../features/auth/login_screen.dart'; +import '../../features/auth/signup_screens.dart'; import '../../features/galaxy/galaxy_screen.dart'; import '../../features/home/home_screen.dart'; import '../../features/inside/inside_screen.dart'; @@ -77,19 +78,24 @@ List visibleTabs(FeatureFlags flags) => [ const String loginPath = '/login'; -/// 5탭 셸 라우트 + 온보딩(로그인) 라우트. +/// 가입 약관 — 동의 기록(`termsAgreedAt`)이 없는 계정은 셸 대신 여기로 온다. +const String termsPath = '/terms'; + +/// 5탭 셸 라우트 + 로그인 · 가입 약관 라우트. /// -/// 온보딩 나머지 화면(S-A-INTRO · S-A-TERMS · S-A-PROFILE · S-A-ARTIST · -/// S-A-PLANET-NEW · S-A-PERMS)은 3주차 범위라 아직 라우트가 없다. +/// 로그인 → (동의 기록이 없으면) 약관([termsPath]) → 셸. 가입 · 로그인 버튼이나 제공자와 +/// 무관하게 약관 동의 없이 셸에 들어가지 못한다. GoRouter buildRouter(AuthRepository auth, {FeatureFlags? flags}) { final featureFlags = flags ?? FeatureFlags(); return GoRouter( initialLocation: appTabs.first.path, refreshListenable: auth, redirect: (context, state) { - final atLogin = state.matchedLocation == loginPath; - if (!auth.isSignedIn) return atLogin ? null : loginPath; - if (atLogin) return appTabs.first.path; + final at = state.matchedLocation; + if (!auth.isSignedIn) return at == loginPath ? null : loginPath; + if (auth.terms != TermsGate.done) + return at == termsPath ? null : termsPath; + if (at == loginPath || at == termsPath) return appTabs.first.path; return null; }, routes: [ @@ -97,6 +103,10 @@ GoRouter buildRouter(AuthRepository auth, {FeatureFlags? flags}) { path: loginPath, builder: (_, _) => LoginScreen(auth: auth), ), + GoRoute( + path: termsPath, + builder: (_, _) => SignupTermsGate(auth: auth), + ), StatefulShellRoute.indexedStack( builder: (_, _, shell) => AppShell(shell: shell, flags: featureFlags), branches: [ diff --git a/app/lib/features/auth/auth_repository.dart b/app/lib/features/auth/auth_repository.dart index d1d88b9..5de798e 100644 --- a/app/lib/features/auth/auth_repository.dart +++ b/app/lib/features/auth/auth_repository.dart @@ -6,21 +6,85 @@ import '../../core/firebase/firebase_boot.dart'; import 'auth.dart'; import 'social_providers.dart'; -/// 로그인 상태 보관소. 라우터가 이걸 듣고 로그인/셸을 가른다. +/// 가입 약관 관문 — 로그인한 계정이 약관 동의를 마쳤는가(`users/{uid}.termsAgreedAt`). +enum TermsGate { checking, required, done } + +/// 로그인 상태 보관소. 라우터가 이걸 듣고 로그인/약관/셸을 가른다. class AuthRepository extends ChangeNotifier { AuthRepository({ List? providers, AuthSession? session, EmailAuthProvider? qaEmailProvider, bool? releaseMode, + Future Function(String uid)? needsTerms, + Future Function(Set agreed)? recordTerms, }) : providers = providers ?? _defaultProviders(), _qaEmail = qaEmailProvider ?? const EmailPasswordProvider(), _session = session, + _needsTerms = needsTerms, + _recordTerms = recordTerms, _account = session?.currentAccount, // 기본값은 실제 빌드 모드. 테스트에서만 주입해 릴리스 동작을 재현한다 // (kReleaseMode 는 컴파일 상수라 테스트에서 뒤집을 수 없다). _releaseMode = releaseMode ?? (kReleaseMode || kProfileMode) { _sessionSubscription = session?.accountChanges.listen(_replaceAccount); + final initial = _account; + if (initial != null) unawaited(_checkTerms(initial)); + } + + /// 이 계정이 약관 동의를 거쳐야 하는가. null 이면 판정하지 않는다(목 모드 · 테스트). + /// + /// **「로그인」 버튼으로 처음 들어온 소셜 계정도 여기서 걸린다** — 가입/로그인 버튼이나 + /// 제공자(카카오 · LINE 커스텀 토큰 포함)와 무관하게, 서버에 동의 기록이 없으면 셸에 들어가지 못한다. + final Future Function(String uid)? _needsTerms; + + /// 동의 기록(`fnSignupConsent`). 필수 누락은 서버가 거부한다. + final Future Function(Set agreed)? _recordTerms; + + TermsGate _gate = TermsGate.done; + TermsGate get terms => _gate; + + /// 가입 모드에서 제공자 인증 **전에** 동의한 약관. 인증 뒤 동의가 필요한 계정이면 + /// 이 값으로 기록하고 약관 화면을 다시 띄우지 않는다. + Set? pendingConsent; + + /// 약관 동의를 서버에 기록하고 셸로 보낸다. 실패하면 예외를 그대로 던진다(화면이 안내). + Future agreeTerms(Set agreed) async { + await _recordTerms?.call(agreed); + _gate = TermsGate.done; + pendingConsent = null; + notifyListeners(); + } + + Future _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; @@ -172,7 +236,14 @@ class AuthRepository extends ChangeNotifier { void _replaceAccount(AuthAccount? next) { if (_sameAccount(_account, next)) return; + final uidChanged = _account?.uid != next?.uid; _account = next; + if (next == null) { + _gate = TermsGate.done; + pendingConsent = null; + } else if (uidChanged) { + unawaited(_checkTerms(next)); + } notifyListeners(); } diff --git a/app/lib/features/auth/login_screen.dart b/app/lib/features/auth/login_screen.dart index 18b530f..548af81 100644 --- a/app/lib/features/auth/login_screen.dart +++ b/app/lib/features/auth/login_screen.dart @@ -210,13 +210,16 @@ class _SimpleLoginScreenState extends State { Future _signIn(SocialProvider provider) async { if (widget.signUp) { - final agreed = await Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => const TermsScreen())); - if (agreed != true || !mounted) return; + final agreed = await Navigator.of(context).push>( + MaterialPageRoute(builder: (_) => const TermsScreen()), + ); + if (agreed == null || !mounted) return; + // 인증 뒤 동의가 필요한 계정이면 이 동의를 서버에 기록한다(약관을 다시 묻지 않는다). + widget.auth.pendingConsent = agreed; } setState(() => _busy = true); final outcome = await widget.auth.signIn(provider); + if (outcome is! AuthSucceeded) widget.auth.pendingConsent = null; if (!mounted) return; setState(() => _busy = false); // 키 대기 제공자는 안내 다이얼로그로 끊는다 — 눌렀는데 아무 일도 안 일어나면 @@ -266,7 +269,7 @@ class _SimpleLoginScreenState extends State { for (final kind in _figmaOrder) ...widget.auth.providers.where((p) => p.provider == kind), ]; - return Scaffold( + final scaffold = Scaffold( appBar: AppBar( automaticallyImplyLeading: false, actions: [ @@ -345,6 +348,7 @@ class _SimpleLoginScreenState extends State { ), ), ); + return scaffold; } } diff --git a/app/lib/features/auth/signup_screens.dart b/app/lib/features/auth/signup_screens.dart index 07120fe..a440364 100644 --- a/app/lib/features/auth/signup_screens.dart +++ b/app/lib/features/auth/signup_screens.dart @@ -1,23 +1,45 @@ import 'package:flutter/material.dart'; +import '../../core/data/backend.dart'; import '../../core/theme/tokens.dart'; +import 'auth_repository.dart'; /// 회원가입 약관 — Figma `179:24230`(미동의) · `179:24324`(전체 동의). /// -/// 필수 3항(서비스 · 위치기반 · 만 14세)을 모두 체크해야 「완료」가 켜진다. 완료하면 `true` 를 -/// 돌려주고, 부른 쪽([SimpleLoginScreen] 가입 모드)이 제공자 인증으로 넘어간다. +/// 필수 항목을 모두 체크해야 「완료」가 켜진다. 완료하면 동의한 항목 키([signupTermKeys] 중)를 +/// 돌려준다 — 가입 모드([SimpleLoginScreen])는 `pop` 결과로, 로그인 직후 약관 단계는 [onAgreed] 로 받는다. +/// 서버(`fnSignupConsent`)가 같은 필수 목록을 다시 검사하고 항목별 동의 행을 남긴다. /// -/// 약관 본문은 시안의 자리 표시 문구다. 확정 약관이 오면 [_body] 만 바꾼다. +/// - 필수: 서비스 이용약관 · 개인정보 수집·이용 동의 · 만 14세 이상. +/// - 선택: 마케팅 수신(기본 OFF). +/// - 위치기반 서비스 약관은 여기서 받지 않는다 — 위치기능 최초 사용 직전에 따로 받는다. +/// - 개인정보처리방침은 동의 항목이 아니라 고지다 — 체크 칸 없이 목록 끝에 안내와 보기 링크만 둔다. +/// +/// 약관 본문은 확정 문구를 받기 전까지 시안의 자리 표시 문구다 — 받으면 +/// [termsPlaceholderBody] 와 [signupTermsVersion] 을 함께 바꾼다(동의 기록에 버전이 남는다). class TermsScreen extends StatefulWidget { - const TermsScreen({super.key}); + const TermsScreen({super.key, this.onAgreed, this.onBack, this.busy = false}); + + /// 로그인 직후 약관 단계에서 쓸 때 — 「완료」가 화면을 닫지 않고 이걸 부른다. + final ValueChanged>? onAgreed; + final VoidCallback? onBack; + + /// 동의를 기록하는 중 — 버튼을 막고 진행 표시를 띄운다. + final bool busy; @override State createState() => _TermsScreenState(); } +/// 동의 기록에 남는 약관 버전. 본문이 자리 표시 문구인 동안은 `draft` 다. +const signupTermsVersion = 'draft-1'; + +/// 서버 `SIGNUP_REQUIRED_TERMS` + 선택 `marketing` 과 같은 키. +List get signupTermKeys => [for (final t in _Term.values) t.name]; + enum _Term { service('서비스 이용약관 (필수)', required: true, body: true), - location('위치기반 서비스 이용약관 (필수)', required: true, body: true), + privacy('개인정보 수집·이용 동의 (필수)', required: true, body: true), age('만 14세 이상입니다. (필수)', required: true, body: false), marketing('마케팅 정보 및 이벤트 알림 수신 동의 (선택)', required: false, body: true); @@ -28,7 +50,8 @@ enum _Term { final bool body; } -const _body = +/// 약관 본문 자리 표시 문구. +const termsPlaceholderBody = 'FANUZU 서비스 이용약관\n\n제1조 (적용범위)\n서비스 이용약관에 대한 더미 텍스트 입니다.\n' '추후 작성해주시기 바랍니다. 서비스 이용약관에 대한 더미 텍스트 입니다.'; @@ -41,13 +64,88 @@ class _TermsScreenState extends State { bool get _ready => _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) => setState(() => _agreed.contains(t) ? _agreed.remove(t) : _agreed.add(t)); + void _showPrivacyPolicy() => showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('개인정보처리방침'), + content: const SingleChildScrollView(child: Text(termsPlaceholderBody)), + 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 Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('이용 약관')), + final scaffold = Scaffold( + // 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: _buttons(), + ), + ), body: ListView( padding: const EdgeInsets.all(AppSpace.s4), children: [ @@ -85,7 +183,7 @@ class _TermsScreenState extends State { height: 80, child: ClipRect( child: Text( - _body, + termsPlaceholderBody, overflow: TextOverflow.clip, style: TextStyle( fontFamily: AppFonts.display, @@ -108,42 +206,101 @@ class _TermsScreenState extends State { ), ), ], - 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, + // 개인정보처리방침 — 동의 항목과 구분한 고지. 체크 칸이 없고 「보기」로 본문을 연다. + Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpace.s4), + child: Row( + children: [ + const Expanded( + child: Text( + '개인정보 처리 내용은 개인정보처리방침에서 확인할 수 있습니다.', + style: TextStyle( + fontFamily: AppFonts.display, + fontFamilyFallback: [AppFonts.body], + fontSize: 12, + height: 1.36, + color: AppColors.onCosmicMuted, + ), ), - 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('완료'), + TextButton( + key: const ValueKey('privacy-policy-link'), + onPressed: _showPrivacyPolicy, + 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 createState() => _SignupTermsGateState(); +} + +class _SignupTermsGateState extends State { + bool _busy = false; + + Future _agree(Set 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. class _Box extends StatelessWidget { const _Box({required this.child}); diff --git a/app/lib/main.dart b/app/lib/main.dart index 4307e62..ef074c8 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -1,14 +1,37 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:flutter/material.dart'; import 'app.dart'; +import 'core/data/backend.dart'; import 'core/firebase/firebase_boot.dart'; import 'features/auth/auth_repository.dart'; import 'features/auth/firebase_auth_session.dart'; +import 'features/auth/signup_screens.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); // 설정 파일이 없으면 목 모드로 내려간다. 여기서 throw 하지 않는다 — FirebaseBoot 참고. await FirebaseBoot.init(); - final session = FirebaseBoot.isLive ? FirebaseAuthSession() : null; - runApp(FancakeApp(auth: AuthRepository(session: session))); + final live = FirebaseBoot.isLive; + 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 _needsTerms(String uid) async { + final snap = await FirebaseFirestore.instance.doc('users/$uid').get(); + return snap.data()?['termsAgreedAt'] == null; } diff --git a/app/test/signup_terms_test.dart b/app/test/signup_terms_test.dart new file mode 100644 index 0000000..1f0cd47 --- /dev/null +++ b/app/test/signup_terms_test.dart @@ -0,0 +1,146 @@ +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: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(); + final provider = _Provider(AuthProviderKind.kakao, () => signIn.future); + Set? 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(); + + await tester.tap(find.text(providerButtonLabel(AuthProviderKind.kakao))); + await tester.pump(); + 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(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.scrollUntilVisible(policy, 200); + await tester.tap(policy); + await tester.pumpAndSettle(); + expect(find.text('개인정보처리방침'), findsOneWidget); + await tester.tap(find.text('확인')); + await tester.pumpAndSettle(); + + expect(tester.widget(done).onPressed, isNotNull); + await tester.tap(done); + await tester.pumpAndSettle(); + + expect(recorded, {'service', 'privacy', 'age'}); + expect(find.byType(NavigationBar), findsOneWidget); + }); + + 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? 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 Function() _signIn; + int calls = 0; + + @override + bool get isLive => true; + + @override + List get missingConfig => const []; + + @override + Future signIn() { + calls += 1; + return _signIn(); + } + + @override + Future signOut() async {} +}