Merge 592-migrate-frontend-applications-to-v2-backend-and-database into feature/session-persistence-new

This commit is contained in:
2026-03-18 12:51:23 +05:30
660 changed files with 18935 additions and 21383 deletions

View File

@@ -2,7 +2,8 @@ library;
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krow_domain/krow_domain.dart';
import 'src/data/repositories_impl/auth_repository_impl.dart';
import 'src/domain/repositories/auth_repository_interface.dart';
import 'src/domain/usecases/sign_in_with_email_use_case.dart';
@@ -21,14 +22,19 @@ export 'src/presentation/pages/client_sign_up_page.dart';
export 'package:core_localization/core_localization.dart';
/// A [Module] for the client authentication feature.
///
/// Imports [CoreModule] for [BaseApiService] and registers repositories,
/// use cases, and BLoCs for the client authentication flow.
class ClientAuthenticationModule extends Module {
@override
List<Module> get imports => <Module>[DataConnectModule()];
List<Module> get imports => <Module>[CoreModule()];
@override
void binds(Injector i) {
// Repositories
i.addLazySingleton<AuthRepositoryInterface>(AuthRepositoryImpl.new);
i.addLazySingleton<AuthRepositoryInterface>(
() => AuthRepositoryImpl(apiService: i.get<BaseApiService>()),
);
// UseCases
i.addLazySingleton(

View File

@@ -1,68 +1,80 @@
import 'dart:developer' as developer;
import 'package:client_authentication/src/domain/repositories/auth_repository_interface.dart';
import 'package:firebase_auth/firebase_auth.dart' as firebase;
import 'package:firebase_data_connect/firebase_data_connect.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart'
show
AccountExistsException,
ApiResponse,
AppException,
BaseApiService,
ClientSession,
InvalidCredentialsException,
NetworkException,
PasswordMismatchException,
SignInFailedException,
SignUpFailedException,
WeakPasswordException,
AccountExistsException,
UserNotFoundException,
UnauthorizedAppException,
PasswordMismatchException,
NetworkException;
import 'package:krow_domain/krow_domain.dart' as domain;
User,
UserStatus,
WeakPasswordException;
import '../../domain/repositories/auth_repository_interface.dart';
/// Production-ready implementation of the [AuthRepositoryInterface] for the client app.
/// Production implementation of the [AuthRepositoryInterface] for the client app.
///
/// This implementation integrates with Firebase Authentication for user
/// identity management and KROW's Data Connect SDK for storing user profile data.
/// Uses Firebase Auth client-side for sign-in (to maintain local auth state for
/// the [AuthInterceptor]), then calls V2 `GET /auth/session` to retrieve
/// business context. Sign-up provisioning (tenant, business, memberships) is
/// handled entirely server-side by the V2 API.
class AuthRepositoryImpl implements AuthRepositoryInterface {
/// Creates an [AuthRepositoryImpl] with the real dependencies.
AuthRepositoryImpl({dc.DataConnectService? service})
: _service = service ?? dc.DataConnectService.instance;
/// Creates an [AuthRepositoryImpl] with the given [BaseApiService].
AuthRepositoryImpl({required BaseApiService apiService})
: _apiService = apiService;
final dc.DataConnectService _service;
/// The V2 API service for backend calls.
final BaseApiService _apiService;
/// Firebase Auth instance for client-side sign-in/sign-up.
firebase.FirebaseAuth get _auth => firebase.FirebaseAuth.instance;
@override
Future<domain.User> signInWithEmail({
Future<User> signInWithEmail({
required String email,
required String password,
}) async {
try {
final firebase.UserCredential credential = await _service.auth
.signInWithEmailAndPassword(email: email, password: password);
// Step 1: Call V2 sign-in endpoint — server handles Firebase Auth
// via Identity Toolkit and returns a full auth envelope.
final ApiResponse response = await _apiService.post(
AuthEndpoints.clientSignIn,
data: <String, dynamic>{
'email': email,
'password': password,
},
);
final Map<String, dynamic> body =
response.data as Map<String, dynamic>;
// Step 2: Sign in locally so AuthInterceptor can attach Bearer tokens
// to subsequent requests. The V2 API already validated credentials, so
// email/password sign-in establishes the local Firebase Auth state.
final firebase.UserCredential credential =
await _auth.signInWithEmailAndPassword(
email: email,
password: password,
);
final firebase.User? firebaseUser = credential.user;
if (firebaseUser == null) {
throw const SignInFailedException(
technicalMessage: 'No Firebase user received after sign-in',
technicalMessage: 'Local Firebase sign-in failed after V2 sign-in',
);
}
return _getUserProfile(
firebaseUserId: firebaseUser.uid,
fallbackEmail: firebaseUser.email ?? email,
requireBusinessRole: true,
);
} on firebase.FirebaseAuthException catch (e) {
if (e.code == 'invalid-credential' || e.code == 'wrong-password') {
throw InvalidCredentialsException(
technicalMessage: 'Firebase error code: ${e.code}',
);
} else if (e.code == 'network-request-failed') {
throw NetworkException(technicalMessage: 'Firebase: ${e.message}');
} else {
throw SignInFailedException(
technicalMessage: 'Firebase auth error: ${e.message}',
);
}
} on domain.AppException {
// Step 3: Populate session store from the V2 auth envelope directly
// (no need for a separate GET /auth/session call).
return _populateStoreFromAuthEnvelope(body, firebaseUser, email);
} on AppException {
rethrow;
} catch (e) {
throw SignInFailedException(technicalMessage: 'Unexpected error: $e');
@@ -70,50 +82,52 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
}
@override
Future<domain.User> signUpWithEmail({
Future<User> signUpWithEmail({
required String companyName,
required String email,
required String password,
}) async {
firebase.User? firebaseUser;
String? createdBusinessId;
try {
// Step 1: Try to create Firebase Auth user
final firebase.UserCredential credential = await _service.auth
.createUserWithEmailAndPassword(email: email, password: password);
// Step 1: Call V2 sign-up endpoint which handles everything server-side:
// - Creates Firebase Auth account via Identity Toolkit
// - Creates user, tenant, business, memberships in one transaction
// - Returns full auth envelope with session tokens
final ApiResponse response = await _apiService.post(
AuthEndpoints.clientSignUp,
data: <String, dynamic>{
'companyName': companyName,
'email': email,
'password': password,
},
);
firebaseUser = credential.user;
final Map<String, dynamic> body = response.data as Map<String, dynamic>;
// Step 2: Sign in locally to Firebase Auth so AuthInterceptor works
// for subsequent requests. The V2 API already created the Firebase
// account, so this should succeed.
final firebase.UserCredential credential =
await _auth.signInWithEmailAndPassword(
email: email,
password: password,
);
final firebase.User? firebaseUser = credential.user;
if (firebaseUser == null) {
throw const SignUpFailedException(
technicalMessage: 'Firebase user could not be created',
technicalMessage: 'Local Firebase sign-in failed after V2 sign-up',
);
}
// Force-refresh the ID token so the Data Connect SDK has a valid bearer
// token before we fire any mutations. Without this, there is a race
// condition where the gRPC layer sends the request unauthenticated
// immediately after account creation (gRPC code 16 UNAUTHENTICATED).
await firebaseUser.getIdToken(true);
// New user created successfully, proceed to create PostgreSQL entities
return await _createBusinessAndUser(
firebaseUser: firebaseUser,
companyName: companyName,
email: email,
onBusinessCreated: (String businessId) =>
createdBusinessId = businessId,
);
// Step 3: Populate store from the sign-up response envelope.
return _populateStoreFromAuthEnvelope(body, firebaseUser, email);
} on firebase.FirebaseAuthException catch (e) {
if (e.code == 'weak-password') {
throw WeakPasswordException(technicalMessage: 'Firebase: ${e.message}');
} else if (e.code == 'email-already-in-use') {
// Email exists in Firebase Auth - try to sign in and complete registration
return await _handleExistingFirebaseAccount(
email: email,
password: password,
companyName: companyName,
if (e.code == 'email-already-in-use') {
throw AccountExistsException(
technicalMessage: 'Firebase: ${e.message}',
);
} else if (e.code == 'weak-password') {
throw WeakPasswordException(technicalMessage: 'Firebase: ${e.message}');
} else if (e.code == 'network-request-failed') {
throw NetworkException(technicalMessage: 'Firebase: ${e.message}');
} else {
@@ -121,304 +135,116 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
technicalMessage: 'Firebase auth error: ${e.message}',
);
}
} on domain.AppException {
// Rollback for our known exceptions
await _rollbackSignUp(
firebaseUser: firebaseUser,
businessId: createdBusinessId,
);
} on AppException {
rethrow;
} catch (e) {
// Rollback: Clean up any partially created resources
await _rollbackSignUp(
firebaseUser: firebaseUser,
businessId: createdBusinessId,
);
throw SignUpFailedException(technicalMessage: 'Unexpected error: $e');
}
}
/// Handles the case where email already exists in Firebase Auth.
///
/// This can happen when:
/// 1. User signed up with Google in another app sharing the same Firebase project
/// 2. User already has a KROW account
///
/// The flow:
/// 1. Try to sign in with provided password
/// 2. If sign-in succeeds, check if BUSINESS user exists in PostgreSQL
/// 3. If not, create Business + User (user is new to KROW)
/// 4. If yes, they already have a KROW account
Future<domain.User> _handleExistingFirebaseAccount({
required String email,
required String password,
required String companyName,
}) async {
developer.log(
'Email exists in Firebase, attempting sign-in: $email',
name: 'AuthRepository',
);
try {
// Try to sign in with the provided password
final firebase.UserCredential credential = await _service.auth
.signInWithEmailAndPassword(email: email, password: password);
final firebase.User? firebaseUser = credential.user;
if (firebaseUser == null) {
throw const SignUpFailedException(
technicalMessage: 'Sign-in succeeded but no user returned',
);
}
// Force-refresh the ID token so the Data Connect SDK receives a valid
// bearer token before any subsequent Data Connect queries run.
await firebaseUser.getIdToken(true);
// Sign-in succeeded! Check if user already has a BUSINESS account in PostgreSQL
final bool hasBusinessAccount = await _checkBusinessUserExists(
firebaseUser.uid,
);
if (hasBusinessAccount) {
// User already has a KROW Client account
developer.log(
'User already has BUSINESS account: ${firebaseUser.uid}',
name: 'AuthRepository',
);
throw AccountExistsException(
technicalMessage:
'User ${firebaseUser.uid} already has BUSINESS role',
);
}
// User exists in Firebase but not in KROW PostgreSQL - create the entities
developer.log(
'Creating BUSINESS account for existing Firebase user: ${firebaseUser.uid}',
name: 'AuthRepository',
);
return await _createBusinessAndUser(
firebaseUser: firebaseUser,
companyName: companyName,
email: email,
onBusinessCreated:
(_) {}, // No rollback needed for existing Firebase user
);
} on firebase.FirebaseAuthException catch (e) {
// Sign-in failed - check why
developer.log(
'Sign-in failed with code: ${e.code}',
name: 'AuthRepository',
);
if (e.code == 'wrong-password' || e.code == 'invalid-credential') {
// Password doesn't match - check what providers are available
return await _handlePasswordMismatch(email);
} else {
throw SignUpFailedException(
technicalMessage: 'Firebase sign-in error: ${e.message}',
);
}
} on domain.AppException {
rethrow;
}
}
/// Handles the case where the password doesn't match the existing account.
///
/// Note: fetchSignInMethodsForEmail was deprecated by Firebase for security
/// reasons (email enumeration). We show a combined message that covers both
/// cases: wrong password OR account uses different sign-in method (Google).
Future<Never> _handlePasswordMismatch(String email) async {
// We can't distinguish between "wrong password" and "no password provider"
// due to Firebase deprecating fetchSignInMethodsForEmail.
// The PasswordMismatchException message covers both scenarios.
developer.log(
'Password mismatch or different provider for: $email',
name: 'AuthRepository',
);
throw PasswordMismatchException(
technicalMessage:
'Email $email: password mismatch or different auth provider',
);
}
/// Checks if a user with BUSINESS role exists in PostgreSQL.
Future<bool> _checkBusinessUserExists(String firebaseUserId) async {
final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> response =
await _service.run(
() => _service.connector.getUserById(id: firebaseUserId).execute(),
);
final dc.GetUserByIdUser? user = response.data.user;
return user != null &&
(user.userRole == 'BUSINESS' || user.userRole == 'BOTH');
}
/// Creates Business and User entities in PostgreSQL for a Firebase user.
Future<domain.User> _createBusinessAndUser({
required firebase.User firebaseUser,
required String companyName,
required String email,
required void Function(String businessId) onBusinessCreated,
}) async {
// Create Business entity in PostgreSQL
final OperationResult<dc.CreateBusinessData, dc.CreateBusinessVariables>
createBusinessResponse = await _service.run(
() => _service.connector
.createBusiness(
businessName: companyName,
userId: firebaseUser.uid,
rateGroup: dc.BusinessRateGroup.STANDARD,
status: dc.BusinessStatus.PENDING,
)
.execute(),
);
final dc.CreateBusinessBusinessInsert businessData =
createBusinessResponse.data.business_insert;
onBusinessCreated(businessData.id);
// Check if User entity already exists in PostgreSQL
final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> userResult =
await _service.run(
() => _service.connector.getUserById(id: firebaseUser.uid).execute(),
);
final dc.GetUserByIdUser? existingUser = userResult.data.user;
if (existingUser != null) {
// User exists (likely in another app like STAFF). Update role to BOTH.
await _service.run(
() => _service.connector
.updateUser(id: firebaseUser.uid)
.userRole('BOTH')
.execute(),
);
} else {
// Create new User entity in PostgreSQL
await _service.run(
() => _service.connector
.createUser(id: firebaseUser.uid, role: dc.UserBaseRole.USER)
.email(email)
.userRole('BUSINESS')
.execute(),
);
}
return _getUserProfile(
firebaseUserId: firebaseUser.uid,
fallbackEmail: firebaseUser.email ?? email,
);
}
/// Rollback helper to clean up partially created resources during sign-up.
Future<void> _rollbackSignUp({
firebase.User? firebaseUser,
String? businessId,
}) async {
// Delete business first (if created)
if (businessId != null) {
try {
await _service.connector.deleteBusiness(id: businessId).execute();
} catch (_) {
// Log but don't throw - we're already in error recovery
}
}
// Delete Firebase user (if created)
if (firebaseUser != null) {
try {
await firebaseUser.delete();
} catch (_) {
// Log but don't throw - we're already in error recovery
}
}
}
@override
Future<void> signOut() async {
try {
await _service.signOut();
} catch (e) {
throw Exception('Error signing out: ${e.toString()}');
}
}
@override
Future<domain.User> signInWithSocial({required String provider}) {
Future<User> signInWithSocial({required String provider}) {
throw UnimplementedError(
'Social authentication with $provider is not yet implemented.',
);
}
Future<domain.User> _getUserProfile({
required String firebaseUserId,
required String? fallbackEmail,
bool requireBusinessRole = false,
}) async {
final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> response =
await _service.run(
() => _service.connector.getUserById(id: firebaseUserId).execute(),
);
final dc.GetUserByIdUser? user = response.data.user;
if (user == null) {
throw UserNotFoundException(
technicalMessage:
'Firebase UID $firebaseUserId not found in users table',
);
}
if (requireBusinessRole &&
user.userRole != 'BUSINESS' &&
user.userRole != 'BOTH') {
await _service.signOut();
throw UnauthorizedAppException(
technicalMessage:
'User role is ${user.userRole}, expected BUSINESS or BOTH',
@override
Future<void> signOut() async {
try {
// Step 1: Call V2 sign-out endpoint for server-side token revocation.
await _apiService.post(AuthEndpoints.clientSignOut);
} catch (e) {
developer.log(
'V2 sign-out request failed: $e',
name: 'AuthRepository',
);
// Continue with local sign-out even if server-side fails.
}
final String? email = user.email ?? fallbackEmail;
if (email == null || email.isEmpty) {
throw UserNotFoundException(
technicalMessage: 'User email missing for UID $firebaseUserId',
);
try {
// Step 2: Sign out from local Firebase Auth.
await _auth.signOut();
} catch (e) {
throw Exception('Error signing out locally: $e');
}
final domain.User domainUser = domain.User(
id: user.id,
// Step 3: Clear the client session store.
ClientSessionStore.instance.clear();
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
/// Populates the session store from a V2 auth envelope response and
/// returns a domain [User].
User _populateStoreFromAuthEnvelope(
Map<String, dynamic> envelope,
firebase.User firebaseUser,
String fallbackEmail,
) {
final Map<String, dynamic>? userJson =
envelope['user'] as Map<String, dynamic>?;
final Map<String, dynamic>? businessJson =
envelope['business'] as Map<String, dynamic>?;
if (businessJson != null) {
// The auth envelope from buildAuthEnvelope uses `user.id` but
// ClientSession.fromJson expects `user.userId` (matching the shape
// returned by loadActorContext / GET /client/session). Normalise the
// key so the session is populated correctly.
final Map<String, dynamic> normalisedEnvelope = <String, dynamic>{
...envelope,
if (userJson != null)
'user': <String, dynamic>{
...userJson,
'userId': userJson['id'] ?? userJson['userId'],
},
};
final ClientSession clientSession =
ClientSession.fromJson(normalisedEnvelope);
ClientSessionStore.instance.setSession(clientSession);
}
final String userId =
userJson?['id'] as String? ?? firebaseUser.uid;
final String? email = userJson?['email'] as String? ?? fallbackEmail;
return User(
id: userId,
email: email,
role: user.role.stringValue,
displayName: userJson?['displayName'] as String?,
phone: userJson?['phone'] as String?,
status: _parseUserStatus(userJson?['status'] as String?),
);
}
final QueryResult<
dc.GetBusinessesByUserIdData,
dc.GetBusinessesByUserIdVariables
>
businessResponse = await _service.run(
() => _service.connector
.getBusinessesByUserId(userId: firebaseUserId)
.execute(),
);
final dc.GetBusinessesByUserIdBusinesses? business =
businessResponse.data.businesses.isNotEmpty
? businessResponse.data.businesses.first
: null;
/// Maps a V2 error code to the appropriate domain exception for sign-up.
Never _throwSignUpError(String errorCode, String message) {
switch (errorCode) {
case 'AUTH_PROVIDER_ERROR' when message.contains('EMAIL_EXISTS'):
throw AccountExistsException(technicalMessage: message);
case 'AUTH_PROVIDER_ERROR' when message.contains('WEAK_PASSWORD'):
throw WeakPasswordException(technicalMessage: message);
case 'FORBIDDEN':
throw PasswordMismatchException(technicalMessage: message);
default:
throw SignUpFailedException(technicalMessage: '$errorCode: $message');
}
}
dc.ClientSessionStore.instance.setSession(
dc.ClientSession(
business: business == null
? null
: dc.ClientBusinessSession(
id: business.id,
businessName: business.businessName,
email: business.email,
city: business.city,
contactName: business.contactName,
companyLogoUrl: business.companyLogoUrl,
),
),
);
return domainUser;
/// Parses a status string from the API into a [UserStatus].
static UserStatus _parseUserStatus(String? value) {
switch (value?.toUpperCase()) {
case 'ACTIVE':
return UserStatus.active;
case 'INVITED':
return UserStatus.invited;
case 'DISABLED':
return UserStatus.disabled;
default:
return UserStatus.active;
}
}
}