From 98156d1e496e6df30e567d6cc2d3a5a4617a2af5 Mon Sep 17 00:00:00 2001 From: ramadhan sjamsani Date: Fri, 24 Apr 2026 16:08:20 +0800 Subject: [PATCH] Phase 3.4: client_app self-managed auth cutover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- client_app/CLAUDE.md | 18 +- client_app/lib/core/api/api_client.dart | 72 ++- .../lib/core/api/api_client_provider.dart | 3 +- .../lib/core/api/api_client_provider.g.dart | 2 +- client_app/lib/core/auth/auth_bridge.dart | 49 ++ client_app/lib/core/auth/auth_bridge.g.dart | 26 + client_app/lib/core/auth/auth_notifier.dart | 465 ++++++++++++------ client_app/lib/core/auth/auth_notifier.g.dart | 2 +- .../lib/core/auth/social_auth_enabled.dart | 7 + client_app/lib/core/auth/token_storage.dart | 17 + client_app/lib/core/chat/chat_notifier.dart | 9 +- client_app/lib/core/chat/chat_notifier.g.dart | 2 +- .../core/chat/session_closure_notifier.g.dart | 2 +- .../lib/core/pairing/pairing_notifier.dart | 7 +- .../lib/core/pairing/pairing_notifier.g.dart | 2 +- .../auth/screens/force_register_screen.dart | 53 +- .../lib/features/auth/screens/otp_screen.dart | 14 +- .../auth/screens/register_screen.dart | 45 +- .../flutter/generated_plugin_registrant.cc | 4 + .../linux/flutter/generated_plugins.cmake | 2 + .../Flutter/GeneratedPluginRegistrant.swift | 4 +- client_app/pubspec.lock | 168 +++++-- client_app/pubspec.yaml | 9 +- .../flutter/generated_plugin_registrant.cc | 6 +- .../windows/flutter/generated_plugins.cmake | 3 +- 25 files changed, 722 insertions(+), 269 deletions(-) create mode 100644 client_app/lib/core/auth/auth_bridge.dart create mode 100644 client_app/lib/core/auth/auth_bridge.g.dart create mode 100644 client_app/lib/core/auth/social_auth_enabled.dart create mode 100644 client_app/lib/core/auth/token_storage.dart diff --git a/client_app/CLAUDE.md b/client_app/CLAUDE.md index d2dec7e..76e9e07 100644 --- a/client_app/CLAUDE.md +++ b/client_app/CLAUDE.md @@ -7,20 +7,22 @@ Flutter mobile application for end users (clients) seeking mental health support ## 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/client/` and `/api/shared/` routes) +- **Auth:** Self-managed (Phase 3.4). Anonymous-first + phone OTP + (Google / Apple when creds arrive). + - Access token in memory on `AuthBridge`; refresh token persisted via `flutter_secure_storage`. + - Google + Apple SDKs installed but buttons are hidden behind `--dart-define=ENABLE_SOCIAL_AUTH=true` until backend OAuth credentials exist. + - `firebase_auth` removed; `firebase_messaging` kept for FCM push. +- **API:** Calls public Fastify backend (`/api/client/` and `/api/shared/` routes). Refresh + logout live on `shared.auth`. - **Payment:** Xendit (paid sessions, optional trial) ## Key Concepts - Users are **clients** — they seek mental health support ("curhat") -- Core flow: register → browse/match with mitra → book session → chat → pay -- Trial period available for new users +- Core flow: **server-issued anonymous** → optional phone/Google/Apple identity upgrade (same customer row via `anonymous_customer_id`) → browse/match with mitra → book session → chat → pay +- Anonymity toggle: if `/api/shared/config/anonymity` reports `anonymity_enabled = false`, the router shows `ForceRegisterScreen` until the user identifies ## Conventions - Never call `/api/mitra/` or `/internal/` routes from this app -- All API calls must include Firebase JWT token in `Authorization` header -- Handle token refresh transparently +- API calls go through `ApiClient`; it auto-attaches the JWT from `AuthBridge` and auto-refreshes on 401 +- WebSocket handshake (`/api/shared/ws`) reads the access token from `AuthBridge` in the first frame's `{type:"auth", token, session_id?}` message +- Use `const bool.fromEnvironment('ENABLE_SOCIAL_AUTH')` (via `social_auth_enabled.dart`) to gate any Google/Apple UI — never call `loginGoogle` / `loginApple` from a path reachable without that flag diff --git a/client_app/lib/core/api/api_client.dart b/client_app/lib/core/api/api_client.dart index b7516c2..a5ebf37 100644 --- a/client_app/lib/core/api/api_client.dart +++ b/client_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 +/// (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> post(String path, {Map? data}) async { - final response = await _dio.post(path, data: data); - return response.data as Map; + 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> get(String path, {Map? queryParameters}) async { @@ -33,8 +59,30 @@ class ApiClient { return response.data as Map; } + Future> post(String path, {Map? data}) async { + final response = await _dio.post(path, data: data); + return response.data as Map; + } + Future> patch(String path, {Map? data}) async { 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 anonymous / refresh / OTP / Google / Apple endpoints where + /// attaching a 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/client_app/lib/core/api/api_client_provider.dart b/client_app/lib/core/api/api_client_provider.dart index 1670e2f..a0a4359 100644 --- a/client_app/lib/core/api/api_client_provider.dart +++ b/client_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/client_app/lib/core/api/api_client_provider.g.dart b/client_app/lib/core/api/api_client_provider.g.dart index c46826e..cf2cd18 100644 --- a/client_app/lib/core/api/api_client_provider.g.dart +++ b/client_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/client_app/lib/core/auth/auth_bridge.dart b/client_app/lib/core/auth/auth_bridge.dart new file mode 100644 index 0000000..2c264f1 --- /dev/null +++ b/client_app/lib/core/auth/auth_bridge.dart @@ -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 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/client_app/lib/core/auth/auth_bridge.g.dart b/client_app/lib/core/auth/auth_bridge.g.dart new file mode 100644 index 0000000..87d9114 --- /dev/null +++ b/client_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/client_app/lib/core/auth/auth_notifier.dart b/client_app/lib/core/auth/auth_notifier.dart index e01aec4..d419379 100644 --- a/client_app/lib/core/auth/auth_notifier.dart +++ b/client_app/lib/core/auth/auth_notifier.dart @@ -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 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 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 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 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 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?> _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 _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> _applyTokens(Map response) async { + final data = response['data'] as Map; + final access = data['access_token'] as String; + final refresh = data['refresh_token'] as String; + final profile = data['profile'] as Map; + + 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 _stateForProfile(Map 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 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 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; - 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 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; + } 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 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; + 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 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 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 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 requestOtp(String phone) async { - state = const AsyncLoading(); - 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('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 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 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 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 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)); - } catch (e) { + final response = await _apiClient.patch( + '/api/client/auth/profile', + data: {'display_name': displayName}, + ); + final profile = response['data'] as Map; + state = AsyncData(await _stateForProfile(profile)); + } catch (_) { state = AsyncError('Gagal menyimpan nama. Coba lagi.', StackTrace.current); } } - Future _verifyAndReturn() async { - final response = await _apiClient.post('/api/client/auth/verify'); - final profile = response['data'] as Map; - if (profile['display_name'] == null || (profile['display_name'] as String).isEmpty) { - return AuthNeedsDisplayNameData(profile); + // ---------------- Logout ---------------- + + Future 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); } } diff --git a/client_app/lib/core/auth/auth_notifier.g.dart b/client_app/lib/core/auth/auth_notifier.g.dart index f78f42a..a62ce2c 100644 --- a/client_app/lib/core/auth/auth_notifier.g.dart +++ b/client_app/lib/core/auth/auth_notifier.g.dart @@ -6,7 +6,7 @@ part of 'auth_notifier.dart'; // RiverpodGenerator // ************************************************************************** -String _$authHash() => r'8cb877e94ccf4366b574ffe8c8b4b63321340b6d'; +String _$authHash() => r'601e614f3297fb679f5baa893932a43ae981eb9d'; /// See also [Auth]. @ProviderFor(Auth) diff --git a/client_app/lib/core/auth/social_auth_enabled.dart b/client_app/lib/core/auth/social_auth_enabled.dart new file mode 100644 index 0000000..df544d1 --- /dev/null +++ b/client_app/lib/core/auth/social_auth_enabled.dart @@ -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, +); diff --git a/client_app/lib/core/auth/token_storage.dart b/client_app/lib/core/auth/token_storage.dart new file mode 100644 index 0000000..fe20076 --- /dev/null +++ b/client_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 = 'customer_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/client_app/lib/core/chat/chat_notifier.dart b/client_app/lib/core/chat/chat_notifier.dart index 74c459a..d87cede 100644 --- a/client_app/lib/core/chat/chat_notifier.dart +++ b/client_app/lib/core/chat/chat_notifier.dart @@ -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://'); diff --git a/client_app/lib/core/chat/chat_notifier.g.dart b/client_app/lib/core/chat/chat_notifier.g.dart index 07bd91f..5f4349c 100644 --- a/client_app/lib/core/chat/chat_notifier.g.dart +++ b/client_app/lib/core/chat/chat_notifier.g.dart @@ -6,7 +6,7 @@ part of 'chat_notifier.dart'; // RiverpodGenerator // ************************************************************************** -String _$chatHash() => r'c67d0e916a9474e5142d1f07649792cd448607e4'; +String _$chatHash() => r'b704f27f25fb06bbb266f394daf05ca12f518363'; /// See also [Chat]. @ProviderFor(Chat) diff --git a/client_app/lib/core/chat/session_closure_notifier.g.dart b/client_app/lib/core/chat/session_closure_notifier.g.dart index 14878ef..eb2b0e0 100644 --- a/client_app/lib/core/chat/session_closure_notifier.g.dart +++ b/client_app/lib/core/chat/session_closure_notifier.g.dart @@ -6,7 +6,7 @@ part of 'session_closure_notifier.dart'; // RiverpodGenerator // ************************************************************************** -String _$sessionClosureHash() => r'5799a386e1e9c925601567b1fb8c684be7c7e23c'; +String _$sessionClosureHash() => r'22a7994290c3a0cc3c692a68063bdc8ffcb2bf87'; /// See also [SessionClosure]. @ProviderFor(SessionClosure) diff --git a/client_app/lib/core/pairing/pairing_notifier.dart b/client_app/lib/core/pairing/pairing_notifier.dart index 945a941..f25a12a 100644 --- a/client_app/lib/core/pairing/pairing_notifier.dart +++ b/client_app/lib/core/pairing/pairing_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 'pairing_notifier.g.dart'; @@ -114,10 +114,9 @@ class Pairing extends _$Pairing { Future _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://'); diff --git a/client_app/lib/core/pairing/pairing_notifier.g.dart b/client_app/lib/core/pairing/pairing_notifier.g.dart index 7b83fa9..b7db413 100644 --- a/client_app/lib/core/pairing/pairing_notifier.g.dart +++ b/client_app/lib/core/pairing/pairing_notifier.g.dart @@ -6,7 +6,7 @@ part of 'pairing_notifier.dart'; // RiverpodGenerator // ************************************************************************** -String _$pairingHash() => r'93049804c1d55a0195a56b97d6e7f34fe6ab8086'; +String _$pairingHash() => r'a283e74d7cb4244bac74a950205c91d4b2cf3e9a'; /// See also [Pairing]. @ProviderFor(Pairing) diff --git a/client_app/lib/features/auth/screens/force_register_screen.dart b/client_app/lib/features/auth/screens/force_register_screen.dart index 07e4382..1f4164b 100644 --- a/client_app/lib/features/auth/screens/force_register_screen.dart +++ b/client_app/lib/features/auth/screens/force_register_screen.dart @@ -2,9 +2,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../core/auth/auth_notifier.dart'; +import '../../../core/auth/social_auth_enabled.dart'; /// Shown when anonymity is disabled by admin. -/// User must link their account. Display name is pre-filled. +/// User must identify themselves (phone OTP / Google / Apple). +/// Backend upgrades the existing anonymous customer row when the current +/// anonymous_customer_id is passed on sign-in (see AuthNotifier). class ForceRegisterScreen extends ConsumerStatefulWidget { const ForceRegisterScreen({super.key}); @@ -31,10 +34,6 @@ class _ForceRegisterScreenState extends ConsumerState { if (data is AuthOtpSentData) { context.push('/auth/otp', extra: _phoneController.text.trim()); } - if (data is AuthAuthenticatedData) { - // After social login succeeds, link account to existing anonymous record - ref.read(authProvider.notifier).linkAccount(); - } if (next is AsyncError) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString()))); } @@ -52,27 +51,29 @@ class _ForceRegisterScreenState extends ConsumerState { style: TextStyle(fontSize: 16), ), const SizedBox(height: 24), - ElevatedButton.icon( - icon: const Icon(Icons.g_mobiledata), - onPressed: isLoading ? null - : () => ref.read(authProvider.notifier).loginGoogle(), - label: const Text('Lanjut dengan Google'), - ), - const SizedBox(height: 12), - ElevatedButton.icon( - icon: const Icon(Icons.apple), - onPressed: isLoading ? null - : () => ref.read(authProvider.notifier).loginApple(), - label: const Text('Lanjut dengan Apple'), - ), - const Padding( - padding: EdgeInsets.symmetric(vertical: 24), - child: Row(children: [ - Expanded(child: Divider()), - Padding(padding: EdgeInsets.symmetric(horizontal: 12), child: Text('atau')), - Expanded(child: Divider()), - ]), - ), + if (kSocialAuthEnabled) ...[ + ElevatedButton.icon( + icon: const Icon(Icons.g_mobiledata), + onPressed: isLoading ? null + : () => ref.read(authProvider.notifier).loginGoogle(), + label: const Text('Lanjut dengan Google'), + ), + const SizedBox(height: 12), + ElevatedButton.icon( + icon: const Icon(Icons.apple), + onPressed: isLoading ? null + : () => ref.read(authProvider.notifier).loginApple(), + label: const Text('Lanjut dengan Apple'), + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Row(children: [ + Expanded(child: Divider()), + Padding(padding: EdgeInsets.symmetric(horizontal: 12), child: Text('atau')), + Expanded(child: Divider()), + ]), + ), + ], TextField( controller: _phoneController, decoration: const InputDecoration( diff --git a/client_app/lib/features/auth/screens/otp_screen.dart b/client_app/lib/features/auth/screens/otp_screen.dart index cea63e2..3a37f0c 100644 --- a/client_app/lib/features/auth/screens/otp_screen.dart +++ b/client_app/lib/features/auth/screens/otp_screen.dart @@ -12,15 +12,15 @@ class OtpScreen extends ConsumerStatefulWidget { class _OtpScreenState extends ConsumerState { final _otpController = TextEditingController(); - String? _verificationId; + String? _otpRequestId; @override void initState() { super.initState(); - // Capture verification ID from current state + // Capture OTP request id from current state final data = ref.read(authProvider).valueOrNull; if (data is AuthOtpSentData) { - _verificationId = data.verificationId; + _otpRequestId = data.otpRequestId; } } @@ -35,10 +35,10 @@ class _OtpScreenState extends ConsumerState { final authState = ref.watch(authProvider); 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 AuthOtpSentData) { - _verificationId = data.verificationId; + _otpRequestId = data.otpRequestId; } ref.listen(authProvider, (prev, next) { @@ -69,8 +69,8 @@ class _OtpScreenState extends ConsumerState { ElevatedButton( onPressed: isLoading ? null : () { final otp = _otpController.text.trim(); - if (otp.length != 6 || _verificationId == null) return; - ref.read(authProvider.notifier).verifyOtp(_verificationId!, otp); + if (otp.length != 6 || _otpRequestId == null) return; + ref.read(authProvider.notifier).verifyOtp(_otpRequestId!, otp); }, child: isLoading ? const CircularProgressIndicator() diff --git a/client_app/lib/features/auth/screens/register_screen.dart b/client_app/lib/features/auth/screens/register_screen.dart index d4dc47e..acfe576 100644 --- a/client_app/lib/features/auth/screens/register_screen.dart +++ b/client_app/lib/features/auth/screens/register_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../core/auth/auth_notifier.dart'; +import '../../../core/auth/social_auth_enabled.dart'; class RegisterScreen extends ConsumerStatefulWidget { const RegisterScreen({super.key}); @@ -41,27 +42,29 @@ class _RegisterScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - ElevatedButton.icon( - icon: const Icon(Icons.g_mobiledata), - onPressed: isLoading ? null - : () => ref.read(authProvider.notifier).loginGoogle(), - label: const Text('Lanjut dengan Google'), - ), - const SizedBox(height: 12), - ElevatedButton.icon( - icon: const Icon(Icons.apple), - onPressed: isLoading ? null - : () => ref.read(authProvider.notifier).loginApple(), - label: const Text('Lanjut dengan Apple'), - ), - const Padding( - padding: EdgeInsets.symmetric(vertical: 24), - child: Row(children: [ - Expanded(child: Divider()), - Padding(padding: EdgeInsets.symmetric(horizontal: 12), child: Text('atau')), - Expanded(child: Divider()), - ]), - ), + if (kSocialAuthEnabled) ...[ + ElevatedButton.icon( + icon: const Icon(Icons.g_mobiledata), + onPressed: isLoading ? null + : () => ref.read(authProvider.notifier).loginGoogle(), + label: const Text('Lanjut dengan Google'), + ), + const SizedBox(height: 12), + ElevatedButton.icon( + icon: const Icon(Icons.apple), + onPressed: isLoading ? null + : () => ref.read(authProvider.notifier).loginApple(), + label: const Text('Lanjut dengan Apple'), + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Row(children: [ + Expanded(child: Divider()), + Padding(padding: EdgeInsets.symmetric(horizontal: 12), child: Text('atau')), + Expanded(child: Divider()), + ]), + ), + ], TextField( controller: _phoneController, decoration: const InputDecoration( diff --git a/client_app/linux/flutter/generated_plugin_registrant.cc b/client_app/linux/flutter/generated_plugin_registrant.cc index e71a16d..d0e7f79 100644 --- a/client_app/linux/flutter/generated_plugin_registrant.cc +++ b/client_app/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); } diff --git a/client_app/linux/flutter/generated_plugins.cmake b/client_app/linux/flutter/generated_plugins.cmake index 2e1de87..ce58916 100644 --- a/client_app/linux/flutter/generated_plugins.cmake +++ b/client_app/linux/flutter/generated_plugins.cmake @@ -3,9 +3,11 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/client_app/macos/Flutter/GeneratedPluginRegistrant.swift b/client_app/macos/Flutter/GeneratedPluginRegistrant.swift index e483bf5..aba5109 100644 --- a/client_app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/client_app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,19 +5,19 @@ import FlutterMacOS import Foundation -import firebase_auth import firebase_core import firebase_messaging import flutter_local_notifications +import flutter_secure_storage_macos import google_sign_in_ios import shared_preferences_foundation import sign_in_with_apple func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin")) diff --git a/client_app/pubspec.lock b/client_app/pubspec.lock index 81fe66a..4e00018 100644 --- a/client_app/pubspec.lock +++ b/client_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: "0fed2133bee1369ee1118c1fef27b2ce0d84c54b7819a2b17dada5cfec3b03ff" - url: "https://pub.dev" - source: hosted - version: "5.7.0" - firebase_auth_platform_interface: - dependency: transitive - description: - name: firebase_auth_platform_interface - sha256: "871c9df4ec9a754d1a793f7eb42fa3b94249d464cfb19152ba93e14a5966b386" - url: "https://pub.dev" - source: hosted - version: "7.7.3" - firebase_auth_web: - dependency: transitive - description: - name: firebase_auth_web - sha256: d9ada769c43261fd1b18decf113186e915c921a811bd5014f5ea08f4cf4bc57e - url: "https://pub.dev" - source: hosted - version: "5.15.3" 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 @@ -520,6 +552,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: @@ -568,14 +608,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: @@ -656,6 +712,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: @@ -672,6 +744,30 @@ 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: @@ -744,6 +840,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: @@ -1045,6 +1149,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" xdg_directories: dependency: transitive description: @@ -1070,5 +1182,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/client_app/pubspec.yaml b/client_app/pubspec.yaml index 9757464..df19716 100644 --- a/client_app/pubspec.yaml +++ b/client_app/pubspec.yaml @@ -11,12 +11,12 @@ dependencies: flutter: sdk: flutter - # Firebase + # Firebase (Messaging only — Auth dropped in Phase 3.4, self-managed JWT now) firebase_core: ^3.12.1 - firebase_auth: ^5.5.1 firebase_messaging: ^15.2.5 - # Social login + # Social login (kept — activated when OAuth creds arrive; buttons hidden behind + # ENABLE_SOCIAL_AUTH dart-define flag until then) google_sign_in: ^6.2.1 sign_in_with_apple: ^6.1.0 @@ -31,7 +31,8 @@ dependencies: flutter_hooks: ^0.20.5 # Storage - shared_preferences: ^2.2.3 + shared_preferences: ^2.2.3 # onboarding flag, non-sensitive + flutter_secure_storage: ^9.2.2 # refresh token (encrypted) # Navigation go_router: ^13.2.1 diff --git a/client_app/windows/flutter/generated_plugin_registrant.cc b/client_app/windows/flutter/generated_plugin_registrant.cc index d141b74..39cedd3 100644 --- a/client_app/windows/flutter/generated_plugin_registrant.cc +++ b/client_app/windows/flutter/generated_plugin_registrant.cc @@ -6,12 +6,12 @@ #include "generated_plugin_registrant.h" -#include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { - FirebaseAuthPluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi")); FirebaseCorePluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); } diff --git a/client_app/windows/flutter/generated_plugins.cmake b/client_app/windows/flutter/generated_plugins.cmake index ba5c35b..6ffe921 100644 --- a/client_app/windows/flutter/generated_plugins.cmake +++ b/client_app/windows/flutter/generated_plugins.cmake @@ -3,12 +3,13 @@ # list(APPEND FLUTTER_PLUGIN_LIST - firebase_auth firebase_core + flutter_secure_storage_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST flutter_local_notifications_windows + jni ) set(PLUGIN_BUNDLED_LIBRARIES)