Phase 3.4: mitra_app self-managed auth cutover
Rips firebase_auth; phone OTP flow now talks directly to the new backend endpoints, JWT access token lives in memory, refresh token persists via flutter_secure_storage. WebSocket handshakes read the access token from AuthBridge instead of Firebase. Smoke-tested end-to-end against the backend via curl: - otp/request → read stub code from backend log → otp/verify - /api/mitra/auth/me + /api/shared/auth/refresh rotation - logout → post-logout refresh correctly fails REFRESH_INVALID - ACCOUNT_INACTIVE (403) + WRONG_FLOW (400) error paths verified - Debug APK links cleanly - pubspec: drop firebase_auth, add flutter_secure_storage - core/auth/auth_bridge.dart: shared mutable state (access token + refresh callback + in-flight de-dup) as keepAlive provider - core/auth/token_storage.dart: flutter_secure_storage wrapper - core/auth/auth_notifier.dart: bootstrap → refresh; requestOtp + verifyOtp via /api/mitra/auth/*; logout; granular OTP error codes - core/api/api_client.dart: Bearer from bridge + postRaw(skipAuth) for auth endpoints + single-retry 401 refresh - core/chat/*_notifier.dart: WS auth frame reads bridge.accessToken - features/auth/screens/otp_screen.dart: verificationId → otpRequestId - mitra_app/CLAUDE.md: Auth section rewritten (was stale on Firebase) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<String, dynamic>;
|
||||
}
|
||||
|
||||
/// 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<Map<String, dynamic>> postRaw(
|
||||
String path, {
|
||||
Map<String, dynamic>? data,
|
||||
bool skipAuth = false,
|
||||
}) async {
|
||||
final response = await _dio.post(
|
||||
path,
|
||||
data: data,
|
||||
options: Options(extra: {_skipAuthKey: skipAuth}),
|
||||
);
|
||||
return response.data as Map<String, dynamic>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'api_client_provider.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$apiClientHash() => r'90c807f03b90249684265cc91739139c2c89eeb9';
|
||||
String _$apiClientHash() => r'c0ade0ab10bbe40a30a4e5b17ea8c8c27517f502';
|
||||
|
||||
/// See also [apiClient].
|
||||
@ProviderFor(apiClient)
|
||||
|
||||
52
mitra_app/lib/core/auth/auth_bridge.dart
Normal file
52
mitra_app/lib/core/auth/auth_bridge.dart
Normal file
@@ -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<String?> Function()? refresh;
|
||||
void Function()? onUnauthenticated;
|
||||
|
||||
Completer<String?>? _inFlight;
|
||||
|
||||
void setAccessToken(String? token) {
|
||||
accessToken = token;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
accessToken = null;
|
||||
}
|
||||
|
||||
Future<String?> tryRefresh() {
|
||||
final existing = _inFlight;
|
||||
if (existing != null) return existing.future;
|
||||
final completer = Completer<String?>();
|
||||
_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();
|
||||
26
mitra_app/lib/core/auth/auth_bridge.g.dart
Normal file
26
mitra_app/lib/core/auth/auth_bridge.g.dart
Normal file
@@ -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<AuthBridge>.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<AuthBridge>;
|
||||
// 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
|
||||
@@ -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<MitraAuthData> 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<void> 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<void>();
|
||||
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<Map<String, dynamic>?> _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<String, dynamic>;
|
||||
final newAccess = data['access_token'] as String;
|
||||
final newRefresh = data['refresh_token'] as String;
|
||||
final profile = data['profile'] as Map<String, dynamic>;
|
||||
|
||||
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<void> 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<String?> _refreshForBridge() async {
|
||||
final profile = await _refreshFromStorage();
|
||||
return profile == null ? null : _bridge.accessToken;
|
||||
}
|
||||
|
||||
Future<void> 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<String, dynamic>;
|
||||
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<void> 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<String, dynamic>;
|
||||
final accessToken = data['access_token'] as String;
|
||||
final refreshToken = data['refresh_token'] as String;
|
||||
final profile = data['profile'] as Map<String, dynamic>;
|
||||
|
||||
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<void> 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<MitraAuthData> _verifyAndReturn() async {
|
||||
try {
|
||||
final response = await _apiClient.post('/api/mitra/auth/verify');
|
||||
return MitraAuthAuthenticatedData(response['data'] as Map<String, dynamic>);
|
||||
} 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.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'auth_notifier.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$mitraAuthHash() => r'ddb09225b47b4e7683c9f8ad46abc21d9fb7a37b';
|
||||
String _$mitraAuthHash() => r'c353c4b6cc0335c6276baa4029e361f4ec3b4a36';
|
||||
|
||||
/// See also [MitraAuth].
|
||||
@ProviderFor(MitraAuth)
|
||||
|
||||
17
mitra_app/lib/core/auth/token_storage.dart
Normal file
17
mitra_app/lib/core/auth/token_storage.dart
Normal file
@@ -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<String?> readRefreshToken() => _storage.read(key: _refreshKey);
|
||||
|
||||
Future<void> writeRefreshToken(String token) =>
|
||||
_storage.write(key: _refreshKey, value: token);
|
||||
|
||||
Future<void> clear() => _storage.delete(key: _refreshKey);
|
||||
}
|
||||
@@ -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<void> _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://');
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'chat_request_notifier.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$chatRequestHash() => r'c80b16e371658fbbaca88a75b48e16a3c0e057b3';
|
||||
String _$chatRequestHash() => r'52aeaca594a44be3eeef7d264a1f311004b38416';
|
||||
|
||||
/// See also [ChatRequest].
|
||||
@ProviderFor(ChatRequest)
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'extension_notifier.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$mitraExtensionHash() => r'4eed73b51454238e2cd40a255c148f232f281913';
|
||||
String _$mitraExtensionHash() => r'e1346601df43c42aea6f2bc984b507547507a57c';
|
||||
|
||||
/// See also [MitraExtension].
|
||||
@ProviderFor(MitraExtension)
|
||||
|
||||
@@ -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://');
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'mitra_chat_notifier.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$mitraChatHash() => r'827aa874dbcf49c17f94c0507f5e0a4064bcede3';
|
||||
String _$mitraChatHash() => r'd5f4819264b9c71ce29a640ee2cfee608ead5e9e';
|
||||
|
||||
/// See also [MitraChat].
|
||||
@ProviderFor(MitraChat)
|
||||
|
||||
@@ -15,14 +15,14 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
||||
final List<TextEditingController> _controllers =
|
||||
List.generate(6, (_) => TextEditingController());
|
||||
final List<FocusNode> _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<OtpScreen> {
|
||||
|
||||
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<OtpScreen> {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user