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:
35
client_app/lib/core/api/api_client.dart
Normal file
35
client_app/lib/core/api/api_client.dart
Normal 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>;
|
||||
}
|
||||
}
|
||||
230
client_app/lib/core/auth/auth_bloc.dart
Normal file
230
client_app/lib/core/auth/auth_bloc.dart
Normal 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.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user