가입 시 약관 동의 단계 추가 — 필수·선택 항목 구분 · 개인정보처리방침 고지 · 동의 기록 저장

This commit is contained in:
Sn-Kinos
2026-10-11 23:43:25 +09:00
parent 9119b3beaa
commit c40bde2cd0
9 changed files with 564 additions and 51 deletions
+52 -1
View File
@@ -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';
@@ -85,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` — 마스터 전용. 운영자 등급을 커스텀 클레임으로 심는다.
* *
+1 -1
View File
@@ -4,7 +4,7 @@
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 { fnAuthKakao } from './account/kakao.js';
export { fnAuthLine } from './account/line.js'; export { fnAuthLine } from './account/line.js';
@@ -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');
});
});
+16 -6
View File
@@ -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';
@@ -77,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: [
@@ -97,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: [
+72 -1
View File
@@ -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;
@@ -172,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();
} }
+9 -5
View File
@@ -210,13 +210,16 @@ class _SimpleLoginScreenState extends State<SimpleLoginScreen> {
Future<void> _signIn(SocialProvider provider) async { Future<void> _signIn(SocialProvider provider) async {
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;
} }
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 +269,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 +348,7 @@ class _SimpleLoginScreenState extends State<SimpleLoginScreen> {
), ),
), ),
); );
return scaffold;
} }
} }
+189 -32
View File
@@ -1,23 +1,45 @@
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).
/// - 위치기반 서비스 약관은 여기서 받지 않는다 — 위치기능 최초 사용 직전에 따로 받는다.
/// - 개인정보처리방침은 동의 항목이 아니라 고지다 — 체크 칸 없이 목록 끝에 안내와 보기 링크만 둔다.
///
/// 약관 본문은 확정 문구를 받기 전까지 시안의 자리 표시 문구다 — 받으면
/// [termsPlaceholderBody] 와 [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();
} }
/// 동의 기록에 남는 약관 버전. 본문이 자리 표시 문구인 동안은 `draft` 다.
const signupTermsVersion = 'draft-1';
/// 서버 `SIGNUP_REQUIRED_TERMS` + 선택 `marketing` 과 같은 키.
List<String> get signupTermKeys => [for (final t in _Term.values) t.name];
enum _Term { enum _Term {
service('서비스 이용약관 (필수)', required: true, body: true), service('서비스 이용약관 (필수)', required: true, body: true),
location('위치기반 서비스 이용약관 (필수)', required: true, body: true), privacy('개인정보 수집·이용 동의 (필수)', required: true, body: true),
age('만 14세 이상입니다. (필수)', required: true, body: false), age('만 14세 이상입니다. (필수)', required: true, body: false),
marketing('마케팅 정보 및 이벤트 알림 수신 동의 (선택)', required: false, body: true); marketing('마케팅 정보 및 이벤트 알림 수신 동의 (선택)', required: false, body: true);
@@ -28,7 +50,8 @@ enum _Term {
final bool body; final bool body;
} }
const _body = /// 약관 본문 자리 표시 문구.
const termsPlaceholderBody =
'FANUZU 서비스 이용약관\n\n제1조 (적용범위)\n서비스 이용약관에 대한 더미 텍스트 입니다.\n' 'FANUZU 서비스 이용약관\n\n제1조 (적용범위)\n서비스 이용약관에 대한 더미 텍스트 입니다.\n'
'추후 작성해주시기 바랍니다. 서비스 이용약관에 대한 더미 텍스트 입니다.'; '추후 작성해주시기 바랍니다. 서비스 이용약관에 대한 더미 텍스트 입니다.';
@@ -41,13 +64,88 @@ 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(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 @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: _buttons(),
),
),
body: ListView( body: ListView(
padding: const EdgeInsets.all(AppSpace.s4), padding: const EdgeInsets.all(AppSpace.s4),
children: [ children: [
@@ -85,7 +183,7 @@ class _TermsScreenState extends State<TermsScreen> {
height: 80, height: 80,
child: ClipRect( child: ClipRect(
child: Text( child: Text(
_body, termsPlaceholderBody,
overflow: TextOverflow.clip, overflow: TextOverflow.clip,
style: TextStyle( style: TextStyle(
fontFamily: AppFonts.display, fontFamily: AppFonts.display,
@@ -108,42 +206,101 @@ class _TermsScreenState extends State<TermsScreen> {
), ),
), ),
], ],
const SizedBox(height: AppSpace.s6), // 개인정보처리방침 — 동의 항목과 구분한 고지. 체크 칸이 없고 「보기」로 본문을 연다.
Row( Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpace.s4),
child: Row(
children: [ children: [
Expanded( const Expanded(
child: FilledButton( child: Text(
onPressed: () => Navigator.of(context).pop(false), '개인정보 처리 내용은 개인정보처리방침에서 확인할 수 있습니다.',
style: FilledButton.styleFrom( style: TextStyle(
backgroundColor: Colors.black, fontFamily: AppFonts.display,
foregroundColor: AppColors.onCosmic, fontFamilyFallback: [AppFonts.body],
), fontSize: 12,
child: const Text('이전'), height: 1.36,
color: AppColors.onCosmicMuted,
), ),
), ),
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<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});
+25 -2
View File
@@ -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;
} }
+146
View File
@@ -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<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();
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<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.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<FilledButton>(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<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 {}
}