Phase 3.4: client_app self-managed auth cutover

Rips firebase_auth; auth talks directly to the new backend endpoints.
Anonymous-first + phone OTP work end-to-end; Google/Apple SDKs are kept
but buttons are hidden behind ENABLE_SOCIAL_AUTH until backend OAuth
credentials are provisioned.

Smoke-tested against the backend via curl:
- anonymous → PATCH display_name → /me
- OTP request (read stub code from backend log) → verify with
  anonymous_customer_id → same customer row preserved, display_name
  preserved, phone added → upgrade confirmed
- refresh rotation + logout → post-logout refresh correctly fails
  REFRESH_INVALID
- Debug APK builds clean

- pubspec: drop firebase_auth; add flutter_secure_storage
- core/auth/auth_bridge.dart: shared mutable state (access token +
  refresh callback + in-flight de-dup) — keepAlive provider
- core/auth/token_storage.dart: flutter_secure_storage wrapper
  (customer_refresh_token key)
- core/auth/social_auth_enabled.dart: const flag from
  --dart-define=ENABLE_SOCIAL_AUTH (default false)
- core/auth/auth_notifier.dart: bootstrap via stored refresh; anonymous
  via /api/shared/auth/anonymous + PATCH display_name; phone OTP via
  /api/client/auth/*; Google + Apple wired (passes anonymous_customer_id
  for upgrade); anonymity config check for ForceRegister state; granular
  error-code mapping
- core/api/api_client.dart: Bearer from bridge + postRaw(skipAuth) for
  auth endpoints + single-retry 401 refresh
- core/chat/chat_notifier.dart + core/pairing/pairing_notifier.dart: WS
  auth frame reads bridge.accessToken
- features/auth/screens/otp_screen.dart: verificationId → otpRequestId
- features/auth/screens/register_screen.dart + force_register_screen.dart:
  Google/Apple buttons gated behind kSocialAuthEnabled; force_register
  drops obsolete linkAccount() (upgrade happens server-side now via
  anonymous_customer_id)
- client_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:
2026-04-24 16:08:20 +08:00
parent 2b61c79a86
commit 98156d1e49
25 changed files with 722 additions and 269 deletions

View File

@@ -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
/// (anonymous/refresh/OTP/Google/Apple) to avoid recursion.
const _skipAuthKey = 'skipAuth';
class ApiClient {
static const String baseUrl = String.fromEnvironment(
@@ -7,25 +12,46 @@ 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;
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>;
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);
},
));
}
Future<Map<String, dynamic>> get(String path, {Map<String, dynamic>? queryParameters}) async {
@@ -33,8 +59,30 @@ class ApiClient {
return response.data as Map<String, dynamic>;
}
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>> patch(String path, {Map<String, dynamic>? data}) async {
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 anonymous / refresh / OTP / Google / Apple endpoints where
/// attaching a 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>;
}
}

View File

@@ -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));

View File

@@ -6,7 +6,7 @@ part of 'api_client_provider.dart';
// RiverpodGenerator
// **************************************************************************
String _$apiClientHash() => r'90c807f03b90249684265cc91739139c2c89eeb9';
String _$apiClientHash() => r'c0ade0ab10bbe40a30a4e5b17ea8c8c27517f502';
/// See also [apiClient].
@ProviderFor(apiClient)

View File

@@ -0,0 +1,49 @@
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.
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();

View 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

View File

@@ -1,15 +1,17 @@
import 'dart:async';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:dio/dio.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sign_in_with_apple/sign_in_with_apple.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 AuthData {
const AuthData();
}
@@ -18,209 +20,384 @@ class AuthInitialData extends AuthData {
const AuthInitialData();
}
/// Signed-in with a server-generated anonymous customer (no identity yet).
class AuthAnonymousData extends AuthData {
final Map<String, dynamic> profile;
const AuthAnonymousData(this.profile);
String get customerId => profile['id'] as String;
String get displayName => (profile['display_name'] as String?) ?? '';
}
/// Signed-in with a real identity (phone / google / apple).
class AuthAuthenticatedData extends AuthData {
final Map<String, dynamic> profile;
const AuthAuthenticatedData(this.profile);
}
class AuthAnonymousData extends AuthData {
final String customerId;
final String displayName;
const AuthAnonymousData({required this.customerId, required this.displayName});
}
/// Phone OTP requested; waiting for the code.
class AuthOtpSentData extends AuthData {
final String verificationId;
const AuthOtpSentData(this.verificationId);
final String otpRequestId;
final String? channelUsed;
const AuthOtpSentData(this.otpRequestId, {this.channelUsed});
}
/// Anonymity has been disabled by admin — user must identify before continuing.
class AuthForceRegisterData extends AuthData {
final String customerId;
final String displayName;
const AuthForceRegisterData({required this.customerId, required this.displayName});
final Map<String, dynamic> profile;
const AuthForceRegisterData(this.profile);
String get customerId => profile['id'] as String;
String get displayName => (profile['display_name'] as String?) ?? '';
}
/// Authenticated but display_name is empty (e.g. Apple withheld it) —
/// user must set one before chatting.
class AuthNeedsDisplayNameData extends AuthData {
final Map<String, dynamic> profile;
const AuthNeedsDisplayNameData(this.profile);
}
// Notifier
@Riverpod(keepAlive: true)
class Auth extends _$Auth {
FirebaseAuth get _auth => FirebaseAuth.instance;
final _storage = TokenStorage();
ApiClient get _apiClient => ref.read(apiClientProvider);
AuthBridge get _bridge => ref.read(authBridgeProvider);
@override
FutureOr<AuthData> build() async {
final prefs = await SharedPreferences.getInstance();
final customerId = prefs.getString('anonymous_customer_id');
final displayName = prefs.getString('anonymous_display_name');
final currentUser = _auth.currentUser;
_bridge.refresh = _refreshForBridge;
_bridge.onUnauthenticated = () {
_bridge.clear();
state = const AsyncData(AuthInitialData());
};
if (currentUser != null && currentUser.isAnonymous && customerId != null && displayName != null) {
try {
final config = await _apiClient.get('/api/shared/config/anonymity');
final anonymityEnabled = config['data']['anonymity_enabled'] as bool;
if (!anonymityEnabled) {
return AuthForceRegisterData(customerId: customerId, displayName: displayName);
}
return AuthAnonymousData(customerId: customerId, displayName: displayName);
} catch (_) {
return AuthAnonymousData(customerId: customerId, displayName: displayName);
}
} else if (currentUser != null && !currentUser.isAnonymous) {
return await _verifyAndReturn();
}
return const AuthInitialData();
final profile = await _refreshFromStorage();
if (profile == null) return const AuthInitialData();
return await _stateForProfile(profile);
}
// ---------------- Refresh / bootstrap ----------------
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,
);
return _applyTokens(response);
} catch (_) {
await _storage.clear();
_bridge.clear();
final current = state.valueOrNull;
if (current is AuthAuthenticatedData ||
current is AuthAnonymousData ||
current is AuthForceRegisterData ||
current is AuthNeedsDisplayNameData) {
state = const AsyncData(AuthInitialData());
}
return null;
}
}
Future<String?> _refreshForBridge() async {
final profile = await _refreshFromStorage();
if (profile == null) return null;
// Keep the notifier state in sync if we were already signed in.
final current = state.valueOrNull;
if (current != null && current is! AuthInitialData && current is! AuthOtpSentData) {
state = AsyncData(await _stateForProfile(profile));
}
return _bridge.accessToken;
}
/// Persists refresh token, updates the access token on the bridge, and
/// returns the profile. Shared by bootstrap, OTP verify, social sign-in.
Future<Map<String, dynamic>> _applyTokens(Map<String, dynamic> response) async {
final data = response['data'] as Map<String, dynamic>;
final access = data['access_token'] as String;
final refresh = data['refresh_token'] as String;
final profile = data['profile'] as Map<String, dynamic>;
await _storage.writeRefreshToken(refresh);
_bridge.setAccessToken(access);
return profile;
}
/// Decides which AuthData variant matches the current customer row,
/// consulting server config for the anonymity toggle.
Future<AuthData> _stateForProfile(Map<String, dynamic> profile) async {
final hasIdentity = _hasIdentity(profile);
if (hasIdentity) {
final displayName = profile['display_name'] as String?;
if (displayName == null || displayName.isEmpty) {
return AuthNeedsDisplayNameData(profile);
}
return AuthAuthenticatedData(profile);
}
// Anonymous — check if platform allows it.
try {
final config = await _apiClient.get('/api/shared/config/anonymity');
final anonymityEnabled = config['data']['anonymity_enabled'] as bool? ?? true;
if (!anonymityEnabled) return AuthForceRegisterData(profile);
} catch (_) {
// On config fetch failure, default to allowing anonymity (best UX).
}
return AuthAnonymousData(profile);
}
bool _hasIdentity(Map<String, dynamic> profile) {
return (profile['phone'] as String?) != null ||
(profile['google_sub'] as String?) != null ||
(profile['apple_sub'] as String?) != null;
}
String? _currentAnonymousCustomerId() {
final current = state.valueOrNull;
if (current is AuthAnonymousData) return current.customerId;
if (current is AuthForceRegisterData) return current.customerId;
return null;
}
// ---------------- Anonymous ----------------
Future<void> loginAnonymous(String displayName) async {
state = const AsyncLoading();
try {
await _auth.signInAnonymously();
final response = await _apiClient.post(
'/api/shared/customer/anonymous',
data: {'display_name': displayName},
final anon = await _apiClient.postRaw(
'/api/shared/auth/anonymous',
skipAuth: true,
);
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);
state = AsyncData(AuthAnonymousData(
customerId: customer['id'] as String,
displayName: customer['display_name'] as String,
));
} catch (e) {
state = AsyncError('Failed to continue as guest. Please try again.', StackTrace.current);
final profile = await _applyTokens(anon);
// Patch display name if user chose one (anonymous endpoint auto-generates).
final trimmed = displayName.trim();
Map<String, dynamic> finalProfile = profile;
if (trimmed.isNotEmpty && trimmed != profile['display_name']) {
try {
final patch = await _apiClient.patch(
'/api/client/auth/profile',
data: {'display_name': trimmed},
);
finalProfile = patch['data'] as Map<String, dynamic>;
} catch (_) {
// Keep the server-generated name if patch fails.
}
}
state = AsyncData(await _stateForProfile(finalProfile));
} catch (_) {
state = AsyncError('Gagal masuk sebagai tamu. Coba lagi.', StackTrace.current);
}
}
// ---------------- Phone OTP ----------------
Future<void> requestOtp(String phone, {String? channel}) async {
state = const AsyncLoading();
try {
final response = await _apiClient.postRaw(
'/api/client/auth/otp/request',
data: {
'phone': phone,
if (channel != null) 'channel': channel,
},
skipAuth: true,
);
final data = response['data'] as Map<String, dynamic>;
state = AsyncData(AuthOtpSentData(
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/client/auth/otp/verify',
data: {
'otp_request_id': otpRequestId,
'code': code,
if (_currentAnonymousCustomerId() != null)
'anonymous_customer_id': _currentAnonymousCustomerId(),
},
skipAuth: true,
);
final profile = await _applyTokens(response);
state = AsyncData(await _stateForProfile(profile));
} on DioException catch (e) {
state = AsyncError(_otpVerifyMessage(e), StackTrace.current);
} catch (_) {
state = AsyncError('Gagal verifikasi. Coba lagi.', StackTrace.current);
}
}
// ---------------- Google ----------------
Future<void> loginGoogle() async {
state = const AsyncLoading();
try {
final googleUser = await GoogleSignIn().signIn();
if (googleUser == null) {
// User cancelled — revert to previous state.
final previous = _currentAnonymousCustomerId();
if (previous != null) {
// We were anonymous; rebuild state from the stored refresh token to
// get the right variant (Anonymous vs ForceRegister).
final profile = await _refreshFromStorage();
if (profile != null) {
state = AsyncData(await _stateForProfile(profile));
return;
}
}
state = const AsyncData(AuthInitialData());
return;
}
final googleAuth = await googleUser.authentication;
final credential = GoogleAuthProvider.credential(
accessToken: googleAuth.accessToken,
idToken: googleAuth.idToken,
final idToken = googleAuth.idToken;
if (idToken == null) {
state = AsyncError('Gagal mendapatkan kredensial Google.', StackTrace.current);
return;
}
final response = await _apiClient.postRaw(
'/api/client/auth/google',
data: {
'id_token': idToken,
if (_currentAnonymousCustomerId() != null)
'anonymous_customer_id': _currentAnonymousCustomerId(),
},
skipAuth: true,
);
await _auth.signInWithCredential(credential);
state = AsyncData(await _verifyAndReturn());
} catch (e) {
state = AsyncError('Google sign-in failed. Please try again.', StackTrace.current);
final profile = await _applyTokens(response);
state = AsyncData(await _stateForProfile(profile));
} on DioException catch (e) {
state = AsyncError(_socialSignInMessage(e), StackTrace.current);
} catch (_) {
state = AsyncError('Gagal masuk dengan Google.', StackTrace.current);
}
}
// ---------------- Apple ----------------
Future<void> loginApple() async {
state = const AsyncLoading();
try {
final appleCredential = await SignInWithApple.getAppleIDCredential(
scopes: [AppleIDAuthorizationScopes.email],
final credential = await SignInWithApple.getAppleIDCredential(
scopes: [AppleIDAuthorizationScopes.email, AppleIDAuthorizationScopes.fullName],
);
final oauthCredential = OAuthProvider('apple.com').credential(
idToken: appleCredential.identityToken,
accessToken: appleCredential.authorizationCode,
);
await _auth.signInWithCredential(oauthCredential);
state = AsyncData(await _verifyAndReturn());
} catch (e) {
state = AsyncError('Apple sign-in failed. Please try again.', StackTrace.current);
}
}
Future<void> requestOtp(String phone) async {
state = const AsyncLoading();
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('Failed to send OTP. Please try again.', StackTrace.current);
if (!completer.isCompleted) completer.complete();
},
codeSent: (verificationId, _) {
state = AsyncData(AuthOtpSentData(verificationId));
if (!completer.isCompleted) completer.complete();
},
codeAutoRetrievalTimeout: (_) {
if (!completer.isCompleted) completer.complete();
},
);
await completer.future;
}
Future<void> verifyOtp(String verificationId, String smsCode) async {
state = const AsyncLoading();
try {
// If already signed in via auto-verification, skip credential sign-in
if (_auth.currentUser == null || _auth.currentUser!.isAnonymous) {
final credential = PhoneAuthProvider.credential(
verificationId: verificationId,
smsCode: smsCode,
);
await _auth.signInWithCredential(credential);
final idToken = credential.identityToken;
if (idToken == null) {
state = AsyncError('Gagal mendapatkan kredensial Apple.', StackTrace.current);
return;
}
state = AsyncData(await _verifyAndReturn());
} catch (e) {
state = AsyncError('Invalid OTP. Please try again.', StackTrace.current);
final response = await _apiClient.postRaw(
'/api/client/auth/apple',
data: {
'id_token': idToken,
if (_currentAnonymousCustomerId() != null)
'anonymous_customer_id': _currentAnonymousCustomerId(),
},
skipAuth: true,
);
final profile = await _applyTokens(response);
state = AsyncData(await _stateForProfile(profile));
} on SignInWithAppleAuthorizationException {
state = AsyncError('Login Apple dibatalkan.', StackTrace.current);
} on DioException catch (e) {
state = AsyncError(_socialSignInMessage(e), StackTrace.current);
} catch (_) {
state = AsyncError('Gagal masuk dengan Apple.', StackTrace.current);
}
}
Future<void> linkAccount() async {
final prefs = await SharedPreferences.getInstance();
final customerId = prefs.getString('anonymous_customer_id');
if (customerId == null || _auth.currentUser == null) return;
state = const AsyncLoading();
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');
state = AsyncData(await _verifyAndReturn());
} catch (e) {
state = AsyncError('Failed to link account. Please try again.', StackTrace.current);
}
}
Future<void> logout() async {
await _auth.signOut();
final prefs = await SharedPreferences.getInstance();
await prefs.remove('anonymous_customer_id');
await prefs.remove('anonymous_display_name');
state = const AsyncData(AuthInitialData());
}
// ---------------- Display name update ----------------
Future<void> setDisplayName(String displayName) async {
state = const AsyncLoading();
try {
final response = await _apiClient.patch('/api/client/auth/profile', data: {
'display_name': displayName,
});
state = AsyncData(AuthAuthenticatedData(response['data'] as Map<String, dynamic>));
} catch (e) {
final response = await _apiClient.patch(
'/api/client/auth/profile',
data: {'display_name': displayName},
);
final profile = response['data'] as Map<String, dynamic>;
state = AsyncData(await _stateForProfile(profile));
} catch (_) {
state = AsyncError('Gagal menyimpan nama. Coba lagi.', StackTrace.current);
}
}
Future<AuthData> _verifyAndReturn() async {
final response = await _apiClient.post('/api/client/auth/verify');
final profile = response['data'] as Map<String, dynamic>;
if (profile['display_name'] == null || (profile['display_name'] as String).isEmpty) {
return AuthNeedsDisplayNameData(profile);
// ---------------- Logout ----------------
Future<void> logout() async {
try {
await _apiClient.post('/api/shared/auth/logout');
} catch (_) {}
await _storage.clear();
_bridge.clear();
state = const AsyncData(AuthInitialData());
}
// ---------------- Error-code mapping ----------------
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 'WRONG_FLOW':
return 'OTP tidak valid untuk login pelanggan.';
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.';
case 'IDENTITY_CONFLICT':
return 'Nomor ini sudah terdaftar di akun lain.';
default:
return 'Gagal verifikasi. Coba lagi.';
}
}
String _socialSignInMessage(DioException e) {
final code = e.response?.data?['error']?['code'] as String?;
switch (code) {
case 'IDENTITY_CONFLICT':
return 'Akun ini sudah terhubung ke pengguna lain.';
case 'INVALID_ID_TOKEN':
return 'Verifikasi identitas gagal. Coba lagi.';
default:
return 'Gagal masuk. Coba lagi.';
}
return AuthAuthenticatedData(profile);
}
}

View File

@@ -6,7 +6,7 @@ part of 'auth_notifier.dart';
// RiverpodGenerator
// **************************************************************************
String _$authHash() => r'8cb877e94ccf4366b574ffe8c8b4b63321340b6d';
String _$authHash() => r'601e614f3297fb679f5baa893932a43ae981eb9d';
/// See also [Auth].
@ProviderFor(Auth)

View File

@@ -0,0 +1,7 @@
/// Build-time flag controlling whether Google / Apple sign-in buttons
/// are shown. Default: false until backend OAuth credentials are
/// provisioned. Enable with `--dart-define=ENABLE_SOCIAL_AUTH=true`.
const bool kSocialAuthEnabled = bool.fromEnvironment(
'ENABLE_SOCIAL_AUTH',
defaultValue: false,
);

View File

@@ -0,0 +1,17 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class TokenStorage {
static const _refreshKey = 'customer_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);
}

View File

@@ -1,10 +1,10 @@
import 'dart:async';
import 'dart:convert';
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 'chat_notifier.g.dart';
@@ -135,8 +135,11 @@ class Chat extends _$Chat {
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 ChatErrorData('Sesi berakhir. Silakan login ulang.');
return;
}
final wsUrl = ApiClient.baseUrl
.replaceFirst('https://', 'wss://')
.replaceFirst('http://', 'ws://');

View File

@@ -6,7 +6,7 @@ part of 'chat_notifier.dart';
// RiverpodGenerator
// **************************************************************************
String _$chatHash() => r'c67d0e916a9474e5142d1f07649792cd448607e4';
String _$chatHash() => r'b704f27f25fb06bbb266f394daf05ca12f518363';
/// See also [Chat].
@ProviderFor(Chat)

View File

@@ -6,7 +6,7 @@ part of 'session_closure_notifier.dart';
// RiverpodGenerator
// **************************************************************************
String _$sessionClosureHash() => r'5799a386e1e9c925601567b1fb8c684be7c7e23c';
String _$sessionClosureHash() => r'22a7994290c3a0cc3c692a68063bdc8ffcb2bf87';
/// See also [SessionClosure].
@ProviderFor(SessionClosure)

View File

@@ -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 'pairing_notifier.g.dart';
@@ -114,10 +114,9 @@ class Pairing extends _$Pairing {
Future<void> _connectWebSocket() async {
_closeWebSocket();
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://');

View File

@@ -6,7 +6,7 @@ part of 'pairing_notifier.dart';
// RiverpodGenerator
// **************************************************************************
String _$pairingHash() => r'93049804c1d55a0195a56b97d6e7f34fe6ab8086';
String _$pairingHash() => r'a283e74d7cb4244bac74a950205c91d4b2cf3e9a';
/// See also [Pairing].
@ProviderFor(Pairing)