diff --git a/mitra_app/CLAUDE.md b/mitra_app/CLAUDE.md index 3bfbb8f..5638fee 100644 --- a/mitra_app/CLAUDE.md +++ b/mitra_app/CLAUDE.md @@ -7,19 +7,18 @@ Flutter mobile application for mental health professionals (mitra/partners). ## 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) +- **Auth:** Self-managed (Phase 3.4). Phone OTP only — no Google / Apple. Access token lives in memory on an `AuthBridge`; refresh token persists in `flutter_secure_storage`. `firebase_auth` is no longer used; `firebase_messaging` is kept for FCM push. +- **API:** Calls public Fastify backend (`/api/mitra/` and `/api/shared/` routes). `shared.auth` covers refresh + logout for both apps. ## 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 +- Core flow: phone OTP login → set availability → accept sessions → chat with client → receive payment +- Mitra accounts require approval from control center before going live (backend returns `ACCOUNT_INACTIVE` 403 on OTP verify when `is_active=false`) ## 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 +- API calls go through `ApiClient`; it auto-attaches the JWT from `AuthBridge` and auto-refreshes on 401 +- WebSocket handshake (`/api/shared/ws`) sends the same access token in the first frame's `{type:"auth", token}` message +- Mitra role is encoded in the JWT claims (`user_type: "mitra"`) — the backend enforces the role per route; never trust client state alone diff --git a/mitra_app/lib/core/api/api_client.dart b/mitra_app/lib/core/api/api_client.dart index 70108c9..98def3a 100644 --- a/mitra_app/lib/core/api/api_client.dart +++ b/mitra_app/lib/core/api/api_client.dart @@ -1,5 +1,10 @@ import 'package:dio/dio.dart'; -import 'package:firebase_auth/firebase_auth.dart'; +import '../auth/auth_bridge.dart'; + +/// Per-request flag: set `options.extra['skipAuth'] = true` to skip +/// token attachment + 401-refresh retry. Used by auth endpoints themselves +/// (login/refresh/OTP) to avoid recursion. +const _skipAuthKey = 'skipAuth'; class ApiClient { static const String baseUrl = String.fromEnvironment( @@ -7,19 +12,45 @@ class ApiClient { defaultValue: 'https://api.halobestie.com', ); + final AuthBridge _bridge; late final Dio _dio; - ApiClient() { + ApiClient(this._bridge) { _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'; + onRequest: (options, handler) { + if (options.extra[_skipAuthKey] != true) { + final token = _bridge.accessToken; + if (token != null) { + options.headers['Authorization'] = 'Bearer $token'; + } } handler.next(options); }, + onError: (err, handler) async { + final req = err.requestOptions; + final is401 = err.response?.statusCode == 401; + final alreadyRetried = req.extra['_retry'] == true; + final skipAuth = req.extra[_skipAuthKey] == true; + + if (is401 && !alreadyRetried && !skipAuth) { + final newToken = await _bridge.tryRefresh(); + if (newToken != null) { + req.extra['_retry'] = true; + req.headers['Authorization'] = 'Bearer $newToken'; + try { + final response = await _dio.fetch(req); + return handler.resolve(response); + } catch (e) { + if (e is DioException) return handler.next(e); + rethrow; + } + } else { + _bridge.notifyUnauthenticated(); + } + } + handler.next(err); + }, )); } @@ -37,4 +68,20 @@ class ApiClient { final response = await _dio.patch(path, data: data); return response.data as Map; } + + /// Raw POST with per-call control over auth attachment. Used by the auth + /// notifier for OTP / refresh endpoints where attaching a (possibly stale) + /// access token — or triggering a 401-refresh loop — would be wrong. + Future> postRaw( + String path, { + Map? data, + bool skipAuth = false, + }) async { + final response = await _dio.post( + path, + data: data, + options: Options(extra: {_skipAuthKey: skipAuth}), + ); + return response.data as Map; + } } diff --git a/mitra_app/lib/core/api/api_client_provider.dart b/mitra_app/lib/core/api/api_client_provider.dart index 1670e2f..a0a4359 100644 --- a/mitra_app/lib/core/api/api_client_provider.dart +++ b/mitra_app/lib/core/api/api_client_provider.dart @@ -1,8 +1,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../auth/auth_bridge.dart'; import 'api_client.dart'; part 'api_client_provider.g.dart'; @Riverpod(keepAlive: true) -ApiClient apiClient(Ref ref) => ApiClient(); +ApiClient apiClient(Ref ref) => ApiClient(ref.read(authBridgeProvider)); diff --git a/mitra_app/lib/core/api/api_client_provider.g.dart b/mitra_app/lib/core/api/api_client_provider.g.dart index c46826e..cf2cd18 100644 --- a/mitra_app/lib/core/api/api_client_provider.g.dart +++ b/mitra_app/lib/core/api/api_client_provider.g.dart @@ -6,7 +6,7 @@ part of 'api_client_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$apiClientHash() => r'90c807f03b90249684265cc91739139c2c89eeb9'; +String _$apiClientHash() => r'c0ade0ab10bbe40a30a4e5b17ea8c8c27517f502'; /// See also [apiClient]. @ProviderFor(apiClient) diff --git a/mitra_app/lib/core/auth/auth_bridge.dart b/mitra_app/lib/core/auth/auth_bridge.dart new file mode 100644 index 0000000..ce719ec --- /dev/null +++ b/mitra_app/lib/core/auth/auth_bridge.dart @@ -0,0 +1,52 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'auth_bridge.g.dart'; + +/// Shared mutable auth state: decouples api_client / WS notifiers +/// (read-only consumers) from auth_notifier (writer). Also de-dupes +/// concurrent 401-triggered refreshes. +/// +/// Auth notifier calls [setAccessToken] on successful auth / refresh, +/// registers [refresh] and [onUnauthenticated] callbacks on build. +class AuthBridge { + String? accessToken; + Future Function()? refresh; + void Function()? onUnauthenticated; + + Completer? _inFlight; + + void setAccessToken(String? token) { + accessToken = token; + } + + void clear() { + accessToken = null; + } + + Future tryRefresh() { + final existing = _inFlight; + if (existing != null) return existing.future; + final completer = Completer(); + _inFlight = completer; + () async { + try { + final next = await refresh?.call(); + completer.complete(next); + } catch (_) { + completer.complete(null); + } finally { + _inFlight = null; + } + }(); + return completer.future; + } + + void notifyUnauthenticated() { + onUnauthenticated?.call(); + } +} + +@Riverpod(keepAlive: true) +AuthBridge authBridge(Ref ref) => AuthBridge(); diff --git a/mitra_app/lib/core/auth/auth_bridge.g.dart b/mitra_app/lib/core/auth/auth_bridge.g.dart new file mode 100644 index 0000000..87d9114 --- /dev/null +++ b/mitra_app/lib/core/auth/auth_bridge.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'auth_bridge.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$authBridgeHash() => r'eb546dd1e79626bfaa8619d004b4cf64b596f65a'; + +/// See also [authBridge]. +@ProviderFor(authBridge) +final authBridgeProvider = Provider.internal( + authBridge, + name: r'authBridgeProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$authBridgeHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef AuthBridgeRef = ProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/mitra_app/lib/core/auth/auth_notifier.dart b/mitra_app/lib/core/auth/auth_notifier.dart index 5e83a74..94f57d7 100644 --- a/mitra_app/lib/core/auth/auth_notifier.dart +++ b/mitra_app/lib/core/auth/auth_notifier.dart @@ -1,14 +1,13 @@ import 'dart:async'; import 'package:dio/dio.dart'; -import 'package:firebase_auth/firebase_auth.dart'; -import 'package:flutter/foundation.dart' show kIsWeb; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../api/api_client.dart'; import '../api/api_client_provider.dart'; +import 'auth_bridge.dart'; +import 'token_storage.dart'; part 'auth_notifier.g.dart'; -// States sealed class MitraAuthData { const MitraAuthData(); } @@ -23,103 +22,170 @@ class MitraAuthAuthenticatedData extends MitraAuthData { } class MitraAuthOtpSentData extends MitraAuthData { - final String verificationId; - const MitraAuthOtpSentData(this.verificationId); + final String otpRequestId; + final String? channelUsed; + const MitraAuthOtpSentData(this.otpRequestId, {this.channelUsed}); } @Riverpod(keepAlive: true) class MitraAuth extends _$MitraAuth { - FirebaseAuth get _auth => FirebaseAuth.instance; + final _storage = TokenStorage(); + ApiClient get _apiClient => ref.read(apiClientProvider); - ConfirmationResult? _webConfirmationResult; + AuthBridge get _bridge => ref.read(authBridgeProvider); @override FutureOr build() async { - if (_auth.currentUser != null) { - return await _verifyAndReturn(); + _bridge.refresh = _refreshForBridge; + _bridge.onUnauthenticated = () { + _bridge.clear(); + state = const AsyncData(MitraAuthInitialData()); + }; + + final profile = await _refreshFromStorage(); + if (profile != null) { + return MitraAuthAuthenticatedData(profile); } - return const MitraAuthInitialData(); // FIX: was missing in BLoC version + return const MitraAuthInitialData(); } - Future requestOtp(String phone) async { - state = const AsyncLoading(); - if (kIsWeb) { - try { - final confirmationResult = await _auth.signInWithPhoneNumber(phone); - _webConfirmationResult = confirmationResult; - state = const AsyncData(MitraAuthOtpSentData('web')); - } catch (e) { - state = AsyncError('Gagal mengirim OTP. Coba lagi.', StackTrace.current); - } - } else { - final completer = Completer(); - await _auth.verifyPhoneNumber( - phoneNumber: phone, - verificationCompleted: (credential) async { - try { - await _auth.signInWithCredential(credential); - state = AsyncData(await _verifyAndReturn()); - } catch (_) {} - if (!completer.isCompleted) completer.complete(); - }, - verificationFailed: (e) { - state = AsyncError('Gagal mengirim OTP. Coba lagi.', StackTrace.current); - if (!completer.isCompleted) completer.complete(); - }, - codeSent: (verificationId, _) { - state = AsyncData(MitraAuthOtpSentData(verificationId)); - if (!completer.isCompleted) completer.complete(); - }, - codeAutoRetrievalTimeout: (_) { - if (!completer.isCompleted) completer.complete(); - }, + /// Reads the stored refresh token, rotates it against the backend, and + /// returns the fresh profile. Also updates the [AuthBridge] access token + /// and persists the new refresh token. Returns null if no token is stored + /// or the refresh call fails (refresh expired, revoked, etc). + Future?> _refreshFromStorage() async { + final refreshToken = await _storage.readRefreshToken(); + if (refreshToken == null) return null; + + try { + final response = await _apiClient.postRaw( + '/api/shared/auth/refresh', + data: {'refresh_token': refreshToken}, + skipAuth: true, ); - await completer.future; + final data = response['data'] as Map; + final newAccess = data['access_token'] as String; + final newRefresh = data['refresh_token'] as String; + final profile = data['profile'] as Map; + + await _storage.writeRefreshToken(newRefresh); + _bridge.setAccessToken(newAccess); + + // Sync notifier state if it was already authenticated (e.g. mid-session + // 401 refresh). On initial bootstrap, build() sets state from our return. + final current = state.valueOrNull; + if (current is MitraAuthAuthenticatedData) { + state = AsyncData(MitraAuthAuthenticatedData(profile)); + } + return profile; + } catch (_) { + await _storage.clear(); + _bridge.clear(); + final current = state.valueOrNull; + if (current is MitraAuthAuthenticatedData) { + state = const AsyncData(MitraAuthInitialData()); + } + return null; } } - Future verifyOtp(String verificationId, String smsCode) async { + /// Thin adapter for [AuthBridge.refresh]: returns the rotated access + /// token (or null on failure) so api_client can retry a 401. + Future _refreshForBridge() async { + final profile = await _refreshFromStorage(); + return profile == null ? null : _bridge.accessToken; + } + + Future requestOtp(String phone, {String? channel}) async { state = const AsyncLoading(); try { - if (kIsWeb && _webConfirmationResult != null) { - await _webConfirmationResult!.confirm(smsCode); - } else if (_auth.currentUser == null) { - // Only sign in if not already signed in via auto-verification - final credential = PhoneAuthProvider.credential( - verificationId: verificationId, - smsCode: smsCode, - ); - await _auth.signInWithCredential(credential); - } - state = AsyncData(await _verifyAndReturn()); - } on FirebaseAuthException { - state = AsyncError('OTP tidak valid. Coba lagi.', StackTrace.current); - } catch (e) { - state = AsyncError(e.toString().replaceFirst('Exception: ', ''), StackTrace.current); + final response = await _apiClient.postRaw( + '/api/mitra/auth/otp/request', + data: { + 'phone': phone, + if (channel != null) 'channel': channel, + }, + skipAuth: true, + ); + final data = response['data'] as Map; + state = AsyncData(MitraAuthOtpSentData( + data['otp_request_id'] as String, + channelUsed: data['channel_used'] as String?, + )); + } on DioException catch (e) { + state = AsyncError(_otpRequestMessage(e), StackTrace.current); + } catch (_) { + state = AsyncError('Gagal mengirim OTP. Coba lagi.', StackTrace.current); + } + } + + Future verifyOtp(String otpRequestId, String code) async { + state = const AsyncLoading(); + try { + final response = await _apiClient.postRaw( + '/api/mitra/auth/otp/verify', + data: {'otp_request_id': otpRequestId, 'code': code}, + skipAuth: true, + ); + final data = response['data'] as Map; + final accessToken = data['access_token'] as String; + final refreshToken = data['refresh_token'] as String; + final profile = data['profile'] as Map; + + await _storage.writeRefreshToken(refreshToken); + _bridge.setAccessToken(accessToken); + state = AsyncData(MitraAuthAuthenticatedData(profile)); + } on DioException catch (e) { + state = AsyncError(_otpVerifyMessage(e), StackTrace.current); + } catch (_) { + state = AsyncError('Gagal verifikasi. Coba lagi.', StackTrace.current); } } Future logout() async { - await _auth.signOut(); + // Best-effort server-side revocation; local clear always runs. + try { + await _apiClient.post('/api/shared/auth/logout'); + } catch (_) {} + await _storage.clear(); + _bridge.clear(); state = const AsyncData(MitraAuthInitialData()); } - Future _verifyAndReturn() async { - try { - final response = await _apiClient.post('/api/mitra/auth/verify'); - return MitraAuthAuthenticatedData(response['data'] as Map); - } on DioException catch (e) { - await _auth.signOut(); - final code = e.response?.data?['error']?['code'] as String?; - if (code == 'ACCOUNT_NOT_FOUND' || e.response?.statusCode == 404) { - throw Exception('Akun tidak ditemukan. Hubungi administrator.'); - } else if (code == 'ACCOUNT_INACTIVE' || e.response?.statusCode == 403) { - throw Exception('Akun tidak aktif. Hubungi administrator.'); - } - throw Exception('Gagal masuk. Coba lagi.'); - } on Exception { - await _auth.signOut(); - throw Exception('Gagal masuk. Coba lagi.'); + String _otpRequestMessage(DioException e) { + final code = e.response?.data?['error']?['code'] as String?; + switch (code) { + case 'PHONE_INVALID': + return 'Nomor HP tidak valid.'; + case 'OTP_COOLDOWN': + return e.response?.data?['error']?['message'] as String? ?? + 'Tunggu sebentar sebelum minta OTP lagi.'; + case 'OTP_RATE_LIMIT_PHONE': + case 'OTP_RATE_LIMIT_IP': + return 'Terlalu banyak permintaan OTP. Coba lagi nanti.'; + default: + return 'Gagal mengirim OTP. Coba lagi.'; + } + } + + String _otpVerifyMessage(DioException e) { + final code = e.response?.data?['error']?['code'] as String?; + switch (code) { + case 'ACCOUNT_INACTIVE': + return 'Akun tidak aktif. Hubungi administrator.'; + case 'WRONG_FLOW': + return 'OTP tidak valid untuk login mitra.'; + case 'CODE_MISMATCH': + case 'CODE_INVALID': + return 'Kode OTP salah.'; + case 'OTP_EXPIRED': + return 'Kode OTP kedaluwarsa. Minta kode baru.'; + case 'OTP_USED': + return 'Kode OTP sudah digunakan.'; + case 'OTP_ATTEMPTS_EXCEEDED': + return 'Terlalu banyak percobaan. Minta kode baru.'; + default: + return 'Gagal verifikasi. Coba lagi.'; } } } diff --git a/mitra_app/lib/core/auth/auth_notifier.g.dart b/mitra_app/lib/core/auth/auth_notifier.g.dart index a7e733d..c3e5f57 100644 --- a/mitra_app/lib/core/auth/auth_notifier.g.dart +++ b/mitra_app/lib/core/auth/auth_notifier.g.dart @@ -6,7 +6,7 @@ part of 'auth_notifier.dart'; // RiverpodGenerator // ************************************************************************** -String _$mitraAuthHash() => r'ddb09225b47b4e7683c9f8ad46abc21d9fb7a37b'; +String _$mitraAuthHash() => r'c353c4b6cc0335c6276baa4029e361f4ec3b4a36'; /// See also [MitraAuth]. @ProviderFor(MitraAuth) diff --git a/mitra_app/lib/core/auth/token_storage.dart b/mitra_app/lib/core/auth/token_storage.dart new file mode 100644 index 0000000..c2254fd --- /dev/null +++ b/mitra_app/lib/core/auth/token_storage.dart @@ -0,0 +1,17 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +class TokenStorage { + static const _refreshKey = 'mitra_refresh_token'; + + final _storage = const FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), + ); + + Future readRefreshToken() => _storage.read(key: _refreshKey); + + Future writeRefreshToken(String token) => + _storage.write(key: _refreshKey, value: token); + + Future clear() => _storage.delete(key: _refreshKey); +} diff --git a/mitra_app/lib/core/chat/chat_request_notifier.dart b/mitra_app/lib/core/chat/chat_request_notifier.dart index a39a969..0c65a39 100644 --- a/mitra_app/lib/core/chat/chat_request_notifier.dart +++ b/mitra_app/lib/core/chat/chat_request_notifier.dart @@ -1,11 +1,11 @@ import 'dart:async'; import 'dart:convert'; import 'package:dio/dio.dart'; -import 'package:firebase_auth/firebase_auth.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import '../api/api_client.dart'; import '../api/api_client_provider.dart'; +import '../auth/auth_bridge.dart'; import '../constants.dart'; import '../notifications/notification_service.dart'; @@ -147,10 +147,9 @@ class ChatRequest extends _$ChatRequest { Future _connectWebSocket() async { try { - final user = FirebaseAuth.instance.currentUser; - if (user == null) return; + final token = ref.read(authBridgeProvider).accessToken; + if (token == null) return; - final token = await user.getIdToken(); final wsUrl = ApiClient.baseUrl .replaceFirst('https://', 'wss://') .replaceFirst('http://', 'ws://'); diff --git a/mitra_app/lib/core/chat/chat_request_notifier.g.dart b/mitra_app/lib/core/chat/chat_request_notifier.g.dart index 254be4e..624c779 100644 --- a/mitra_app/lib/core/chat/chat_request_notifier.g.dart +++ b/mitra_app/lib/core/chat/chat_request_notifier.g.dart @@ -6,7 +6,7 @@ part of 'chat_request_notifier.dart'; // RiverpodGenerator // ************************************************************************** -String _$chatRequestHash() => r'c80b16e371658fbbaca88a75b48e16a3c0e057b3'; +String _$chatRequestHash() => r'52aeaca594a44be3eeef7d264a1f311004b38416'; /// See also [ChatRequest]. @ProviderFor(ChatRequest) diff --git a/mitra_app/lib/core/chat/extension_notifier.g.dart b/mitra_app/lib/core/chat/extension_notifier.g.dart index 870fa01..ace2e3b 100644 --- a/mitra_app/lib/core/chat/extension_notifier.g.dart +++ b/mitra_app/lib/core/chat/extension_notifier.g.dart @@ -6,7 +6,7 @@ part of 'extension_notifier.dart'; // RiverpodGenerator // ************************************************************************** -String _$mitraExtensionHash() => r'4eed73b51454238e2cd40a255c148f232f281913'; +String _$mitraExtensionHash() => r'e1346601df43c42aea6f2bc984b507547507a57c'; /// See also [MitraExtension]. @ProviderFor(MitraExtension) diff --git a/mitra_app/lib/core/chat/mitra_chat_notifier.dart b/mitra_app/lib/core/chat/mitra_chat_notifier.dart index a76687d..b433629 100644 --- a/mitra_app/lib/core/chat/mitra_chat_notifier.dart +++ b/mitra_app/lib/core/chat/mitra_chat_notifier.dart @@ -1,11 +1,11 @@ import 'dart:async'; import 'dart:convert'; import 'package:dio/dio.dart'; -import 'package:firebase_auth/firebase_auth.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import '../api/api_client.dart'; import '../api/api_client_provider.dart'; +import '../auth/auth_bridge.dart'; import '../constants.dart'; part 'mitra_chat_notifier.g.dart'; @@ -137,8 +137,11 @@ class MitraChat extends _$MitraChat { createdAt: DateTime.parse(m['created_at'] as String), )).toList(); - final user = FirebaseAuth.instance.currentUser; - final token = await user?.getIdToken(); + final token = ref.read(authBridgeProvider).accessToken; + if (token == null) { + state = const MitraChatErrorData('Sesi berakhir. Silakan login ulang.'); + return; + } final wsUrl = ApiClient.baseUrl .replaceFirst('https://', 'wss://') .replaceFirst('http://', 'ws://'); diff --git a/mitra_app/lib/core/chat/mitra_chat_notifier.g.dart b/mitra_app/lib/core/chat/mitra_chat_notifier.g.dart index e3e9e1c..3d646f8 100644 --- a/mitra_app/lib/core/chat/mitra_chat_notifier.g.dart +++ b/mitra_app/lib/core/chat/mitra_chat_notifier.g.dart @@ -6,7 +6,7 @@ part of 'mitra_chat_notifier.dart'; // RiverpodGenerator // ************************************************************************** -String _$mitraChatHash() => r'827aa874dbcf49c17f94c0507f5e0a4064bcede3'; +String _$mitraChatHash() => r'd5f4819264b9c71ce29a640ee2cfee608ead5e9e'; /// See also [MitraChat]. @ProviderFor(MitraChat) diff --git a/mitra_app/lib/features/auth/screens/otp_screen.dart b/mitra_app/lib/features/auth/screens/otp_screen.dart index 326306c..ac2eaf9 100644 --- a/mitra_app/lib/features/auth/screens/otp_screen.dart +++ b/mitra_app/lib/features/auth/screens/otp_screen.dart @@ -15,14 +15,14 @@ class _OtpScreenState extends ConsumerState { final List _controllers = List.generate(6, (_) => TextEditingController()); final List _focusNodes = List.generate(6, (_) => FocusNode()); - String? _verificationId; + String? _otpRequestId; @override void initState() { super.initState(); final data = ref.read(mitraAuthProvider).valueOrNull; if (data is MitraAuthOtpSentData) { - _verificationId = data.verificationId; + _otpRequestId = data.otpRequestId; } } @@ -60,8 +60,8 @@ class _OtpScreenState extends ConsumerState { void _submit() { final otp = _otp; - if (otp.length != 6 || _verificationId == null) return; - ref.read(mitraAuthProvider.notifier).verifyOtp(_verificationId!, otp); + if (otp.length != 6 || _otpRequestId == null) return; + ref.read(mitraAuthProvider.notifier).verifyOtp(_otpRequestId!, otp); } @override @@ -69,10 +69,10 @@ class _OtpScreenState extends ConsumerState { final authState = ref.watch(mitraAuthProvider); final isLoading = authState is AsyncLoading; - // Update verification ID if state changes + // Update OTP request id if state changes (e.g. resend) final data = authState.valueOrNull; if (data is MitraAuthOtpSentData) { - _verificationId = data.verificationId; + _otpRequestId = data.otpRequestId; } ref.listen(mitraAuthProvider, (prev, next) { diff --git a/mitra_app/pubspec.lock b/mitra_app/pubspec.lock index 2ecf67e..fc3e1b6 100644 --- a/mitra_app/pubspec.lock +++ b/mitra_app/pubspec.lock @@ -161,6 +161,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" code_builder: dependency: transitive description: @@ -281,30 +289,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" - firebase_auth: - dependency: "direct main" - description: - name: firebase_auth - sha256: cfc2d970829202eca09e2896f0a5aa7c87302817ecc0bdfa954f026046bf10ba - url: "https://pub.dev" - source: hosted - version: "4.20.0" - firebase_auth_platform_interface: - dependency: transitive - description: - name: firebase_auth_platform_interface - sha256: a0270e1db3b2098a14cb2a2342b3cd2e7e458e0c391b1f64f6f78b14296ec093 - url: "https://pub.dev" - source: hosted - version: "7.3.0" - firebase_auth_web: - dependency: transitive - description: - name: firebase_auth_web - sha256: "64e067e763c6378b7e774e872f0f59f6812885e43020e25cde08f42e9459837b" - url: "https://pub.dev" - source: hosted - version: "5.12.0" firebase_core: dependency: "direct main" description: @@ -422,6 +406,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.1" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" flutter_test: dependency: "direct dev" description: flutter @@ -472,6 +504,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + hooks: + dependency: transitive + description: + name: hooks + sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" + url: "https://pub.dev" + source: hosted + version: "1.0.3" hooks_riverpod: dependency: "direct main" description: @@ -520,14 +560,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" js: dependency: transitive description: name: js - sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.6.7" json_annotation: dependency: transitive description: @@ -608,6 +664,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" package_config: dependency: transitive description: @@ -624,6 +696,54 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" petitparser: dependency: transitive description: @@ -632,6 +752,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -664,6 +792,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" riverpod: dependency: transitive description: @@ -877,6 +1013,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.5" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" xdg_directories: dependency: transitive description: @@ -902,5 +1046,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.10.0 <4.0.0" - flutter: ">=3.38.1" + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/mitra_app/pubspec.yaml b/mitra_app/pubspec.yaml index 5bf7dad..9d137f6 100644 --- a/mitra_app/pubspec.yaml +++ b/mitra_app/pubspec.yaml @@ -11,11 +11,13 @@ dependencies: flutter: sdk: flutter - # Firebase + # Firebase (Messaging only — Auth dropped in Phase 3.4, self-managed JWT now) firebase_core: ^2.27.1 - firebase_auth: ^4.18.0 firebase_messaging: ^14.7.15 + # Secure token storage (refresh + access) + flutter_secure_storage: ^9.2.2 + # HTTP & WebSocket dio: ^5.4.3 web_socket_channel: ^2.4.5