Phase 1 scaffold: auth for all apps

- Backend: Fastify with two listeners (public + internal), routes, services, DB migration + seed
- client_app: Flutter with BLoC, all auth screens (welcome, display name, register, OTP, force-register)
- mitra_app: Flutter with BLoC, OTP-only login
- control_center: React + Vite, email/password login, mitra/user management, anonymity settings
- Docs: phase1 plan, API contract, client app mockup
- CLAUDE.md and shared memory for all subprojects

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 10:08:42 +08:00
commit a7a2a32d27
85 changed files with 3953 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
import 'package:dio/dio.dart';
import 'package:firebase_auth/firebase_auth.dart';
class ApiClient {
static const String _baseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'https://api.halobestie.com',
);
late final Dio _dio;
ApiClient() {
_dio = Dio(BaseOptions(baseUrl: _baseUrl));
_dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
final user = FirebaseAuth.instance.currentUser;
if (user != null) {
final token = await user.getIdToken();
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
},
));
}
Future<Map<String, dynamic>> post(String path, {Map<String, dynamic>? data}) async {
final response = await _dio.post(path, data: data);
return response.data as Map<String, dynamic>;
}
Future<Map<String, dynamic>> get(String path, {Map<String, dynamic>? queryParameters}) async {
final response = await _dio.get(path, queryParameters: queryParameters);
return response.data as Map<String, dynamic>;
}
}

View File

@@ -0,0 +1,230 @@
import 'package:equatable/equatable.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../api/api_client.dart';
// Events
abstract class AuthEvent extends Equatable {
@override
List<Object?> get props => [];
}
class AppStarted extends AuthEvent {}
class AnonymousLoginRequested extends AuthEvent {
final String displayName;
AnonymousLoginRequested(this.displayName);
@override List<Object?> get props => [displayName];
}
class GoogleLoginRequested extends AuthEvent {}
class AppleLoginRequested extends AuthEvent {}
class PhoneOtpRequested extends AuthEvent {
final String phone;
PhoneOtpRequested(this.phone);
@override List<Object?> get props => [phone];
}
class OtpVerified extends AuthEvent {
final String verificationId;
final String smsCode;
OtpVerified(this.verificationId, this.smsCode);
@override List<Object?> get props => [verificationId, smsCode];
}
class LinkAccountRequested extends AuthEvent {}
class LogoutRequested extends AuthEvent {}
// States
abstract class AuthState extends Equatable {
@override
List<Object?> get props => [];
}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthAuthenticated extends AuthState {
final Map<String, dynamic> profile;
AuthAuthenticated(this.profile);
@override List<Object?> get props => [profile];
}
class AuthAnonymous extends AuthState {
final String customerId;
final String displayName;
AuthAnonymous({required this.customerId, required this.displayName});
@override List<Object?> get props => [customerId, displayName];
}
class AuthOtpSent extends AuthState {
final String verificationId;
AuthOtpSent(this.verificationId);
@override List<Object?> get props => [verificationId];
}
class AuthError extends AuthState {
final String message;
AuthError(this.message);
@override List<Object?> get props => [message];
}
class AuthForceRegister extends AuthState {
final String customerId;
final String displayName;
AuthForceRegister({required this.customerId, required this.displayName});
@override List<Object?> get props => [customerId, displayName];
}
// Bloc
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final ApiClient apiClient;
final _auth = FirebaseAuth.instance;
String? _pendingVerificationId;
AuthBloc({required this.apiClient}) : super(AuthInitial()) {
on<AppStarted>(_onAppStarted);
on<AnonymousLoginRequested>(_onAnonymousLogin);
on<GoogleLoginRequested>(_onGoogleLogin);
on<AppleLoginRequested>(_onAppleLogin);
on<PhoneOtpRequested>(_onPhoneOtpRequested);
on<OtpVerified>(_onOtpVerified);
on<LinkAccountRequested>(_onLinkAccount);
on<LogoutRequested>(_onLogout);
}
Future<void> _onAppStarted(AppStarted event, Emitter<AuthState> emit) async {
final prefs = await SharedPreferences.getInstance();
final customerId = prefs.getString('anonymous_customer_id');
final displayName = prefs.getString('anonymous_display_name');
if (_auth.currentUser != null) {
await _verifyAndEmit(emit);
} else if (customerId != null && displayName != null) {
// Check anonymity config
try {
final config = await apiClient.get('/api/shared/config/anonymity');
final anonymityEnabled = config['data']['anonymity_enabled'] as bool;
if (!anonymityEnabled) {
emit(AuthForceRegister(customerId: customerId, displayName: displayName));
} else {
emit(AuthAnonymous(customerId: customerId, displayName: displayName));
}
} catch (_) {
emit(AuthAnonymous(customerId: customerId, displayName: displayName));
}
} else {
emit(AuthInitial());
}
}
Future<void> _onAnonymousLogin(AnonymousLoginRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
final response = await apiClient.post(
'/api/shared/customer/anonymous',
data: {'display_name': event.displayName},
);
final customer = response['data'] as Map<String, dynamic>;
final prefs = await SharedPreferences.getInstance();
await prefs.setString('anonymous_customer_id', customer['id'] as String);
await prefs.setString('anonymous_display_name', customer['display_name'] as String);
emit(AuthAnonymous(customerId: customer['id'] as String, displayName: customer['display_name'] as String));
} catch (e) {
emit(AuthError('Failed to continue as guest. Please try again.'));
}
}
Future<void> _onGoogleLogin(GoogleLoginRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
final googleUser = await GoogleSignIn().signIn();
if (googleUser == null) { emit(AuthInitial()); return; }
final googleAuth = await googleUser.authentication;
final credential = GoogleAuthProvider.credential(
accessToken: googleAuth.accessToken,
idToken: googleAuth.idToken,
);
await _auth.signInWithCredential(credential);
await _verifyAndEmit(emit);
} catch (e) {
emit(AuthError('Google sign-in failed. Please try again.'));
}
}
Future<void> _onAppleLogin(AppleLoginRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
final appleCredential = await SignInWithApple.getAppleIDCredential(
scopes: [AppleIDAuthorizationScopes.email],
);
final oauthCredential = OAuthProvider('apple.com').credential(
idToken: appleCredential.identityToken,
accessToken: appleCredential.authorizationCode,
);
await _auth.signInWithCredential(oauthCredential);
await _verifyAndEmit(emit);
} catch (e) {
emit(AuthError('Apple sign-in failed. Please try again.'));
}
}
Future<void> _onPhoneOtpRequested(PhoneOtpRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
await _auth.verifyPhoneNumber(
phoneNumber: event.phone,
verificationCompleted: (_) {},
verificationFailed: (e) => emit(AuthError('Failed to send OTP. Please try again.')),
codeSent: (verificationId, _) {
_pendingVerificationId = verificationId;
emit(AuthOtpSent(verificationId));
},
codeAutoRetrievalTimeout: (_) {},
);
}
Future<void> _onOtpVerified(OtpVerified event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
final credential = PhoneAuthProvider.credential(
verificationId: event.verificationId,
smsCode: event.smsCode,
);
await _auth.signInWithCredential(credential);
await _verifyAndEmit(emit);
} catch (e) {
emit(AuthError('Invalid OTP. Please try again.'));
}
}
Future<void> _onLinkAccount(LinkAccountRequested event, Emitter<AuthState> emit) async {
// Called after anonymous user completes social/OTP login to link accounts
final prefs = await SharedPreferences.getInstance();
final customerId = prefs.getString('anonymous_customer_id');
if (customerId == null || _auth.currentUser == null) return;
emit(AuthLoading());
try {
await apiClient.post('/api/shared/customer/link', data: {
'customer_id': customerId,
'firebase_uid': _auth.currentUser!.uid,
});
await prefs.remove('anonymous_customer_id');
await prefs.remove('anonymous_display_name');
await _verifyAndEmit(emit);
} catch (e) {
emit(AuthError('Failed to link account. Please try again.'));
}
}
Future<void> _onLogout(LogoutRequested event, Emitter<AuthState> emit) async {
await _auth.signOut();
final prefs = await SharedPreferences.getInstance();
await prefs.remove('anonymous_customer_id');
await prefs.remove('anonymous_display_name');
emit(AuthInitial());
}
Future<void> _verifyAndEmit(Emitter<AuthState> emit) async {
try {
final response = await apiClient.post('/api/client/auth/verify');
emit(AuthAuthenticated(response['data'] as Map<String, dynamic>));
} catch (e) {
emit(AuthError('Failed to verify account. Please try again.'));
}
}
}

View File

@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/auth/auth_bloc.dart';
class DisplayNameScreen extends StatefulWidget {
const DisplayNameScreen({super.key});
@override
State<DisplayNameScreen> createState() => _DisplayNameScreenState();
}
class _DisplayNameScreenState extends State<DisplayNameScreen> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _submit() {
final name = _controller.text.trim();
if (name.isEmpty) return;
context.read<AuthBloc>().add(AnonymousLoginRequested(name));
}
@override
Widget build(BuildContext context) {
return BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
}
},
child: Scaffold(
appBar: AppBar(title: const Text('Siapa namamu?')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('Pilih nama yang ingin kamu gunakan. Nama ini tidak akan terlihat oleh siapapun selain mitra kamu.'),
const SizedBox(height: 24),
TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: 'Nama panggilan',
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.done,
onSubmitted: (_) => _submit(),
),
const SizedBox(height: 24),
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) => ElevatedButton(
onPressed: state is AuthLoading ? null : _submit,
child: state is AuthLoading
? const CircularProgressIndicator()
: const Text('Lanjut'),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../core/auth/auth_bloc.dart';
/// Shown when anonymity is disabled by admin.
/// User must link their account. Display name is pre-filled.
class ForceRegisterScreen extends StatefulWidget {
const ForceRegisterScreen({super.key});
@override
State<ForceRegisterScreen> createState() => _ForceRegisterScreenState();
}
class _ForceRegisterScreenState extends State<ForceRegisterScreen> {
final _phoneController = TextEditingController();
@override
void dispose() {
_phoneController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthOtpSent) {
context.push('/auth/otp', extra: _phoneController.text.trim());
}
if (state is AuthAuthenticated) {
// After linking, link account to existing anonymous record
context.read<AuthBloc>().add(LinkAccountRequested());
}
if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
}
},
child: Scaffold(
appBar: AppBar(title: const Text('Verifikasi Akun')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Untuk melanjutkan, kamu perlu mendaftarkan akun.',
style: TextStyle(fontSize: 16),
),
const SizedBox(height: 24),
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) => ElevatedButton.icon(
icon: const Icon(Icons.g_mobiledata),
onPressed: state is AuthLoading ? null
: () => context.read<AuthBloc>().add(GoogleLoginRequested()),
label: const Text('Lanjut dengan Google'),
),
),
const SizedBox(height: 12),
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) => ElevatedButton.icon(
icon: const Icon(Icons.apple),
onPressed: state is AuthLoading ? null
: () => context.read<AuthBloc>().add(AppleLoginRequested()),
label: const Text('Lanjut dengan Apple'),
),
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Row(children: [
Expanded(child: Divider()),
Padding(padding: EdgeInsets.symmetric(horizontal: 12), child: Text('atau')),
Expanded(child: Divider()),
]),
),
TextField(
controller: _phoneController,
decoration: const InputDecoration(
labelText: 'Nomor HP',
hintText: '+628xxxxxxxxxx',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.phone,
),
const SizedBox(height: 12),
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) => ElevatedButton(
onPressed: state is AuthLoading ? null : () {
final phone = _phoneController.text.trim();
if (phone.isEmpty) return;
context.read<AuthBloc>().add(PhoneOtpRequested(phone));
},
child: state is AuthLoading
? const CircularProgressIndicator()
: const Text('Kirim OTP'),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/auth/auth_bloc.dart';
class OtpScreen extends StatefulWidget {
final String phone;
const OtpScreen({super.key, required this.phone});
@override
State<OtpScreen> createState() => _OtpScreenState();
}
class _OtpScreenState extends State<OtpScreen> {
final _otpController = TextEditingController();
@override
void dispose() {
_otpController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
}
},
child: Scaffold(
appBar: AppBar(title: const Text('Masukkan OTP')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Kode OTP telah dikirim ke ${widget.phone}'),
const SizedBox(height: 24),
TextField(
controller: _otpController,
decoration: const InputDecoration(
labelText: 'Kode OTP',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
maxLength: 6,
),
const SizedBox(height: 12),
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) => ElevatedButton(
onPressed: state is AuthLoading ? null : () {
final otp = _otpController.text.trim();
if (otp.length != 6) return;
final verificationId = (state is AuthOtpSent) ? state.verificationId : '';
context.read<AuthBloc>().add(OtpVerified(verificationId, otp));
},
child: state is AuthLoading
? const CircularProgressIndicator()
: const Text('Verifikasi'),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../core/auth/auth_bloc.dart';
class RegisterScreen extends StatefulWidget {
const RegisterScreen({super.key});
@override
State<RegisterScreen> createState() => _RegisterScreenState();
}
class _RegisterScreenState extends State<RegisterScreen> {
final _phoneController = TextEditingController();
@override
void dispose() {
_phoneController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthOtpSent) {
context.push('/auth/otp', extra: _phoneController.text.trim());
}
if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
}
},
child: Scaffold(
appBar: AppBar(title: const Text('Masuk / Daftar')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) => ElevatedButton.icon(
icon: const Icon(Icons.g_mobiledata),
onPressed: state is AuthLoading ? null
: () => context.read<AuthBloc>().add(GoogleLoginRequested()),
label: const Text('Lanjut dengan Google'),
),
),
const SizedBox(height: 12),
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) => ElevatedButton.icon(
icon: const Icon(Icons.apple),
onPressed: state is AuthLoading ? null
: () => context.read<AuthBloc>().add(AppleLoginRequested()),
label: const Text('Lanjut dengan Apple'),
),
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Row(children: [
Expanded(child: Divider()),
Padding(padding: EdgeInsets.symmetric(horizontal: 12), child: Text('atau')),
Expanded(child: Divider()),
]),
),
TextField(
controller: _phoneController,
decoration: const InputDecoration(
labelText: 'Nomor HP',
hintText: '+628xxxxxxxxxx',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.phone,
),
const SizedBox(height: 12),
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) => ElevatedButton(
onPressed: state is AuthLoading ? null : () {
final phone = _phoneController.text.trim();
if (phone.isEmpty) return;
context.read<AuthBloc>().add(PhoneOtpRequested(phone));
},
child: state is AuthLoading
? const CircularProgressIndicator()
: const Text('Kirim OTP'),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class WelcomeScreen extends StatelessWidget {
const WelcomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Halo Bestie',
style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
const Text(
'Tempat curhat kamu',
style: TextStyle(fontSize: 16, color: Colors.grey),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
ElevatedButton(
onPressed: () => context.push('/auth/display-name'),
child: const Text('Lanjut sebagai Tamu'),
),
const SizedBox(height: 12),
OutlinedButton(
onPressed: () => context.push('/auth/register'),
child: const Text('Daftar / Masuk'),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../core/auth/auth_bloc.dart';
/// Phase 1 placeholder — will be replaced in Phase 2 with chat/session features.
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
final displayName = state is AuthAuthenticated
? state.profile['display_name'] as String
: state is AuthAnonymous
? state.displayName
: '';
return Scaffold(
appBar: AppBar(
title: const Text('Halo Bestie'),
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () => context.read<AuthBloc>().add(LogoutRequested()),
),
],
),
body: Center(
child: Text(
'Halo, $displayName!',
style: const TextStyle(fontSize: 24),
),
),
);
},
);
}
}

32
client_app/lib/main.dart Normal file
View File

@@ -0,0 +1,32 @@
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'core/api/api_client.dart';
import 'core/auth/auth_bloc.dart';
import 'firebase_options.dart';
import 'router.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
runApp(const App());
}
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => AuthBloc(apiClient: ApiClient())..add(AppStarted()),
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
return MaterialApp.router(
title: 'Halo Bestie',
routerConfig: buildRouter(context.read<AuthBloc>()),
);
},
),
);
}
}

View File

@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'core/auth/auth_bloc.dart';
import 'features/auth/screens/welcome_screen.dart';
import 'features/auth/screens/display_name_screen.dart';
import 'features/auth/screens/register_screen.dart';
import 'features/auth/screens/otp_screen.dart';
import 'features/auth/screens/force_register_screen.dart';
import 'features/home/home_screen.dart';
GoRouter buildRouter(AuthBloc authBloc) {
return GoRouter(
initialLocation: '/welcome',
redirect: (context, state) {
final authState = authBloc.state;
final isAuthRoute = state.matchedLocation.startsWith('/auth') ||
state.matchedLocation == '/welcome';
if (authState is AuthAuthenticated || authState is AuthAnonymous) {
return isAuthRoute ? '/home' : null;
}
if (authState is AuthForceRegister) return '/auth/force-register';
if (!isAuthRoute) return '/welcome';
return null;
},
routes: [
GoRoute(path: '/welcome', builder: (_, __) => const WelcomeScreen()),
GoRoute(path: '/auth/display-name', builder: (_, __) => const DisplayNameScreen()),
GoRoute(path: '/auth/register', builder: (_, __) => const RegisterScreen()),
GoRoute(path: '/auth/otp', builder: (context, state) => OtpScreen(phone: state.extra as String)),
GoRoute(path: '/auth/force-register', builder: (_, __) => const ForceRegisterScreen()),
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
],
);
}