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,3 @@
# Memory Index
- [Mitra App Context](context.md) — Flutter, Firebase Auth native, calls /api/mitra/ and /api/shared/ only, requires control center approval

View File

@@ -0,0 +1,17 @@
---
name: Mitra App Context
description: Stack, purpose, and API conventions for the Halo Bestie mitra Flutter app
type: project
---
Flutter mobile app (iOS + Android) for trained mental health professionals (mitra/partners).
**Stack:** Flutter, Firebase Auth (`firebase_auth` + `google_sign_in`)
**Auth:** Google Sign-In (native), Apple Sign-In (native), Phone OTP — fully native, no WebView. JWT sent as `Authorization: Bearer` on every API call.
**API:** Calls `/api/mitra/` and `/api/shared/` routes only. Never call `/api/client/` or `/internal/`.
**Domain:** Mitra = trained mental health professional. Flow: register + credential upload → await control center approval → set availability → accept sessions → chat with client → receive payout.
**Important:** Mitra accounts require approval from control center before going live. Mitra role must be verified server-side on every relevant request — never rely on client-side role checks.

25
mitra_app/CLAUDE.md Normal file
View File

@@ -0,0 +1,25 @@
# Halo Bestie — Mitra App
Flutter mobile application for mental health professionals (mitra/partners).
> See root `CLAUDE.md` for full project context and architectural decisions.
## Stack
- **Framework:** Flutter (iOS + Android)
- **Auth:** Firebase Auth — Google Sign-In, Apple Sign-In, Phone OTP
- Fully native UI — no WebView, no Firebase-branded screens
- Use `firebase_auth` + `google_sign_in` packages
- **API:** Calls public Fastify backend (`/api/mitra/` and `/api/shared/` routes)
## Key Concepts
- Users are **mitra** — trained mental health professionals
- Core flow: register + credential verification → set availability → accept sessions → chat with client → receive payment
- Mitra accounts require approval from control center before going live
## Conventions
- Never call `/api/client/` or `/internal/` routes from this app
- All API calls must include Firebase JWT token in `Authorization` header
- Mitra role must be verified server-side on every relevant request

View File

@@ -0,0 +1,30 @@
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>;
}
}

View File

@@ -0,0 +1,115 @@
import 'package:equatable/equatable.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../api/api_client.dart';
// Events
abstract class AuthEvent extends Equatable {
@override
List<Object?> get props => [];
}
class AppStarted 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 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 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];
}
// Bloc
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final ApiClient apiClient;
final _auth = FirebaseAuth.instance;
AuthBloc({required this.apiClient}) : super(AuthInitial()) {
on<AppStarted>(_onAppStarted);
on<PhoneOtpRequested>(_onPhoneOtpRequested);
on<OtpVerified>(_onOtpVerified);
on<LogoutRequested>(_onLogout);
}
Future<void> _onAppStarted(AppStarted event, Emitter<AuthState> emit) async {
if (_auth.currentUser != null) {
await _verifyAndEmit(emit);
}
}
Future<void> _onPhoneOtpRequested(PhoneOtpRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
await _auth.verifyPhoneNumber(
phoneNumber: event.phone,
verificationCompleted: (_) {},
verificationFailed: (e) => emit(AuthError('Gagal mengirim OTP. Coba lagi.')),
codeSent: (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('OTP tidak valid. Coba lagi.'));
}
}
Future<void> _onLogout(LogoutRequested event, Emitter<AuthState> emit) async {
await _auth.signOut();
emit(AuthInitial());
}
Future<void> _verifyAndEmit(Emitter<AuthState> emit) async {
try {
final response = await apiClient.post('/api/mitra/auth/verify');
emit(AuthAuthenticated(response['data'] as Map<String, dynamic>));
} on Exception catch (e) {
await _auth.signOut();
// Surface specific errors from backend
final msg = e.toString();
if (msg.contains('ACCOUNT_NOT_FOUND')) {
emit(AuthError('Akun tidak ditemukan. Hubungi administrator.'));
} else if (msg.contains('ACCOUNT_INACTIVE')) {
emit(AuthError('Akun tidak aktif. Hubungi administrator.'));
} else {
emit(AuthError('Gagal masuk. Coba lagi.'));
}
}
}
}

View File

@@ -0,0 +1,76 @@
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 LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
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('/otp', extra: _phoneController.text.trim());
}
if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
}
},
child: Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Halo Bestie Mitra',
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
TextField(
controller: _phoneController,
decoration: const InputDecoration(
labelText: 'Nomor HP',
hintText: '+628xxxxxxxxxx',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.phone,
),
const SizedBox(height: 16),
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,34 @@
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 session/chat 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
: '';
return Scaffold(
appBar: AppBar(
title: const Text('Halo Bestie Mitra'),
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
mitra_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 Mitra',
routerConfig: buildRouter(context.read<AuthBloc>()),
);
},
),
);
}
}

25
mitra_app/lib/router.dart Normal file
View File

@@ -0,0 +1,25 @@
import 'package:go_router/go_router.dart';
import 'core/auth/auth_bloc.dart';
import 'features/auth/screens/login_screen.dart';
import 'features/auth/screens/otp_screen.dart';
import 'features/home/home_screen.dart';
GoRouter buildRouter(AuthBloc authBloc) {
return GoRouter(
initialLocation: '/login',
redirect: (context, state) {
final authState = authBloc.state;
final isAuthRoute = state.matchedLocation.startsWith('/login') ||
state.matchedLocation.startsWith('/otp');
if (authState is AuthAuthenticated) return isAuthRoute ? '/home' : null;
if (!isAuthRoute) return '/login';
return null;
},
routes: [
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
GoRoute(path: '/otp', builder: (context, state) => OtpScreen(phone: state.extra as String)),
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
],
);
}

34
mitra_app/pubspec.yaml Normal file
View File

@@ -0,0 +1,34 @@
name: mitra_app
description: Halo Bestie - Mitra App
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
# Firebase
firebase_core: ^2.27.1
firebase_auth: ^4.18.0
# HTTP
dio: ^5.4.3
# State management
flutter_bloc: ^8.1.5
equatable: ^2.0.5
# Navigation
go_router: ^13.2.1
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.0
flutter:
uses-material-design: true