Compare commits
4 Commits
1a610363bb
...
b59c66f7df
| Author | SHA1 | Date | |
|---|---|---|---|
| b59c66f7df | |||
| 98156d1e49 | |||
| 2b61c79a86 | |||
| 4a796277b8 |
@@ -8,7 +8,7 @@ Fastify.js REST API serving both mobile apps and the internal control center.
|
|||||||
|
|
||||||
- **Runtime:** Node.js + Fastify.js
|
- **Runtime:** Node.js + Fastify.js
|
||||||
- **Database:** PostgreSQL via GCP Cloud SQL
|
- **Database:** PostgreSQL via GCP Cloud SQL
|
||||||
- **Auth:** Firebase Auth JWT verification (no session, stateless)
|
- **Auth:** Self-managed JWT (HS256 access, 1h) + opaque refresh token (30d, rotated, bcrypt-hashed in `auth_sessions`). Firebase Auth removed in Phase 3.4 (commit `f860ab6`). `firebase-admin` is kept but only for FCM messaging.
|
||||||
- **Payment:** Xendit
|
- **Payment:** Xendit
|
||||||
- **Infra:** GCP Cloud Run
|
- **Infra:** GCP Cloud Run
|
||||||
|
|
||||||
@@ -26,20 +26,25 @@ Internal listener must never be exposed to the public internet.
|
|||||||
```
|
```
|
||||||
/api/client/... → client app routes
|
/api/client/... → client app routes
|
||||||
/api/mitra/... → mitra app routes
|
/api/mitra/... → mitra app routes
|
||||||
/api/shared/... → shared routes (e.g. auth, lookup)
|
/api/shared/... → shared routes (e.g. auth, refresh, logout, anonymous)
|
||||||
/internal/... → control center routes (internal listener only)
|
/internal/... → control center routes (internal listener only)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Auth Flow
|
## Auth Flow
|
||||||
|
|
||||||
1. Firebase Auth issues JWT token on mobile/web
|
- **Mobile (client/mitra):** `Authorization: Bearer <access_token>` header. Access token is our own JWT (HS256, `AUTH_JWT_SECRET`), with claims `{ sub, user_type, session_id }`. Refresh via `POST /api/shared/auth/refresh` with the opaque refresh token in the body.
|
||||||
2. Client sends JWT in `Authorization: Bearer <token>` header
|
- **Control center:** Access token in `Authorization: Bearer` (kept in memory by the SPA). Refresh token lives in an `httpOnly` Secure cookie; refresh calls `POST /internal/auth/refresh` with `credentials: 'include'`.
|
||||||
3. Fastify verifies token using Firebase Admin SDK on every request
|
- **Entry points:**
|
||||||
4. User record fetched from PostgreSQL by Firebase UID
|
- Anonymous customer: `POST /api/shared/auth/anonymous`
|
||||||
|
- Phone OTP (customer/mitra): `/api/{client,mitra}/auth/otp/{request,verify}` — **Fazpass is stubbed** in `otp.service.js`; code is logged to the backend console (`[OTP STUB] phone=… code=…`) until real API docs arrive.
|
||||||
|
- Google/Apple: `/api/client/auth/{google,apple}` (client_app only — creds pending)
|
||||||
|
- CC login: `POST /internal/auth/login` (email + bcrypt password)
|
||||||
|
- **Middleware:** `authenticate` plugin verifies the JWT and attaches `request.auth = { userType, userId, sessionId }`. WebSocket handshake uses the same verification. **No DB lookup on every request** — the user ID is encoded in the token.
|
||||||
|
|
||||||
## Key Conventions
|
## Key Conventions
|
||||||
|
|
||||||
- All routes must be authenticated unless explicitly marked public
|
- All routes must be authenticated unless explicitly marked public (auth + anonymous routes are the exceptions)
|
||||||
- Internal routes have an additional role check (`role: admin`)
|
- Internal routes additionally require `request.auth.userType === 'cc_user'`
|
||||||
- Use Fastify plugins for shared middleware (auth, error handling, logging)
|
- Use Fastify plugins for shared middleware (auth, error handling, logging)
|
||||||
- Business logic lives in `services/` — never directly in route handlers
|
- Business logic lives in `services/` — never directly in route handlers
|
||||||
|
- Never reintroduce Firebase Auth. `firebase-admin` is FCM-only; do not import `.auth()` from it.
|
||||||
|
|||||||
@@ -7,20 +7,22 @@ Flutter mobile application for end users (clients) seeking mental health support
|
|||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- **Framework:** Flutter (iOS + Android)
|
- **Framework:** Flutter (iOS + Android)
|
||||||
- **Auth:** Firebase Auth — Google Sign-In, Apple Sign-In, Phone OTP
|
- **Auth:** Self-managed (Phase 3.4). Anonymous-first + phone OTP + (Google / Apple when creds arrive).
|
||||||
- Fully native UI — no WebView, no Firebase-branded screens
|
- Access token in memory on `AuthBridge`; refresh token persisted via `flutter_secure_storage`.
|
||||||
- Use `firebase_auth` + `google_sign_in` packages
|
- Google + Apple SDKs installed but buttons are hidden behind `--dart-define=ENABLE_SOCIAL_AUTH=true` until backend OAuth credentials exist.
|
||||||
- **API:** Calls public Fastify backend (`/api/client/` and `/api/shared/` routes)
|
- `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)
|
- **Payment:** Xendit (paid sessions, optional trial)
|
||||||
|
|
||||||
## Key Concepts
|
## Key Concepts
|
||||||
|
|
||||||
- Users are **clients** — they seek mental health support ("curhat")
|
- Users are **clients** — they seek mental health support ("curhat")
|
||||||
- Core flow: register → browse/match with mitra → book session → chat → pay
|
- 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
|
||||||
- Trial period available for new users
|
- Anonymity toggle: if `/api/shared/config/anonymity` reports `anonymity_enabled = false`, the router shows `ForceRegisterScreen` until the user identifies
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- Never call `/api/mitra/` or `/internal/` routes from this app
|
- Never call `/api/mitra/` or `/internal/` routes from this app
|
||||||
- All API calls must include Firebase JWT token in `Authorization` header
|
- API calls go through `ApiClient`; it auto-attaches the JWT from `AuthBridge` and auto-refreshes on 401
|
||||||
- Handle token refresh transparently
|
- 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
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import 'package:dio/dio.dart';
|
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 {
|
class ApiClient {
|
||||||
static const String baseUrl = String.fromEnvironment(
|
static const String baseUrl = String.fromEnvironment(
|
||||||
@@ -7,25 +12,46 @@ class ApiClient {
|
|||||||
defaultValue: 'https://api.halobestie.com',
|
defaultValue: 'https://api.halobestie.com',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final AuthBridge _bridge;
|
||||||
late final Dio _dio;
|
late final Dio _dio;
|
||||||
|
|
||||||
ApiClient() {
|
ApiClient(this._bridge) {
|
||||||
_dio = Dio(BaseOptions(baseUrl: baseUrl));
|
_dio = Dio(BaseOptions(baseUrl: baseUrl));
|
||||||
_dio.interceptors.add(InterceptorsWrapper(
|
_dio.interceptors.add(InterceptorsWrapper(
|
||||||
onRequest: (options, handler) async {
|
onRequest: (options, handler) {
|
||||||
final user = FirebaseAuth.instance.currentUser;
|
if (options.extra[_skipAuthKey] != true) {
|
||||||
if (user != null) {
|
final token = _bridge.accessToken;
|
||||||
final token = await user.getIdToken();
|
if (token != null) {
|
||||||
options.headers['Authorization'] = 'Bearer $token';
|
options.headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
handler.next(options);
|
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 {
|
if (is401 && !alreadyRetried && !skipAuth) {
|
||||||
final response = await _dio.post(path, data: data);
|
final newToken = await _bridge.tryRefresh();
|
||||||
return response.data as Map<String, dynamic>;
|
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 {
|
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>;
|
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 {
|
Future<Map<String, dynamic>> patch(String path, {Map<String, dynamic>? data}) async {
|
||||||
final response = await _dio.patch(path, data: data);
|
final response = await _dio.patch(path, data: data);
|
||||||
return response.data as Map<String, dynamic>;
|
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>;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import '../auth/auth_bridge.dart';
|
||||||
import 'api_client.dart';
|
import 'api_client.dart';
|
||||||
|
|
||||||
part 'api_client_provider.g.dart';
|
part 'api_client_provider.g.dart';
|
||||||
|
|
||||||
@Riverpod(keepAlive: true)
|
@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
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$apiClientHash() => r'90c807f03b90249684265cc91739139c2c89eeb9';
|
String _$apiClientHash() => r'c0ade0ab10bbe40a30a4e5b17ea8c8c27517f502';
|
||||||
|
|
||||||
/// See also [apiClient].
|
/// See also [apiClient].
|
||||||
@ProviderFor(apiClient)
|
@ProviderFor(apiClient)
|
||||||
|
|||||||
49
client_app/lib/core/auth/auth_bridge.dart
Normal file
49
client_app/lib/core/auth/auth_bridge.dart
Normal 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();
|
||||||
26
client_app/lib/core/auth/auth_bridge.g.dart
Normal file
26
client_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,15 +1,17 @@
|
|||||||
import 'dart:async';
|
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:google_sign_in/google_sign_in.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.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 'package:sign_in_with_apple/sign_in_with_apple.dart';
|
||||||
import '../api/api_client.dart';
|
import '../api/api_client.dart';
|
||||||
import '../api/api_client_provider.dart';
|
import '../api/api_client_provider.dart';
|
||||||
|
import 'auth_bridge.dart';
|
||||||
|
import 'token_storage.dart';
|
||||||
|
|
||||||
part 'auth_notifier.g.dart';
|
part 'auth_notifier.g.dart';
|
||||||
|
|
||||||
// States
|
// States
|
||||||
|
|
||||||
sealed class AuthData {
|
sealed class AuthData {
|
||||||
const AuthData();
|
const AuthData();
|
||||||
}
|
}
|
||||||
@@ -18,209 +20,384 @@ class AuthInitialData extends AuthData {
|
|||||||
const AuthInitialData();
|
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 {
|
class AuthAuthenticatedData extends AuthData {
|
||||||
final Map<String, dynamic> profile;
|
final Map<String, dynamic> profile;
|
||||||
const AuthAuthenticatedData(this.profile);
|
const AuthAuthenticatedData(this.profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
class AuthAnonymousData extends AuthData {
|
/// Phone OTP requested; waiting for the code.
|
||||||
final String customerId;
|
|
||||||
final String displayName;
|
|
||||||
const AuthAnonymousData({required this.customerId, required this.displayName});
|
|
||||||
}
|
|
||||||
|
|
||||||
class AuthOtpSentData extends AuthData {
|
class AuthOtpSentData extends AuthData {
|
||||||
final String verificationId;
|
final String otpRequestId;
|
||||||
const AuthOtpSentData(this.verificationId);
|
final String? channelUsed;
|
||||||
|
const AuthOtpSentData(this.otpRequestId, {this.channelUsed});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Anonymity has been disabled by admin — user must identify before continuing.
|
||||||
class AuthForceRegisterData extends AuthData {
|
class AuthForceRegisterData extends AuthData {
|
||||||
final String customerId;
|
final Map<String, dynamic> profile;
|
||||||
final String displayName;
|
const AuthForceRegisterData(this.profile);
|
||||||
const AuthForceRegisterData({required this.customerId, required this.displayName});
|
|
||||||
|
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 {
|
class AuthNeedsDisplayNameData extends AuthData {
|
||||||
final Map<String, dynamic> profile;
|
final Map<String, dynamic> profile;
|
||||||
const AuthNeedsDisplayNameData(this.profile);
|
const AuthNeedsDisplayNameData(this.profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notifier
|
||||||
|
|
||||||
@Riverpod(keepAlive: true)
|
@Riverpod(keepAlive: true)
|
||||||
class Auth extends _$Auth {
|
class Auth extends _$Auth {
|
||||||
FirebaseAuth get _auth => FirebaseAuth.instance;
|
final _storage = TokenStorage();
|
||||||
|
|
||||||
ApiClient get _apiClient => ref.read(apiClientProvider);
|
ApiClient get _apiClient => ref.read(apiClientProvider);
|
||||||
|
AuthBridge get _bridge => ref.read(authBridgeProvider);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
FutureOr<AuthData> build() async {
|
FutureOr<AuthData> build() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
_bridge.refresh = _refreshForBridge;
|
||||||
final customerId = prefs.getString('anonymous_customer_id');
|
_bridge.onUnauthenticated = () {
|
||||||
final displayName = prefs.getString('anonymous_display_name');
|
_bridge.clear();
|
||||||
final currentUser = _auth.currentUser;
|
state = const AsyncData(AuthInitialData());
|
||||||
|
};
|
||||||
|
|
||||||
if (currentUser != null && currentUser.isAnonymous && customerId != null && displayName != null) {
|
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 {
|
try {
|
||||||
final config = await _apiClient.get('/api/shared/config/anonymity');
|
final config = await _apiClient.get('/api/shared/config/anonymity');
|
||||||
final anonymityEnabled = config['data']['anonymity_enabled'] as bool;
|
final anonymityEnabled = config['data']['anonymity_enabled'] as bool? ?? true;
|
||||||
if (!anonymityEnabled) {
|
if (!anonymityEnabled) return AuthForceRegisterData(profile);
|
||||||
return AuthForceRegisterData(customerId: customerId, displayName: displayName);
|
|
||||||
}
|
|
||||||
return AuthAnonymousData(customerId: customerId, displayName: displayName);
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return AuthAnonymousData(customerId: customerId, displayName: displayName);
|
// On config fetch failure, default to allowing anonymity (best UX).
|
||||||
}
|
}
|
||||||
} else if (currentUser != null && !currentUser.isAnonymous) {
|
return AuthAnonymousData(profile);
|
||||||
return await _verifyAndReturn();
|
|
||||||
}
|
}
|
||||||
return const AuthInitialData();
|
|
||||||
|
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 {
|
Future<void> loginAnonymous(String displayName) async {
|
||||||
state = const AsyncLoading();
|
state = const AsyncLoading();
|
||||||
try {
|
try {
|
||||||
await _auth.signInAnonymously();
|
final anon = await _apiClient.postRaw(
|
||||||
final response = await _apiClient.post(
|
'/api/shared/auth/anonymous',
|
||||||
'/api/shared/customer/anonymous',
|
skipAuth: true,
|
||||||
data: {'display_name': displayName},
|
|
||||||
);
|
);
|
||||||
final customer = response['data'] as Map<String, dynamic>;
|
final profile = await _applyTokens(anon);
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.setString('anonymous_customer_id', customer['id'] as String);
|
// Patch display name if user chose one (anonymous endpoint auto-generates).
|
||||||
await prefs.setString('anonymous_display_name', customer['display_name'] as String);
|
final trimmed = displayName.trim();
|
||||||
state = AsyncData(AuthAnonymousData(
|
Map<String, dynamic> finalProfile = profile;
|
||||||
customerId: customer['id'] as String,
|
if (trimmed.isNotEmpty && trimmed != profile['display_name']) {
|
||||||
displayName: customer['display_name'] as String,
|
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?,
|
||||||
));
|
));
|
||||||
} catch (e) {
|
} on DioException catch (e) {
|
||||||
state = AsyncError('Failed to continue as guest. Please try again.', StackTrace.current);
|
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 {
|
Future<void> loginGoogle() async {
|
||||||
state = const AsyncLoading();
|
state = const AsyncLoading();
|
||||||
try {
|
try {
|
||||||
final googleUser = await GoogleSignIn().signIn();
|
final googleUser = await GoogleSignIn().signIn();
|
||||||
if (googleUser == null) {
|
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());
|
state = const AsyncData(AuthInitialData());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final googleAuth = await googleUser.authentication;
|
final googleAuth = await googleUser.authentication;
|
||||||
final credential = GoogleAuthProvider.credential(
|
final idToken = googleAuth.idToken;
|
||||||
accessToken: googleAuth.accessToken,
|
if (idToken == null) {
|
||||||
idToken: googleAuth.idToken,
|
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);
|
final profile = await _applyTokens(response);
|
||||||
state = AsyncData(await _verifyAndReturn());
|
state = AsyncData(await _stateForProfile(profile));
|
||||||
} catch (e) {
|
} on DioException catch (e) {
|
||||||
state = AsyncError('Google sign-in failed. Please try again.', StackTrace.current);
|
state = AsyncError(_socialSignInMessage(e), StackTrace.current);
|
||||||
|
} catch (_) {
|
||||||
|
state = AsyncError('Gagal masuk dengan Google.', StackTrace.current);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------- Apple ----------------
|
||||||
|
|
||||||
Future<void> loginApple() async {
|
Future<void> loginApple() async {
|
||||||
state = const AsyncLoading();
|
state = const AsyncLoading();
|
||||||
try {
|
try {
|
||||||
final appleCredential = await SignInWithApple.getAppleIDCredential(
|
final credential = await SignInWithApple.getAppleIDCredential(
|
||||||
scopes: [AppleIDAuthorizationScopes.email],
|
scopes: [AppleIDAuthorizationScopes.email, AppleIDAuthorizationScopes.fullName],
|
||||||
);
|
);
|
||||||
final oauthCredential = OAuthProvider('apple.com').credential(
|
final idToken = credential.identityToken;
|
||||||
idToken: appleCredential.identityToken,
|
if (idToken == null) {
|
||||||
accessToken: appleCredential.authorizationCode,
|
state = AsyncError('Gagal mendapatkan kredensial Apple.', StackTrace.current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final response = await _apiClient.postRaw(
|
||||||
|
'/api/client/auth/apple',
|
||||||
|
data: {
|
||||||
|
'id_token': idToken,
|
||||||
|
if (_currentAnonymousCustomerId() != null)
|
||||||
|
'anonymous_customer_id': _currentAnonymousCustomerId(),
|
||||||
|
},
|
||||||
|
skipAuth: true,
|
||||||
);
|
);
|
||||||
await _auth.signInWithCredential(oauthCredential);
|
final profile = await _applyTokens(response);
|
||||||
state = AsyncData(await _verifyAndReturn());
|
state = AsyncData(await _stateForProfile(profile));
|
||||||
} catch (e) {
|
} on SignInWithAppleAuthorizationException {
|
||||||
state = AsyncError('Apple sign-in failed. Please try again.', StackTrace.current);
|
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> requestOtp(String phone) async {
|
// ---------------- Display name update ----------------
|
||||||
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);
|
|
||||||
}
|
|
||||||
state = AsyncData(await _verifyAndReturn());
|
|
||||||
} catch (e) {
|
|
||||||
state = AsyncError('Invalid OTP. Please try again.', 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());
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setDisplayName(String displayName) async {
|
Future<void> setDisplayName(String displayName) async {
|
||||||
state = const AsyncLoading();
|
state = const AsyncLoading();
|
||||||
try {
|
try {
|
||||||
final response = await _apiClient.patch('/api/client/auth/profile', data: {
|
final response = await _apiClient.patch(
|
||||||
'display_name': displayName,
|
'/api/client/auth/profile',
|
||||||
});
|
data: {'display_name': displayName},
|
||||||
state = AsyncData(AuthAuthenticatedData(response['data'] as Map<String, dynamic>));
|
);
|
||||||
} catch (e) {
|
final profile = response['data'] as Map<String, dynamic>;
|
||||||
|
state = AsyncData(await _stateForProfile(profile));
|
||||||
|
} catch (_) {
|
||||||
state = AsyncError('Gagal menyimpan nama. Coba lagi.', StackTrace.current);
|
state = AsyncError('Gagal menyimpan nama. Coba lagi.', StackTrace.current);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<AuthData> _verifyAndReturn() async {
|
// ---------------- Logout ----------------
|
||||||
final response = await _apiClient.post('/api/client/auth/verify');
|
|
||||||
final profile = response['data'] as Map<String, dynamic>;
|
Future<void> logout() async {
|
||||||
if (profile['display_name'] == null || (profile['display_name'] as String).isEmpty) {
|
try {
|
||||||
return AuthNeedsDisplayNameData(profile);
|
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ part of 'auth_notifier.dart';
|
|||||||
// RiverpodGenerator
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$authHash() => r'8cb877e94ccf4366b574ffe8c8b4b63321340b6d';
|
String _$authHash() => r'601e614f3297fb679f5baa893932a43ae981eb9d';
|
||||||
|
|
||||||
/// See also [Auth].
|
/// See also [Auth].
|
||||||
@ProviderFor(Auth)
|
@ProviderFor(Auth)
|
||||||
|
|||||||
7
client_app/lib/core/auth/social_auth_enabled.dart
Normal file
7
client_app/lib/core/auth/social_auth_enabled.dart
Normal 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,
|
||||||
|
);
|
||||||
17
client_app/lib/core/auth/token_storage.dart
Normal file
17
client_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 = '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);
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
import '../api/api_client.dart';
|
import '../api/api_client.dart';
|
||||||
import '../api/api_client_provider.dart';
|
import '../api/api_client_provider.dart';
|
||||||
|
import '../auth/auth_bridge.dart';
|
||||||
import '../constants.dart';
|
import '../constants.dart';
|
||||||
|
|
||||||
part 'chat_notifier.g.dart';
|
part 'chat_notifier.g.dart';
|
||||||
@@ -135,8 +135,11 @@ class Chat extends _$Chat {
|
|||||||
createdAt: DateTime.parse(m['created_at'] as String),
|
createdAt: DateTime.parse(m['created_at'] as String),
|
||||||
)).toList();
|
)).toList();
|
||||||
|
|
||||||
final user = FirebaseAuth.instance.currentUser;
|
final token = ref.read(authBridgeProvider).accessToken;
|
||||||
final token = await user?.getIdToken();
|
if (token == null) {
|
||||||
|
state = const ChatErrorData('Sesi berakhir. Silakan login ulang.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
final wsUrl = ApiClient.baseUrl
|
final wsUrl = ApiClient.baseUrl
|
||||||
.replaceFirst('https://', 'wss://')
|
.replaceFirst('https://', 'wss://')
|
||||||
.replaceFirst('http://', 'ws://');
|
.replaceFirst('http://', 'ws://');
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ part of 'chat_notifier.dart';
|
|||||||
// RiverpodGenerator
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$chatHash() => r'c67d0e916a9474e5142d1f07649792cd448607e4';
|
String _$chatHash() => r'b704f27f25fb06bbb266f394daf05ca12f518363';
|
||||||
|
|
||||||
/// See also [Chat].
|
/// See also [Chat].
|
||||||
@ProviderFor(Chat)
|
@ProviderFor(Chat)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ part of 'session_closure_notifier.dart';
|
|||||||
// RiverpodGenerator
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$sessionClosureHash() => r'5799a386e1e9c925601567b1fb8c684be7c7e23c';
|
String _$sessionClosureHash() => r'22a7994290c3a0cc3c692a68063bdc8ffcb2bf87';
|
||||||
|
|
||||||
/// See also [SessionClosure].
|
/// See also [SessionClosure].
|
||||||
@ProviderFor(SessionClosure)
|
@ProviderFor(SessionClosure)
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
import '../api/api_client.dart';
|
import '../api/api_client.dart';
|
||||||
import '../api/api_client_provider.dart';
|
import '../api/api_client_provider.dart';
|
||||||
|
import '../auth/auth_bridge.dart';
|
||||||
import '../constants.dart';
|
import '../constants.dart';
|
||||||
|
|
||||||
part 'pairing_notifier.g.dart';
|
part 'pairing_notifier.g.dart';
|
||||||
@@ -114,10 +114,9 @@ class Pairing extends _$Pairing {
|
|||||||
|
|
||||||
Future<void> _connectWebSocket() async {
|
Future<void> _connectWebSocket() async {
|
||||||
_closeWebSocket();
|
_closeWebSocket();
|
||||||
final user = FirebaseAuth.instance.currentUser;
|
final token = ref.read(authBridgeProvider).accessToken;
|
||||||
if (user == null) return;
|
if (token == null) return;
|
||||||
|
|
||||||
final token = await user.getIdToken();
|
|
||||||
final wsUrl = ApiClient.baseUrl
|
final wsUrl = ApiClient.baseUrl
|
||||||
.replaceFirst('https://', 'wss://')
|
.replaceFirst('https://', 'wss://')
|
||||||
.replaceFirst('http://', 'ws://');
|
.replaceFirst('http://', 'ws://');
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ part of 'pairing_notifier.dart';
|
|||||||
// RiverpodGenerator
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$pairingHash() => r'93049804c1d55a0195a56b97d6e7f34fe6ab8086';
|
String _$pairingHash() => r'a283e74d7cb4244bac74a950205c91d4b2cf3e9a';
|
||||||
|
|
||||||
/// See also [Pairing].
|
/// See also [Pairing].
|
||||||
@ProviderFor(Pairing)
|
@ProviderFor(Pairing)
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../../core/auth/auth_notifier.dart';
|
import '../../../core/auth/auth_notifier.dart';
|
||||||
|
import '../../../core/auth/social_auth_enabled.dart';
|
||||||
|
|
||||||
/// Shown when anonymity is disabled by admin.
|
/// 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 {
|
class ForceRegisterScreen extends ConsumerStatefulWidget {
|
||||||
const ForceRegisterScreen({super.key});
|
const ForceRegisterScreen({super.key});
|
||||||
|
|
||||||
@@ -31,10 +34,6 @@ class _ForceRegisterScreenState extends ConsumerState<ForceRegisterScreen> {
|
|||||||
if (data is AuthOtpSentData) {
|
if (data is AuthOtpSentData) {
|
||||||
context.push('/auth/otp', extra: _phoneController.text.trim());
|
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) {
|
if (next is AsyncError) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
|
||||||
}
|
}
|
||||||
@@ -52,6 +51,7 @@ class _ForceRegisterScreenState extends ConsumerState<ForceRegisterScreen> {
|
|||||||
style: TextStyle(fontSize: 16),
|
style: TextStyle(fontSize: 16),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
if (kSocialAuthEnabled) ...[
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
icon: const Icon(Icons.g_mobiledata),
|
icon: const Icon(Icons.g_mobiledata),
|
||||||
onPressed: isLoading ? null
|
onPressed: isLoading ? null
|
||||||
@@ -73,6 +73,7 @@ class _ForceRegisterScreenState extends ConsumerState<ForceRegisterScreen> {
|
|||||||
Expanded(child: Divider()),
|
Expanded(child: Divider()),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
TextField(
|
TextField(
|
||||||
controller: _phoneController,
|
controller: _phoneController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
|
|||||||
@@ -12,15 +12,15 @@ class OtpScreen extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _OtpScreenState extends ConsumerState<OtpScreen> {
|
class _OtpScreenState extends ConsumerState<OtpScreen> {
|
||||||
final _otpController = TextEditingController();
|
final _otpController = TextEditingController();
|
||||||
String? _verificationId;
|
String? _otpRequestId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Capture verification ID from current state
|
// Capture OTP request id from current state
|
||||||
final data = ref.read(authProvider).valueOrNull;
|
final data = ref.read(authProvider).valueOrNull;
|
||||||
if (data is AuthOtpSentData) {
|
if (data is AuthOtpSentData) {
|
||||||
_verificationId = data.verificationId;
|
_otpRequestId = data.otpRequestId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,10 +35,10 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
|||||||
final authState = ref.watch(authProvider);
|
final authState = ref.watch(authProvider);
|
||||||
final isLoading = authState is AsyncLoading;
|
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;
|
final data = authState.valueOrNull;
|
||||||
if (data is AuthOtpSentData) {
|
if (data is AuthOtpSentData) {
|
||||||
_verificationId = data.verificationId;
|
_otpRequestId = data.otpRequestId;
|
||||||
}
|
}
|
||||||
|
|
||||||
ref.listen(authProvider, (prev, next) {
|
ref.listen(authProvider, (prev, next) {
|
||||||
@@ -69,8 +69,8 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
|||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: isLoading ? null : () {
|
onPressed: isLoading ? null : () {
|
||||||
final otp = _otpController.text.trim();
|
final otp = _otpController.text.trim();
|
||||||
if (otp.length != 6 || _verificationId == null) return;
|
if (otp.length != 6 || _otpRequestId == null) return;
|
||||||
ref.read(authProvider.notifier).verifyOtp(_verificationId!, otp);
|
ref.read(authProvider.notifier).verifyOtp(_otpRequestId!, otp);
|
||||||
},
|
},
|
||||||
child: isLoading
|
child: isLoading
|
||||||
? const CircularProgressIndicator()
|
? const CircularProgressIndicator()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../../core/auth/auth_notifier.dart';
|
import '../../../core/auth/auth_notifier.dart';
|
||||||
|
import '../../../core/auth/social_auth_enabled.dart';
|
||||||
|
|
||||||
class RegisterScreen extends ConsumerStatefulWidget {
|
class RegisterScreen extends ConsumerStatefulWidget {
|
||||||
const RegisterScreen({super.key});
|
const RegisterScreen({super.key});
|
||||||
@@ -41,6 +42,7 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
if (kSocialAuthEnabled) ...[
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
icon: const Icon(Icons.g_mobiledata),
|
icon: const Icon(Icons.g_mobiledata),
|
||||||
onPressed: isLoading ? null
|
onPressed: isLoading ? null
|
||||||
@@ -62,6 +64,7 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
|||||||
Expanded(child: Divider()),
|
Expanded(child: Divider()),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
TextField(
|
TextField(
|
||||||
controller: _phoneController,
|
controller: _phoneController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
flutter_secure_storage_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
jni
|
||||||
)
|
)
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|||||||
@@ -5,19 +5,19 @@
|
|||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
import firebase_auth
|
|
||||||
import firebase_core
|
import firebase_core
|
||||||
import firebase_messaging
|
import firebase_messaging
|
||||||
import flutter_local_notifications
|
import flutter_local_notifications
|
||||||
|
import flutter_secure_storage_macos
|
||||||
import google_sign_in_ios
|
import google_sign_in_ios
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import sign_in_with_apple
|
import sign_in_with_apple
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
|
|
||||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||||
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
||||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
|
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||||
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
|
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin"))
|
SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin"))
|
||||||
|
|||||||
@@ -161,6 +161,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
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:
|
code_builder:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -281,30 +289,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
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:
|
firebase_core:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -422,6 +406,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.6.1"
|
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:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -520,6 +552,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.2"
|
version: "2.3.2"
|
||||||
|
hooks:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: hooks
|
||||||
|
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.3"
|
||||||
hooks_riverpod:
|
hooks_riverpod:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -568,14 +608,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.5"
|
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:
|
js:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: js
|
name: js
|
||||||
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.2"
|
version: "0.6.7"
|
||||||
json_annotation:
|
json_annotation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -656,6 +712,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
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:
|
package_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -672,6 +744,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
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:
|
path_provider_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -744,6 +840,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.0"
|
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:
|
riverpod:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1045,6 +1149,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.3"
|
version: "3.0.3"
|
||||||
|
win32:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: win32
|
||||||
|
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.15.0"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1070,5 +1182,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.10.0 <4.0.0"
|
dart: ">=3.10.3 <4.0.0"
|
||||||
flutter: ">=3.38.1"
|
flutter: ">=3.38.4"
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ dependencies:
|
|||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
# Firebase
|
# Firebase (Messaging only — Auth dropped in Phase 3.4, self-managed JWT now)
|
||||||
firebase_core: ^3.12.1
|
firebase_core: ^3.12.1
|
||||||
firebase_auth: ^5.5.1
|
|
||||||
firebase_messaging: ^15.2.5
|
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
|
google_sign_in: ^6.2.1
|
||||||
sign_in_with_apple: ^6.1.0
|
sign_in_with_apple: ^6.1.0
|
||||||
|
|
||||||
@@ -31,7 +31,8 @@ dependencies:
|
|||||||
flutter_hooks: ^0.20.5
|
flutter_hooks: ^0.20.5
|
||||||
|
|
||||||
# Storage
|
# 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
|
# Navigation
|
||||||
go_router: ^13.2.1
|
go_router: ^13.2.1
|
||||||
|
|||||||
@@ -6,12 +6,12 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <firebase_auth/firebase_auth_plugin_c_api.h>
|
|
||||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||||
|
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
FirebaseAuthPluginCApiRegisterWithRegistrar(
|
|
||||||
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
|
|
||||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||||
|
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
firebase_auth
|
|
||||||
firebase_core
|
firebase_core
|
||||||
|
flutter_secure_storage_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
flutter_local_notifications_windows
|
flutter_local_notifications_windows
|
||||||
|
jni
|
||||||
)
|
)
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|||||||
@@ -1,7 +1,2 @@
|
|||||||
# Internal API base URL — accessible via VPN only
|
# Internal API base URL — accessible via VPN only
|
||||||
VITE_API_BASE_URL=https://internal.halobestie.com
|
VITE_API_BASE_URL=https://internal.halobestie.com
|
||||||
|
|
||||||
# Firebase
|
|
||||||
VITE_FIREBASE_API_KEY=
|
|
||||||
VITE_FIREBASE_AUTH_DOMAIN=
|
|
||||||
VITE_FIREBASE_PROJECT_ID=
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ React + Vite SPA for internal platform management. **Internal use only.**
|
|||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- **Framework:** React + Vite
|
- **Framework:** React + Vite
|
||||||
- **Auth:** Firebase Auth (admin role required)
|
- **Auth:** Self-managed (see root `CLAUDE.md` — Phase 3.4). Email + bcrypt password via `POST /internal/auth/login`. Access token lives in memory (React `AuthContext`); refresh token in an `httpOnly` Secure cookie (`cc_refresh_token`). All API calls must send `credentials: 'include'`. Admin-only provisioning — no public signup, no password-reset flow.
|
||||||
- **API:** Calls internal Fastify listener only (`/internal/` routes on port 3001)
|
- **API:** Calls internal Fastify listener only (`/internal/` routes on port 3001)
|
||||||
- **Access:** Internal network / VPN only — never exposed to public internet
|
- **Access:** Internal network / VPN only — never exposed to public internet
|
||||||
|
|
||||||
|
|||||||
1023
control_center/package-lock.json
generated
1023
control_center/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,6 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.23.1",
|
"react-router-dom": "^6.23.1",
|
||||||
"firebase": "^10.12.1",
|
|
||||||
"axios": "^1.7.2",
|
"axios": "^1.7.2",
|
||||||
"@tanstack/react-query": "^5.45.1"
|
"@tanstack/react-query": "^5.45.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,59 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { Outlet, NavLink } from 'react-router-dom'
|
import { Outlet, NavLink } from 'react-router-dom'
|
||||||
import { useAuth } from '../core/auth/AuthContext'
|
import { useAuth } from '../core/auth/AuthContext'
|
||||||
|
import { apiClient } from '../core/api/api-client'
|
||||||
|
|
||||||
|
const PasswordChangeForm = ({ onDone }) => {
|
||||||
|
const [current, setCurrent] = useState('')
|
||||||
|
const [next, setNext] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [success, setSuccess] = useState(false)
|
||||||
|
|
||||||
|
const submit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await apiClient.patch('/internal/control-center-users/me/password', {
|
||||||
|
current_password: current,
|
||||||
|
new_password: next,
|
||||||
|
})
|
||||||
|
setSuccess(true)
|
||||||
|
setCurrent('')
|
||||||
|
setNext('')
|
||||||
|
} catch (err) {
|
||||||
|
const code = err?.response?.data?.error?.code
|
||||||
|
const msg = err?.response?.data?.error?.message
|
||||||
|
if (code === 'INVALID_CREDENTIALS') setError('Password saat ini salah.')
|
||||||
|
else if (code?.startsWith('PASSWORD_')) setError(msg || 'Password tidak memenuhi syarat.')
|
||||||
|
else setError('Gagal mengubah password.')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={submit} style={{ padding: 8, border: '1px solid #eee', marginTop: 8 }}>
|
||||||
|
<input type="password" placeholder="Password lama" value={current}
|
||||||
|
onChange={e => setCurrent(e.target.value)} required
|
||||||
|
style={{ display: 'block', width: '100%', marginBottom: 6 }} />
|
||||||
|
<input type="password" placeholder="Password baru (min 8, huruf besar/kecil + angka)" value={next}
|
||||||
|
onChange={e => setNext(e.target.value)} required minLength={8}
|
||||||
|
style={{ display: 'block', width: '100%', marginBottom: 6 }} />
|
||||||
|
{error && <p style={{ color: 'red', margin: '4px 0', fontSize: 12 }}>{error}</p>}
|
||||||
|
{success && <p style={{ color: 'green', margin: '4px 0', fontSize: 12 }}>Password berhasil diubah.</p>}
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<button type="submit" disabled={saving}>{saving ? '...' : 'Simpan'}</button>
|
||||||
|
<button type="button" onClick={onDone}>Tutup</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
|
const [showPwForm, setShowPwForm] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||||
@@ -18,7 +69,9 @@ export default function Layout() {
|
|||||||
</ul>
|
</ul>
|
||||||
<div style={{ marginTop: 'auto', paddingTop: 16 }}>
|
<div style={{ marginTop: 'auto', paddingTop: 16 }}>
|
||||||
<p style={{ fontSize: 12 }}>{user?.email}</p>
|
<p style={{ fontSize: 12 }}>{user?.email}</p>
|
||||||
|
<button onClick={() => setShowPwForm(v => !v)} style={{ marginRight: 6 }}>Ganti password</button>
|
||||||
<button onClick={logout}>Logout</button>
|
<button onClick={logout}>Logout</button>
|
||||||
|
{showPwForm && <PasswordChangeForm onDone={() => setShowPwForm(false)} />}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<main style={{ flex: 1, padding: 24 }}>
|
<main style={{ flex: 1, padding: 24 }}>
|
||||||
|
|||||||
@@ -1,15 +1,40 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { auth } from '../auth/firebase'
|
import {
|
||||||
|
readAccessToken,
|
||||||
|
refreshAccessToken,
|
||||||
|
notifyUnauthenticated,
|
||||||
|
} from '../auth/token-bridge'
|
||||||
|
|
||||||
export const apiClient = axios.create({
|
export const apiClient = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||||
|
withCredentials: true, // send httpOnly cc_refresh_token cookie on refresh calls
|
||||||
})
|
})
|
||||||
|
|
||||||
apiClient.interceptors.request.use(async (config) => {
|
apiClient.interceptors.request.use((config) => {
|
||||||
const user = auth.currentUser
|
const token = readAccessToken()
|
||||||
if (user) {
|
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||||
const token = await user.getIdToken()
|
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
|
||||||
}
|
|
||||||
return config
|
return config
|
||||||
})
|
})
|
||||||
|
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(res) => res,
|
||||||
|
async (err) => {
|
||||||
|
const original = err.config
|
||||||
|
const status = err.response?.status
|
||||||
|
const url = original?.url || ''
|
||||||
|
|
||||||
|
// Never try to refresh on the refresh endpoint itself — that's a terminal failure.
|
||||||
|
const isRefreshCall = url.includes('/internal/auth/refresh')
|
||||||
|
|
||||||
|
if (status === 401 && original && !original._retry && !isRefreshCall) {
|
||||||
|
original._retry = true
|
||||||
|
const newToken = await refreshAccessToken()
|
||||||
|
if (newToken) {
|
||||||
|
original.headers.Authorization = `Bearer ${newToken}`
|
||||||
|
return apiClient(original)
|
||||||
|
}
|
||||||
|
notifyUnauthenticated()
|
||||||
|
}
|
||||||
|
return Promise.reject(err)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,37 +1,91 @@
|
|||||||
import { createContext, useContext, useEffect, useState } from 'react'
|
import { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react'
|
||||||
import { signInWithEmailAndPassword, signOut, onAuthStateChanged } from 'firebase/auth'
|
import axios from 'axios'
|
||||||
import { auth } from './firebase'
|
import { registerAuthBridge } from './token-bridge'
|
||||||
import { apiClient } from '../api/api-client'
|
|
||||||
|
|
||||||
const AuthContext = createContext(null)
|
const AuthContext = createContext(null)
|
||||||
|
|
||||||
|
const BASE_URL = import.meta.env.VITE_API_BASE_URL
|
||||||
|
|
||||||
|
// Raw axios (not apiClient) for auth calls — avoids the interceptor
|
||||||
|
// triggering a refresh loop while we're the one doing the refresh.
|
||||||
|
const authAxios = axios.create({
|
||||||
|
baseURL: BASE_URL,
|
||||||
|
withCredentials: true,
|
||||||
|
})
|
||||||
|
|
||||||
export const AuthProvider = ({ children }) => {
|
export const AuthProvider = ({ children }) => {
|
||||||
const [user, setUser] = useState(null)
|
const [user, setUser] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
// Keep access token in a ref so api-client (via bridge) always reads the
|
||||||
const unsub = onAuthStateChanged(auth, async (firebaseUser) => {
|
// latest value synchronously — React state updates are async.
|
||||||
if (firebaseUser) {
|
const accessTokenRef = useRef(null)
|
||||||
try {
|
|
||||||
const res = await apiClient.post('/internal/auth/verify')
|
const clearSession = useCallback(() => {
|
||||||
setUser(res.data.data)
|
accessTokenRef.current = null
|
||||||
} catch {
|
|
||||||
await signOut(auth)
|
|
||||||
setUser(null)
|
setUser(null)
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setUser(null)
|
|
||||||
}
|
|
||||||
setLoading(false)
|
|
||||||
})
|
|
||||||
return unsub
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const login = (email, password) => signInWithEmailAndPassword(auth, email, password)
|
const applyTokens = useCallback((accessToken, profile) => {
|
||||||
const logout = () => signOut(auth)
|
accessTokenRef.current = accessToken
|
||||||
|
setUser(profile)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const login = useCallback(async (email, password) => {
|
||||||
|
const res = await authAxios.post('/internal/auth/login', { email, password })
|
||||||
|
const { access_token, profile } = res.data.data
|
||||||
|
applyTokens(access_token, profile)
|
||||||
|
}, [applyTokens])
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await authAxios.post('/internal/auth/refresh')
|
||||||
|
const { access_token, profile } = res.data.data
|
||||||
|
applyTokens(access_token, profile)
|
||||||
|
return access_token
|
||||||
|
} catch {
|
||||||
|
clearSession()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}, [applyTokens, clearSession])
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await authAxios.post('/internal/auth/logout', null, {
|
||||||
|
headers: accessTokenRef.current ? { Authorization: `Bearer ${accessTokenRef.current}` } : {},
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// server-side session may already be gone; always clear locally
|
||||||
|
}
|
||||||
|
clearSession()
|
||||||
|
}, [clearSession])
|
||||||
|
|
||||||
|
// Wire the bridge once — api-client imports token-bridge directly and
|
||||||
|
// reads through these closures, so identity doesn't matter as long as
|
||||||
|
// they read fresh state.
|
||||||
|
useEffect(() => {
|
||||||
|
registerAuthBridge({
|
||||||
|
getAccessToken: () => accessTokenRef.current,
|
||||||
|
runRefresh: refresh,
|
||||||
|
onUnauthenticated: clearSession,
|
||||||
|
})
|
||||||
|
}, [refresh, clearSession])
|
||||||
|
|
||||||
|
// Bootstrap: try to refresh using the httpOnly cookie. If none / expired,
|
||||||
|
// we stay unauthenticated and the router sends us to /login.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
await refresh()
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
})()
|
||||||
|
return () => { cancelled = true }
|
||||||
|
// refresh is stable enough — we only want this to run on mount.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, login, logout }}>
|
<AuthContext.Provider value={{ user, loading, login, logout, refresh }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import { initializeApp } from 'firebase/app'
|
|
||||||
import { getAuth } from 'firebase/auth'
|
|
||||||
|
|
||||||
const firebaseConfig = {
|
|
||||||
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
|
|
||||||
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
|
|
||||||
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = initializeApp(firebaseConfig)
|
|
||||||
export const auth = getAuth(app)
|
|
||||||
33
control_center/src/core/auth/token-bridge.js
Normal file
33
control_center/src/core/auth/token-bridge.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// Bridge between AuthContext (owner of the access token) and api-client
|
||||||
|
// (needs it on every request). AuthContext registers getters/setters here
|
||||||
|
// on mount; api-client reads them. Avoids circular imports + lets us
|
||||||
|
// de-duplicate concurrent 401 refreshes via a shared in-flight promise.
|
||||||
|
|
||||||
|
let getAccessToken = () => null
|
||||||
|
let runRefresh = async () => null
|
||||||
|
let onUnauthenticated = () => {}
|
||||||
|
|
||||||
|
let refreshInFlight = null
|
||||||
|
|
||||||
|
export const registerAuthBridge = ({ getAccessToken: g, runRefresh: r, onUnauthenticated: u }) => {
|
||||||
|
getAccessToken = g
|
||||||
|
runRefresh = r
|
||||||
|
onUnauthenticated = u
|
||||||
|
}
|
||||||
|
|
||||||
|
export const readAccessToken = () => getAccessToken()
|
||||||
|
|
||||||
|
export const refreshAccessToken = async () => {
|
||||||
|
if (!refreshInFlight) {
|
||||||
|
refreshInFlight = (async () => {
|
||||||
|
try {
|
||||||
|
return await runRefresh()
|
||||||
|
} finally {
|
||||||
|
refreshInFlight = null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
return refreshInFlight
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notifyUnauthenticated = () => onUnauthenticated()
|
||||||
@@ -2,6 +2,21 @@ import { useState, useEffect } from 'react'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../../core/auth/AuthContext'
|
import { useAuth } from '../../core/auth/AuthContext'
|
||||||
|
|
||||||
|
const messageForError = (err) => {
|
||||||
|
const code = err?.response?.data?.error?.code
|
||||||
|
const msg = err?.response?.data?.error?.message
|
||||||
|
switch (code) {
|
||||||
|
case 'ACCOUNT_LOCKED':
|
||||||
|
return msg || 'Akun terkunci sementara. Coba lagi nanti.'
|
||||||
|
case 'INVALID_CREDENTIALS':
|
||||||
|
return 'Email atau password salah.'
|
||||||
|
case 'VALIDATION_ERROR':
|
||||||
|
return 'Email dan password wajib diisi.'
|
||||||
|
default:
|
||||||
|
return 'Gagal masuk. Coba lagi.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const { user, loading: authLoading, login } = useAuth()
|
const { user, loading: authLoading, login } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -20,12 +35,14 @@ export default function LoginPage() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
await login(email, password)
|
await login(email, password)
|
||||||
} catch {
|
} catch (err) {
|
||||||
setError('Email atau password salah.')
|
setError(messageForError(err))
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (authLoading) return <div style={{ padding: 24 }}>Loading...</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 360, margin: '100px auto', padding: 24 }}>
|
<div style={{ maxWidth: 360, margin: '100px auto', padding: 24 }}>
|
||||||
<h1>Halo Bestie</h1>
|
<h1>Halo Bestie</h1>
|
||||||
|
|||||||
@@ -17,21 +17,83 @@ const createUser = async (data) => {
|
|||||||
return res.data.data
|
return res.data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resetPassword = async ({ id, new_password }) => {
|
||||||
|
const res = await apiClient.patch(`/internal/control-center-users/${id}/password`, { new_password })
|
||||||
|
return res.data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a temporary password that meets backend complexity rules:
|
||||||
|
// min 8 chars, ≥1 digit, ≥1 uppercase, ≥1 lowercase.
|
||||||
|
const generateTempPassword = () => {
|
||||||
|
const raw = crypto.randomUUID().replace(/-/g, '').slice(0, 12)
|
||||||
|
// Ensure uppercase + lowercase + digit — crypto.randomUUID is lowercase hex,
|
||||||
|
// so we explicitly prefix to guarantee complexity.
|
||||||
|
return `A${raw}9`
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage = (err) => {
|
||||||
|
const code = err?.response?.data?.error?.code
|
||||||
|
const msg = err?.response?.data?.error?.message
|
||||||
|
if (code?.startsWith('PASSWORD_')) return msg || 'Password tidak memenuhi syarat.'
|
||||||
|
if (code === 'VALIDATION_ERROR') return msg || 'Input tidak lengkap.'
|
||||||
|
if (code === 'EMAIL_ALREADY_EXISTS' || code === 'EMAIL_TAKEN') return 'Email sudah digunakan.'
|
||||||
|
return msg || 'Gagal menyimpan.'
|
||||||
|
}
|
||||||
|
|
||||||
|
const ResetPasswordRow = ({ userId }) => {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [pw, setPw] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [success, setSuccess] = useState(false)
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: resetPassword,
|
||||||
|
onSuccess: () => {
|
||||||
|
setSuccess(true)
|
||||||
|
setPw('')
|
||||||
|
},
|
||||||
|
onError: (err) => setError(errorMessage(err)),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return <button onClick={() => { setOpen(true); setSuccess(false); setError('') }}>Reset password</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); setError(''); mutation.mutate({ id: userId, new_password: pw }) }}
|
||||||
|
style={{ display: 'inline-flex', gap: 4, alignItems: 'center' }}>
|
||||||
|
<input type="text" placeholder="Password baru" value={pw}
|
||||||
|
onChange={e => setPw(e.target.value)} required minLength={8}
|
||||||
|
style={{ width: 180 }} />
|
||||||
|
<button type="button" onClick={() => setPw(generateTempPassword())}>Generate</button>
|
||||||
|
<button type="submit" disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending ? '...' : 'Simpan'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => { setOpen(false); setError(''); setSuccess(false) }}>Batal</button>
|
||||||
|
{error && <span style={{ color: 'red', fontSize: 12 }}>{error}</span>}
|
||||||
|
{success && <span style={{ color: 'green', fontSize: 12 }}>Tersimpan.</span>}
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data, isLoading } = useQuery({ queryKey: ['cc-users'], queryFn: fetchUsers })
|
const { data, isLoading } = useQuery({ queryKey: ['cc-users'], queryFn: fetchUsers })
|
||||||
const { data: roles } = useQuery({ queryKey: ['roles'], queryFn: fetchRoles })
|
const { data: roles } = useQuery({ queryKey: ['roles'], queryFn: fetchRoles })
|
||||||
|
|
||||||
const [form, setForm] = useState({ email: '', display_name: '', role_id: '' })
|
const [form, setForm] = useState({ email: '', display_name: '', role_id: '', password: '' })
|
||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [createError, setCreateError] = useState('')
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: createUser,
|
mutationFn: createUser,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['cc-users'] })
|
queryClient.invalidateQueries({ queryKey: ['cc-users'] })
|
||||||
setForm({ email: '', display_name: '', role_id: '' })
|
setForm({ email: '', display_name: '', role_id: '', password: '' })
|
||||||
setShowForm(false)
|
setShowForm(false)
|
||||||
|
setCreateError('')
|
||||||
},
|
},
|
||||||
|
onError: (err) => setCreateError(errorMessage(err)),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (isLoading) return <div>Loading...</div>
|
if (isLoading) return <div>Loading...</div>
|
||||||
@@ -40,11 +102,11 @@ export default function UsersPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<h1>Control Center Users</h1>
|
<h1>Control Center Users</h1>
|
||||||
<button onClick={() => setShowForm(!showForm)}>+ Tambah User</button>
|
<button onClick={() => { setShowForm(!showForm); setCreateError('') }}>+ Tambah User</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<form onSubmit={(e) => { e.preventDefault(); createMutation.mutate(form) }}
|
<form onSubmit={(e) => { e.preventDefault(); setCreateError(''); createMutation.mutate(form) }}
|
||||||
style={{ marginBottom: 24, padding: 16, border: '1px solid #eee' }}>
|
style={{ marginBottom: 24, padding: 16, border: '1px solid #eee' }}>
|
||||||
<h3>Tambah User Baru</h3>
|
<h3>Tambah User Baru</h3>
|
||||||
<input placeholder="Email" type="email" value={form.email}
|
<input placeholder="Email" type="email" value={form.email}
|
||||||
@@ -58,10 +120,18 @@ export default function UsersPage() {
|
|||||||
<option value="">Pilih Role</option>
|
<option value="">Pilih Role</option>
|
||||||
{roles?.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
|
{roles?.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||||
|
<input placeholder="Password awal (min 8, huruf besar/kecil + angka)" type="text" value={form.password}
|
||||||
|
onChange={e => setForm(f => ({ ...f, password: e.target.value }))} required minLength={8}
|
||||||
|
style={{ flex: 1 }} />
|
||||||
|
<button type="button" onClick={() => setForm(f => ({ ...f, password: generateTempPassword() }))}>
|
||||||
|
Generate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button type="submit" disabled={createMutation.isPending}>
|
<button type="submit" disabled={createMutation.isPending}>
|
||||||
{createMutation.isPending ? 'Menyimpan...' : 'Simpan'}
|
{createMutation.isPending ? 'Menyimpan...' : 'Simpan'}
|
||||||
</button>
|
</button>
|
||||||
{createMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
|
{createError && <p style={{ color: 'red' }}>{createError}</p>}
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -71,6 +141,7 @@ export default function UsersPage() {
|
|||||||
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Nama</th>
|
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Nama</th>
|
||||||
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Email</th>
|
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Email</th>
|
||||||
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Role</th>
|
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Role</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Aksi</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -78,7 +149,8 @@ export default function UsersPage() {
|
|||||||
<tr key={user.id}>
|
<tr key={user.id}>
|
||||||
<td style={{ padding: 8 }}>{user.display_name}</td>
|
<td style={{ padding: 8 }}>{user.display_name}</td>
|
||||||
<td style={{ padding: 8 }}>{user.email}</td>
|
<td style={{ padding: 8 }}>{user.email}</td>
|
||||||
<td style={{ padding: 8 }}>{user.role.name}</td>
|
<td style={{ padding: 8 }}>{user.role?.name}</td>
|
||||||
|
<td style={{ padding: 8 }}><ResetPasswordRow userId={user.id} /></td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -7,19 +7,18 @@ Flutter mobile application for mental health professionals (mitra/partners).
|
|||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- **Framework:** Flutter (iOS + Android)
|
- **Framework:** Flutter (iOS + Android)
|
||||||
- **Auth:** Firebase Auth — Google Sign-In, Apple Sign-In, Phone OTP
|
- **Auth:** Self-managed (Phase 3.4). Phone OTP only — no Google / Apple. Access token lives in memory on an `AuthBridge`; refresh token persists in `flutter_secure_storage`. `firebase_auth` is no longer used; `firebase_messaging` is kept for FCM push.
|
||||||
- Fully native UI — no WebView, no Firebase-branded screens
|
- **API:** Calls public Fastify backend (`/api/mitra/` and `/api/shared/` routes). `shared.auth` covers refresh + logout for both apps.
|
||||||
- Use `firebase_auth` + `google_sign_in` packages
|
|
||||||
- **API:** Calls public Fastify backend (`/api/mitra/` and `/api/shared/` routes)
|
|
||||||
|
|
||||||
## Key Concepts
|
## Key Concepts
|
||||||
|
|
||||||
- Users are **mitra** — trained mental health professionals
|
- Users are **mitra** — trained mental health professionals
|
||||||
- Core flow: register + credential verification → set availability → accept sessions → chat with client → receive payment
|
- Core flow: phone OTP login → set availability → accept sessions → chat with client → receive payment
|
||||||
- Mitra accounts require approval from control center before going live
|
- Mitra accounts require approval from control center before going live (backend returns `ACCOUNT_INACTIVE` 403 on OTP verify when `is_active=false`)
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- Never call `/api/client/` or `/internal/` routes from this app
|
- Never call `/api/client/` or `/internal/` routes from this app
|
||||||
- All API calls must include Firebase JWT token in `Authorization` header
|
- API calls go through `ApiClient`; it auto-attaches the JWT from `AuthBridge` and auto-refreshes on 401
|
||||||
- Mitra role must be verified server-side on every relevant request
|
- WebSocket handshake (`/api/shared/ws`) sends the same access token in the first frame's `{type:"auth", token}` message
|
||||||
|
- Mitra role is encoded in the JWT claims (`user_type: "mitra"`) — the backend enforces the role per route; never trust client state alone
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import 'package:dio/dio.dart';
|
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 {
|
class ApiClient {
|
||||||
static const String baseUrl = String.fromEnvironment(
|
static const String baseUrl = String.fromEnvironment(
|
||||||
@@ -7,19 +12,45 @@ class ApiClient {
|
|||||||
defaultValue: 'https://api.halobestie.com',
|
defaultValue: 'https://api.halobestie.com',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final AuthBridge _bridge;
|
||||||
late final Dio _dio;
|
late final Dio _dio;
|
||||||
|
|
||||||
ApiClient() {
|
ApiClient(this._bridge) {
|
||||||
_dio = Dio(BaseOptions(baseUrl: baseUrl));
|
_dio = Dio(BaseOptions(baseUrl: baseUrl));
|
||||||
_dio.interceptors.add(InterceptorsWrapper(
|
_dio.interceptors.add(InterceptorsWrapper(
|
||||||
onRequest: (options, handler) async {
|
onRequest: (options, handler) {
|
||||||
final user = FirebaseAuth.instance.currentUser;
|
if (options.extra[_skipAuthKey] != true) {
|
||||||
if (user != null) {
|
final token = _bridge.accessToken;
|
||||||
final token = await user.getIdToken();
|
if (token != null) {
|
||||||
options.headers['Authorization'] = 'Bearer $token';
|
options.headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
handler.next(options);
|
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);
|
final response = await _dio.patch(path, data: data);
|
||||||
return response.data as Map<String, dynamic>;
|
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:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import '../auth/auth_bridge.dart';
|
||||||
import 'api_client.dart';
|
import 'api_client.dart';
|
||||||
|
|
||||||
part 'api_client_provider.g.dart';
|
part 'api_client_provider.g.dart';
|
||||||
|
|
||||||
@Riverpod(keepAlive: true)
|
@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
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$apiClientHash() => r'90c807f03b90249684265cc91739139c2c89eeb9';
|
String _$apiClientHash() => r'c0ade0ab10bbe40a30a4e5b17ea8c8c27517f502';
|
||||||
|
|
||||||
/// See also [apiClient].
|
/// See also [apiClient].
|
||||||
@ProviderFor(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 'dart:async';
|
||||||
import 'package:dio/dio.dart';
|
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 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import '../api/api_client.dart';
|
import '../api/api_client.dart';
|
||||||
import '../api/api_client_provider.dart';
|
import '../api/api_client_provider.dart';
|
||||||
|
import 'auth_bridge.dart';
|
||||||
|
import 'token_storage.dart';
|
||||||
|
|
||||||
part 'auth_notifier.g.dart';
|
part 'auth_notifier.g.dart';
|
||||||
|
|
||||||
// States
|
|
||||||
sealed class MitraAuthData {
|
sealed class MitraAuthData {
|
||||||
const MitraAuthData();
|
const MitraAuthData();
|
||||||
}
|
}
|
||||||
@@ -23,103 +22,170 @@ class MitraAuthAuthenticatedData extends MitraAuthData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class MitraAuthOtpSentData extends MitraAuthData {
|
class MitraAuthOtpSentData extends MitraAuthData {
|
||||||
final String verificationId;
|
final String otpRequestId;
|
||||||
const MitraAuthOtpSentData(this.verificationId);
|
final String? channelUsed;
|
||||||
|
const MitraAuthOtpSentData(this.otpRequestId, {this.channelUsed});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Riverpod(keepAlive: true)
|
@Riverpod(keepAlive: true)
|
||||||
class MitraAuth extends _$MitraAuth {
|
class MitraAuth extends _$MitraAuth {
|
||||||
FirebaseAuth get _auth => FirebaseAuth.instance;
|
final _storage = TokenStorage();
|
||||||
|
|
||||||
ApiClient get _apiClient => ref.read(apiClientProvider);
|
ApiClient get _apiClient => ref.read(apiClientProvider);
|
||||||
ConfirmationResult? _webConfirmationResult;
|
AuthBridge get _bridge => ref.read(authBridgeProvider);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
FutureOr<MitraAuthData> build() async {
|
FutureOr<MitraAuthData> build() async {
|
||||||
if (_auth.currentUser != null) {
|
_bridge.refresh = _refreshForBridge;
|
||||||
return await _verifyAndReturn();
|
_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 {
|
/// Reads the stored refresh token, rotates it against the backend, and
|
||||||
state = const AsyncLoading();
|
/// returns the fresh profile. Also updates the [AuthBridge] access token
|
||||||
if (kIsWeb) {
|
/// 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 {
|
try {
|
||||||
final confirmationResult = await _auth.signInWithPhoneNumber(phone);
|
final response = await _apiClient.postRaw(
|
||||||
_webConfirmationResult = confirmationResult;
|
'/api/shared/auth/refresh',
|
||||||
state = const AsyncData(MitraAuthOtpSentData('web'));
|
data: {'refresh_token': refreshToken},
|
||||||
} catch (e) {
|
skipAuth: true,
|
||||||
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();
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
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();
|
state = const AsyncLoading();
|
||||||
try {
|
try {
|
||||||
if (kIsWeb && _webConfirmationResult != null) {
|
final response = await _apiClient.postRaw(
|
||||||
await _webConfirmationResult!.confirm(smsCode);
|
'/api/mitra/auth/otp/request',
|
||||||
} else if (_auth.currentUser == null) {
|
data: {
|
||||||
// Only sign in if not already signed in via auto-verification
|
'phone': phone,
|
||||||
final credential = PhoneAuthProvider.credential(
|
if (channel != null) 'channel': channel,
|
||||||
verificationId: verificationId,
|
},
|
||||||
smsCode: smsCode,
|
skipAuth: true,
|
||||||
);
|
);
|
||||||
await _auth.signInWithCredential(credential);
|
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);
|
||||||
}
|
}
|
||||||
state = AsyncData(await _verifyAndReturn());
|
}
|
||||||
} on FirebaseAuthException {
|
|
||||||
state = AsyncError('OTP tidak valid. Coba lagi.', StackTrace.current);
|
Future<void> verifyOtp(String otpRequestId, String code) async {
|
||||||
} catch (e) {
|
state = const AsyncLoading();
|
||||||
state = AsyncError(e.toString().replaceFirst('Exception: ', ''), StackTrace.current);
|
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 {
|
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());
|
state = const AsyncData(MitraAuthInitialData());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<MitraAuthData> _verifyAndReturn() async {
|
String _otpRequestMessage(DioException e) {
|
||||||
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?;
|
final code = e.response?.data?['error']?['code'] as String?;
|
||||||
if (code == 'ACCOUNT_NOT_FOUND' || e.response?.statusCode == 404) {
|
switch (code) {
|
||||||
throw Exception('Akun tidak ditemukan. Hubungi administrator.');
|
case 'PHONE_INVALID':
|
||||||
} else if (code == 'ACCOUNT_INACTIVE' || e.response?.statusCode == 403) {
|
return 'Nomor HP tidak valid.';
|
||||||
throw Exception('Akun tidak aktif. Hubungi administrator.');
|
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.';
|
||||||
}
|
}
|
||||||
throw Exception('Gagal masuk. Coba lagi.');
|
}
|
||||||
} on Exception {
|
|
||||||
await _auth.signOut();
|
String _otpVerifyMessage(DioException e) {
|
||||||
throw Exception('Gagal masuk. Coba lagi.');
|
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
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$mitraAuthHash() => r'ddb09225b47b4e7683c9f8ad46abc21d9fb7a37b';
|
String _$mitraAuthHash() => r'c353c4b6cc0335c6276baa4029e361f4ec3b4a36';
|
||||||
|
|
||||||
/// See also [MitraAuth].
|
/// See also [MitraAuth].
|
||||||
@ProviderFor(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:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
import '../api/api_client.dart';
|
import '../api/api_client.dart';
|
||||||
import '../api/api_client_provider.dart';
|
import '../api/api_client_provider.dart';
|
||||||
|
import '../auth/auth_bridge.dart';
|
||||||
import '../constants.dart';
|
import '../constants.dart';
|
||||||
import '../notifications/notification_service.dart';
|
import '../notifications/notification_service.dart';
|
||||||
|
|
||||||
@@ -147,10 +147,9 @@ class ChatRequest extends _$ChatRequest {
|
|||||||
|
|
||||||
Future<void> _connectWebSocket() async {
|
Future<void> _connectWebSocket() async {
|
||||||
try {
|
try {
|
||||||
final user = FirebaseAuth.instance.currentUser;
|
final token = ref.read(authBridgeProvider).accessToken;
|
||||||
if (user == null) return;
|
if (token == null) return;
|
||||||
|
|
||||||
final token = await user.getIdToken();
|
|
||||||
final wsUrl = ApiClient.baseUrl
|
final wsUrl = ApiClient.baseUrl
|
||||||
.replaceFirst('https://', 'wss://')
|
.replaceFirst('https://', 'wss://')
|
||||||
.replaceFirst('http://', 'ws://');
|
.replaceFirst('http://', 'ws://');
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ part of 'chat_request_notifier.dart';
|
|||||||
// RiverpodGenerator
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$chatRequestHash() => r'c80b16e371658fbbaca88a75b48e16a3c0e057b3';
|
String _$chatRequestHash() => r'52aeaca594a44be3eeef7d264a1f311004b38416';
|
||||||
|
|
||||||
/// See also [ChatRequest].
|
/// See also [ChatRequest].
|
||||||
@ProviderFor(ChatRequest)
|
@ProviderFor(ChatRequest)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ part of 'extension_notifier.dart';
|
|||||||
// RiverpodGenerator
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$mitraExtensionHash() => r'4eed73b51454238e2cd40a255c148f232f281913';
|
String _$mitraExtensionHash() => r'e1346601df43c42aea6f2bc984b507547507a57c';
|
||||||
|
|
||||||
/// See also [MitraExtension].
|
/// See also [MitraExtension].
|
||||||
@ProviderFor(MitraExtension)
|
@ProviderFor(MitraExtension)
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
import '../api/api_client.dart';
|
import '../api/api_client.dart';
|
||||||
import '../api/api_client_provider.dart';
|
import '../api/api_client_provider.dart';
|
||||||
|
import '../auth/auth_bridge.dart';
|
||||||
import '../constants.dart';
|
import '../constants.dart';
|
||||||
|
|
||||||
part 'mitra_chat_notifier.g.dart';
|
part 'mitra_chat_notifier.g.dart';
|
||||||
@@ -137,8 +137,11 @@ class MitraChat extends _$MitraChat {
|
|||||||
createdAt: DateTime.parse(m['created_at'] as String),
|
createdAt: DateTime.parse(m['created_at'] as String),
|
||||||
)).toList();
|
)).toList();
|
||||||
|
|
||||||
final user = FirebaseAuth.instance.currentUser;
|
final token = ref.read(authBridgeProvider).accessToken;
|
||||||
final token = await user?.getIdToken();
|
if (token == null) {
|
||||||
|
state = const MitraChatErrorData('Sesi berakhir. Silakan login ulang.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
final wsUrl = ApiClient.baseUrl
|
final wsUrl = ApiClient.baseUrl
|
||||||
.replaceFirst('https://', 'wss://')
|
.replaceFirst('https://', 'wss://')
|
||||||
.replaceFirst('http://', 'ws://');
|
.replaceFirst('http://', 'ws://');
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ part of 'mitra_chat_notifier.dart';
|
|||||||
// RiverpodGenerator
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$mitraChatHash() => r'827aa874dbcf49c17f94c0507f5e0a4064bcede3';
|
String _$mitraChatHash() => r'd5f4819264b9c71ce29a640ee2cfee608ead5e9e';
|
||||||
|
|
||||||
/// See also [MitraChat].
|
/// See also [MitraChat].
|
||||||
@ProviderFor(MitraChat)
|
@ProviderFor(MitraChat)
|
||||||
|
|||||||
@@ -15,14 +15,14 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
|||||||
final List<TextEditingController> _controllers =
|
final List<TextEditingController> _controllers =
|
||||||
List.generate(6, (_) => TextEditingController());
|
List.generate(6, (_) => TextEditingController());
|
||||||
final List<FocusNode> _focusNodes = List.generate(6, (_) => FocusNode());
|
final List<FocusNode> _focusNodes = List.generate(6, (_) => FocusNode());
|
||||||
String? _verificationId;
|
String? _otpRequestId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
final data = ref.read(mitraAuthProvider).valueOrNull;
|
final data = ref.read(mitraAuthProvider).valueOrNull;
|
||||||
if (data is MitraAuthOtpSentData) {
|
if (data is MitraAuthOtpSentData) {
|
||||||
_verificationId = data.verificationId;
|
_otpRequestId = data.otpRequestId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,8 +60,8 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
|||||||
|
|
||||||
void _submit() {
|
void _submit() {
|
||||||
final otp = _otp;
|
final otp = _otp;
|
||||||
if (otp.length != 6 || _verificationId == null) return;
|
if (otp.length != 6 || _otpRequestId == null) return;
|
||||||
ref.read(mitraAuthProvider.notifier).verifyOtp(_verificationId!, otp);
|
ref.read(mitraAuthProvider.notifier).verifyOtp(_otpRequestId!, otp);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -69,10 +69,10 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
|||||||
final authState = ref.watch(mitraAuthProvider);
|
final authState = ref.watch(mitraAuthProvider);
|
||||||
final isLoading = authState is AsyncLoading;
|
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;
|
final data = authState.valueOrNull;
|
||||||
if (data is MitraAuthOtpSentData) {
|
if (data is MitraAuthOtpSentData) {
|
||||||
_verificationId = data.verificationId;
|
_otpRequestId = data.otpRequestId;
|
||||||
}
|
}
|
||||||
|
|
||||||
ref.listen(mitraAuthProvider, (prev, next) {
|
ref.listen(mitraAuthProvider, (prev, next) {
|
||||||
|
|||||||
@@ -161,6 +161,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
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:
|
code_builder:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -281,30 +289,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
version: "7.0.1"
|
||||||
firebase_auth:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: firebase_auth
|
|
||||||
sha256: cfc2d970829202eca09e2896f0a5aa7c87302817ecc0bdfa954f026046bf10ba
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "4.20.0"
|
|
||||||
firebase_auth_platform_interface:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: firebase_auth_platform_interface
|
|
||||||
sha256: a0270e1db3b2098a14cb2a2342b3cd2e7e458e0c391b1f64f6f78b14296ec093
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "7.3.0"
|
|
||||||
firebase_auth_web:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: firebase_auth_web
|
|
||||||
sha256: "64e067e763c6378b7e774e872f0f59f6812885e43020e25cde08f42e9459837b"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "5.12.0"
|
|
||||||
firebase_core:
|
firebase_core:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -422,6 +406,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.6.1"
|
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:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -472,6 +504,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.2"
|
version: "2.3.2"
|
||||||
|
hooks:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: hooks
|
||||||
|
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.3"
|
||||||
hooks_riverpod:
|
hooks_riverpod:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -520,14 +560,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.5"
|
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:
|
js:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: js
|
name: js
|
||||||
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.2"
|
version: "0.6.7"
|
||||||
json_annotation:
|
json_annotation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -608,6 +664,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
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:
|
package_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -624,6 +696,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.1"
|
||||||
|
path_provider:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider
|
||||||
|
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.5"
|
||||||
|
path_provider_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_android
|
||||||
|
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.1"
|
||||||
|
path_provider_foundation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_foundation
|
||||||
|
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.6.0"
|
||||||
|
path_provider_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_linux
|
||||||
|
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.1"
|
||||||
|
path_provider_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_platform_interface
|
||||||
|
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.2"
|
||||||
|
path_provider_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_windows
|
||||||
|
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.0"
|
||||||
petitparser:
|
petitparser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -632,6 +752,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.2"
|
version: "7.0.2"
|
||||||
|
platform:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: platform
|
||||||
|
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.6"
|
||||||
plugin_platform_interface:
|
plugin_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -664,6 +792,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.0"
|
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:
|
riverpod:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -877,6 +1013,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.5"
|
version: "2.4.5"
|
||||||
|
win32:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: win32
|
||||||
|
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.15.0"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -902,5 +1046,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.10.0 <4.0.0"
|
dart: ">=3.10.3 <4.0.0"
|
||||||
flutter: ">=3.38.1"
|
flutter: ">=3.38.4"
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ dependencies:
|
|||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
# Firebase
|
# Firebase (Messaging only — Auth dropped in Phase 3.4, self-managed JWT now)
|
||||||
firebase_core: ^2.27.1
|
firebase_core: ^2.27.1
|
||||||
firebase_auth: ^4.18.0
|
|
||||||
firebase_messaging: ^14.7.15
|
firebase_messaging: ^14.7.15
|
||||||
|
|
||||||
|
# Secure token storage (refresh + access)
|
||||||
|
flutter_secure_storage: ^9.2.2
|
||||||
|
|
||||||
# HTTP & WebSocket
|
# HTTP & WebSocket
|
||||||
dio: ^5.4.3
|
dio: ^5.4.3
|
||||||
web_socket_channel: ^2.4.5
|
web_socket_channel: ^2.4.5
|
||||||
|
|||||||
@@ -1,273 +0,0 @@
|
|||||||
# Phase 3.3 Testing & Outstanding Regression Checklist
|
|
||||||
|
|
||||||
This is a **reminder document** — consolidated testing work for Phase 3.3 plus every outstanding test item carried over from earlier Phase 3 iterations.
|
|
||||||
|
|
||||||
Tick boxes as you verify.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 1 — Phase 3.3: Session Topic Sensitivity
|
|
||||||
|
|
||||||
### 1.1 Database / Migration
|
|
||||||
|
|
||||||
- [ ] Migration runs cleanly on an existing dev DB (no errors, all `IF NOT EXISTS` / `ON CONFLICT` paths hit)
|
|
||||||
- [ ] `chat_sessions.topic_sensitivity` column exists with default `'regular'` and NOT NULL
|
|
||||||
- [ ] Existing sessions (created before the migration) have `topic_sensitivity = 'regular'` after migration
|
|
||||||
- [ ] `session_sensitivity_log` table exists with correct FKs (sessions, mitras)
|
|
||||||
- [ ] `idx_chat_sessions_topic_sensitivity` index created
|
|
||||||
- [ ] `idx_session_sensitivity_log_session` index created
|
|
||||||
- [ ] `app_config` has `sensitive_flip_confirmation_enabled = true` by default
|
|
||||||
- [ ] `app_config` has `sensitive_flag_one_way_latch = false` by default
|
|
||||||
|
|
||||||
### 1.2 Customer Flow (client_app)
|
|
||||||
|
|
||||||
**Topic selection bottom sheet**
|
|
||||||
- [ ] Tap "Mulai Curhat" → topic selection bottom sheet appears
|
|
||||||
- [ ] Sheet **cannot** be dismissed by tapping outside
|
|
||||||
- [ ] Sheet **cannot** be dismissed by swiping down
|
|
||||||
- [ ] System back button cancels entire "Mulai Curhat" flow (does NOT open pricing)
|
|
||||||
- [ ] Copy matches PRD (title, body, sub-question, helper line)
|
|
||||||
- [ ] "Topik umum" button styling is primary
|
|
||||||
- [ ] "Topik sensitif" button styling is secondary but equal weight (not de-emphasized)
|
|
||||||
|
|
||||||
**Request submission**
|
|
||||||
- [ ] Tap "Topik umum" → pricing sheet opens with topic pre-selected as regular
|
|
||||||
- [ ] Tap "Topik sensitif" → pricing sheet opens with topic pre-selected as sensitive
|
|
||||||
- [ ] After pricing confirm → `POST /api/client/chat/request` body includes `topic_sensitivity: regular|sensitive`
|
|
||||||
- [ ] Backend rejects request with missing `topic_sensitivity` (400 BAD_REQUEST)
|
|
||||||
- [ ] Backend rejects request with invalid `topic_sensitivity` value (e.g., `"other"`)
|
|
||||||
- [ ] Created `chat_sessions` row has correct `topic_sensitivity` value
|
|
||||||
|
|
||||||
**Customer UI after request**
|
|
||||||
- [ ] Chat screen stays pink (no yellow), regardless of flag
|
|
||||||
- [ ] Customer history screen: no badge on any row regardless of flag
|
|
||||||
- [ ] Customer transcript screen: stays pink
|
|
||||||
- [ ] Customer receives `session_topic_updated` WS message after mitra flip → **no UI change**, no error, no crash
|
|
||||||
|
|
||||||
### 1.3 Mitra Flow — Incoming Request (mitra_app)
|
|
||||||
|
|
||||||
- [ ] Regular request: overlay has no badge, no yellow accent
|
|
||||||
- [ ] Sensitive request: overlay shows "Topik sensitif" badge + yellow accent
|
|
||||||
- [ ] Overlay payload from WS includes `topic_sensitivity`
|
|
||||||
- [ ] Overlay payload from FCM fallback includes `topic_sensitivity` (or app fetches on open)
|
|
||||||
- [ ] Overlay payload from `getPendingRequestsForMitra` (app-resume path) includes `topic_sensitivity`
|
|
||||||
- [ ] Mitra accepts sensitive request → lands in active chat screen with correct flag
|
|
||||||
|
|
||||||
### 1.4 Mitra Flow — Active Chat Screen
|
|
||||||
|
|
||||||
- [ ] Sensitive active session: yellow doodle background
|
|
||||||
- [ ] Regular active session: pink doodle (unchanged behavior)
|
|
||||||
- [ ] Header banner shows "Topik sensitif" label only when sensitive
|
|
||||||
- [ ] App-bar toggle icon visible (flag / flag_outlined depending on state)
|
|
||||||
|
|
||||||
**Flip toggle — confirmation enabled (default)**
|
|
||||||
- [ ] Tap toggle regular → sensitive → dialog appears: "Tandai sesi ini sebagai sensitif?"
|
|
||||||
- [ ] "Batal" cancels, no state change, no audit log entry, no WS broadcast
|
|
||||||
- [ ] "Tandai" flips, background turns yellow instantly, log entry created
|
|
||||||
- [ ] Tap toggle sensitive → regular → dialog: "Tandai sesi ini sebagai topik umum?"
|
|
||||||
|
|
||||||
**Flip toggle — confirmation disabled (via CC config)**
|
|
||||||
- [ ] Toggle `sensitive_flip_confirmation_enabled` to `false` in CC
|
|
||||||
- [ ] Flip happens immediately, toast "Sesi ditandai sensitif" / "Sesi ditandai topik umum"
|
|
||||||
- [ ] No dialog appears
|
|
||||||
|
|
||||||
**One-way latch — disabled (default)**
|
|
||||||
- [ ] Can flip regular → sensitive → regular → sensitive freely
|
|
||||||
|
|
||||||
**One-way latch — enabled (via CC config)**
|
|
||||||
- [ ] Toggle `sensitive_flag_one_way_latch` to `true` in CC
|
|
||||||
- [ ] Session that was regular: can flip to sensitive; toggle then disabled
|
|
||||||
- [ ] Session that was already sensitive at latch-enable time: toggle disabled with tooltip
|
|
||||||
- [ ] Attempt to flip sensitive → regular with latch on: API returns 409 `SENSITIVITY_LATCHED`
|
|
||||||
- [ ] Error dialog shown: "Sesi sudah ditandai sensitif dan tidak bisa diubah kembali."
|
|
||||||
|
|
||||||
**Audit trail**
|
|
||||||
- [ ] Every successful flip creates a `session_sensitivity_log` row with correct `from_value`, `to_value`, `changed_by_mitra_id`
|
|
||||||
- [ ] No-op flip (e.g., tap confirm but value didn't actually change) does NOT create a log row
|
|
||||||
- [ ] Log entries ordered correctly (ascending `created_at`)
|
|
||||||
|
|
||||||
### 1.5 Mitra Flow — Extension
|
|
||||||
|
|
||||||
- [ ] Customer requests extension on regular session → mitra extension card has no badge
|
|
||||||
- [ ] Customer requests extension on sensitive session → mitra extension card shows "Topik sensitif" badge + yellow accent
|
|
||||||
- [ ] Mitra flipped session mid-chat regular → sensitive, then customer requests extension → extension card reflects **current** sensitive flag
|
|
||||||
- [ ] Extension accepted → flag carries over unchanged to extended session
|
|
||||||
|
|
||||||
### 1.6 Mitra Flow — History & Transcript
|
|
||||||
|
|
||||||
- [ ] Mitra history list row shows "Topik sensitif" badge for sensitive sessions
|
|
||||||
- [ ] Mitra history list row has no badge for regular sessions
|
|
||||||
- [ ] Mitra transcript view: sensitive session → yellow doodle background
|
|
||||||
- [ ] Mitra transcript view: regular session → pink doodle
|
|
||||||
- [ ] Customer-side history and transcript: always pink, no badge
|
|
||||||
|
|
||||||
### 1.7 Mitra Flow — Edge Cases
|
|
||||||
|
|
||||||
- [ ] Mitra tries to flip flag on a session they don't own → 403 FORBIDDEN
|
|
||||||
- [ ] Mitra tries to flip flag on a `CLOSING` session → 409 SESSION_NOT_ACTIVE
|
|
||||||
- [ ] Mitra tries to flip flag on a `COMPLETED` session → 409 SESSION_NOT_ACTIVE
|
|
||||||
- [ ] Mitra tries to flip flag on an `EXPIRED` session → 409 SESSION_NOT_ACTIVE
|
|
||||||
- [ ] Invalid `topic_sensitivity` value sent to PATCH endpoint → 400 BAD_REQUEST
|
|
||||||
- [ ] Customer tries to call PATCH endpoint → 403 FORBIDDEN (only mitra allowed)
|
|
||||||
|
|
||||||
### 1.8 Control Center — Settings
|
|
||||||
|
|
||||||
- [ ] Settings page has new "Sensitivitas Topik" section
|
|
||||||
- [ ] `sensitive_flip_confirmation_enabled` checkbox reflects current backend value
|
|
||||||
- [ ] `sensitive_flag_one_way_latch` checkbox reflects current backend value
|
|
||||||
- [ ] PATCH `/internal/config/sensitivity` persists changes
|
|
||||||
- [ ] Changes take effect immediately on next mitra flip (no app restart needed on mitra side, if mitra fetches config dynamically)
|
|
||||||
|
|
||||||
### 1.9 Control Center — Sessions Page
|
|
||||||
|
|
||||||
- [ ] Sessions list has new filter dropdown (All / Umum / Sensitif)
|
|
||||||
- [ ] Filter "Sensitif" returns only sessions with `topic_sensitivity = 'sensitive'`
|
|
||||||
- [ ] Filter "Umum" returns only `regular`
|
|
||||||
- [ ] Filter "All" returns everything (backward-compatible)
|
|
||||||
- [ ] Filter works combined with existing status filter
|
|
||||||
- [ ] Session list has new "Topik" column showing badge (green "Umum" / yellow "Sensitif")
|
|
||||||
|
|
||||||
### 1.10 Control Center — Session Detail
|
|
||||||
|
|
||||||
- [ ] Session detail page shows current `topic_sensitivity`
|
|
||||||
- [ ] Session detail shows sensitivity audit trail timeline: "Mitra {name} menandai topik sebagai {from→to} pada {timestamp}"
|
|
||||||
- [ ] Timeline ordered ascending
|
|
||||||
- [ ] Sessions with no flips show empty timeline (no error)
|
|
||||||
|
|
||||||
### 1.11 Control Center — Dashboard
|
|
||||||
|
|
||||||
- [ ] Dashboard shows "Sesi Sensitif" card with total count + 30-day % breakdown
|
|
||||||
- [ ] Percentage math correct (sensitive / total × 100, rounded to 1 decimal)
|
|
||||||
- [ ] Edge case: 0 sessions in last 30 days → shows `0%` not `NaN`
|
|
||||||
|
|
||||||
### 1.12 Control Center — Mitra Activity
|
|
||||||
|
|
||||||
- [ ] Summary table has new columns: Sensitive Total, Sensitive Accepted, Sensitive Rate (%)
|
|
||||||
- [ ] Mitra with 0 sensitive requests shows `—` (not `0%`)
|
|
||||||
- [ ] Sensitive rate computed correctly (sensitive_accepted / sensitive_total × 100)
|
|
||||||
- [ ] Detail log table: optional new "Topik" column with badge
|
|
||||||
- [ ] Date range filter still works with new columns
|
|
||||||
|
|
||||||
### 1.13 Control Center — Integration Regression
|
|
||||||
|
|
||||||
- [ ] Existing settings (anonymity, free-trial, extension-timeout, early-end, mitra-ping, price-tiers) still work
|
|
||||||
- [ ] Existing sessions filter (by status) still works
|
|
||||||
- [ ] Existing dashboard cards still render
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 2 — Outstanding Items From Phase 3.2
|
|
||||||
|
|
||||||
Carried over from `project_phase3_testing_status.md` (2026-04-15):
|
|
||||||
|
|
||||||
### 2.1 Chat Request Overlay
|
|
||||||
|
|
||||||
- [ ] **Multiple concurrent chat requests** — verify queue behavior (one shown at a time, next appears when current resolved)
|
|
||||||
- [ ] Stale request: "cancelled by customer" message shown + requires acknowledge (no auto-dismiss)
|
|
||||||
- [ ] Stale request: "accepted by other bestie" message shown + requires acknowledge
|
|
||||||
- [ ] Stale request: "expired" message shown + requires acknowledge
|
|
||||||
- [ ] Swipe-to-dismiss (ignore) does NOT send reject to backend
|
|
||||||
- [ ] Ignored request eventually logs as `ignored` in `chat_request_notifications` after 60s timeout
|
|
||||||
- [ ] Request `missed` (another mitra accepted first) logs correctly
|
|
||||||
- [ ] `active_session_count` captured correctly at notification creation
|
|
||||||
|
|
||||||
### 2.2 End-to-End Flows
|
|
||||||
|
|
||||||
- [ ] Full chat flow: pair → chat → extension → closure (customer + mitra)
|
|
||||||
- [ ] Goodbye flow: session expires → closing → both submit goodbye → completed
|
|
||||||
- [ ] Extension accepted mid-flow → session resumes, timer extends, no grace timer lingering
|
|
||||||
- [ ] Extension rejected → session moves to closing, both see closure UI
|
|
||||||
- [ ] Extension timeout (no mitra response) → closing
|
|
||||||
|
|
||||||
### 2.3 iOS Coverage (still partially untested)
|
|
||||||
|
|
||||||
- [ ] OTP login on iOS (customer)
|
|
||||||
- [ ] OTP login on iOS (mitra)
|
|
||||||
- [ ] Push notifications on iOS (customer + mitra)
|
|
||||||
- [ ] FCM token registration on iOS
|
|
||||||
- [ ] Chat screen rendering on iOS
|
|
||||||
- [ ] Back button behavior on iOS (deep-link pop fallback)
|
|
||||||
- [ ] Overlay on iOS (from `project_phase3_testing_status`: iOS setup started but incomplete)
|
|
||||||
- [ ] Splash screen on iOS
|
|
||||||
- [ ] Onboarding carousel on iOS
|
|
||||||
- [ ] Keyboard handling on iOS (chat input, goodbye form)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 3 — Outstanding Items From Phase 3 / 3.1
|
|
||||||
|
|
||||||
### 3.1 Session Lifecycle
|
|
||||||
|
|
||||||
- [ ] Server restart mid-session: session timer is restored from DB (`restoreActiveTimers`)
|
|
||||||
- [ ] Stale active sessions auto-complete on restart
|
|
||||||
- [ ] Closing sessions with stale grace timers auto-complete on restart
|
|
||||||
- [ ] Session expired from customer side (5-min countdown display)
|
|
||||||
- [ ] Abandoned session during closure grace period → auto-completes
|
|
||||||
- [ ] **Known limitation**: multi-instance backend sessions not supported until Valkey keyspace notifications implemented (out of scope, just confirm single-instance behavior)
|
|
||||||
|
|
||||||
### 3.2 Chat Mechanics
|
|
||||||
|
|
||||||
- [ ] Message status transitions (sent → delivered → read) work correctly
|
|
||||||
- [ ] Typing indicator shows/hides correctly on both sides
|
|
||||||
- [ ] Messages received while backgrounded are marked `delivered` on foreground resume
|
|
||||||
- [ ] Messages viewed are marked `read` and the read receipt propagates back to sender
|
|
||||||
- [ ] Unread badge on home screen updates correctly (client_app + mitra_app)
|
|
||||||
|
|
||||||
### 3.3 Navigation / UI
|
|
||||||
|
|
||||||
- [ ] All navigation uses `GoRouter.context.push/go` (no leftover `Navigator.pushNamed`)
|
|
||||||
- [ ] Deep-linked screens work with `canPop` fallback + `PopScope`
|
|
||||||
- [ ] `notification_service` uses `go` (not `push`) for terminal states
|
|
||||||
- [ ] Splash screen hides auth loading flash on both apps
|
|
||||||
- [ ] Goodbye views use `SingleChildScrollView` (no keyboard overflow)
|
|
||||||
|
|
||||||
### 3.4 Control Center Settings
|
|
||||||
|
|
||||||
- [ ] Free trial config: toggle + duration edit
|
|
||||||
- [ ] Extension timeout: edit seconds
|
|
||||||
- [ ] Early end: toggle mitra / customer independently
|
|
||||||
- [ ] Mitra ping: toggle require + interval
|
|
||||||
- [ ] Price tiers: add / edit / remove tiers and verify client_app pricing sheet reflects changes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 4 — Cross-Cutting / Pre-Release
|
|
||||||
|
|
||||||
### 4.1 Regression Checks (do after Phase 3.3 merge)
|
|
||||||
|
|
||||||
- [ ] Existing customer auth flow still works (welcome → OTP → register → home)
|
|
||||||
- [ ] Existing mitra auth flow still works
|
|
||||||
- [ ] Existing control center login still works (admin@halobestie.com)
|
|
||||||
- [ ] Pairing flow (mulai curhat → matched) still works end-to-end
|
|
||||||
- [ ] All existing WS messages still processed (no regressions from new `session_topic_updated` handler)
|
|
||||||
|
|
||||||
### 4.2 Platform Coverage
|
|
||||||
|
|
||||||
- [ ] Android: customer app on emulator (Medium_Phone_API_36.1)
|
|
||||||
- [ ] Android: mitra app on physical device (SM-A530F, 52002a5db8e0c46b)
|
|
||||||
- [ ] iOS: customer app (Mac + simulator / physical)
|
|
||||||
- [ ] iOS: mitra app (Mac + simulator / physical)
|
|
||||||
- [ ] Control center: Chrome latest
|
|
||||||
- [ ] Control center: Firefox / Safari (if required)
|
|
||||||
|
|
||||||
### 4.3 Load / Concurrency (sanity)
|
|
||||||
|
|
||||||
- [ ] 2 concurrent customers requesting chat at the same time — both find a mitra (or one waits)
|
|
||||||
- [ ] 1 customer, 5 mitras online — blast notification reaches all 5
|
|
||||||
- [ ] Mitra accepts after another mitra already accepted → receives `missed` with `accepted_by_other`
|
|
||||||
- [ ] Backend restart with active sessions → timers restored, no data loss
|
|
||||||
|
|
||||||
### 4.4 Config Flag Interactions
|
|
||||||
|
|
||||||
- [ ] `sensitive_flag_one_way_latch = true` + existing sensitive session → toggle disabled
|
|
||||||
- [ ] `sensitive_flip_confirmation_enabled = false` + rapid flips → no race, all logged in order
|
|
||||||
- [ ] Both config flags toggled together → no conflict
|
|
||||||
|
|
||||||
### 4.5 Known Blockers / Deferred
|
|
||||||
|
|
||||||
Not tests — tracked here so they don't get forgotten:
|
|
||||||
|
|
||||||
- [ ] **Valkey keyspace notifications** — required for multi-instance session timers (noted in memory as future work)
|
|
||||||
- [ ] **Mitra QC auto-flag** — auto-flagging high-rejection mitras on CC (future phase)
|
|
||||||
- [ ] **Merge-on-link** for social login (currently reject-on-existing)
|
|
||||||
- [ ] **Phase 3.4 auth migration** — separate phase, not blocking 3.3 testing
|
|
||||||
456
requirement/phase3.4-testing.md
Normal file
456
requirement/phase3.4-testing.md
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
# Phase 3.4 Testing & Outstanding Regression Checklist
|
||||||
|
|
||||||
|
Consolidated testing document covering Phase 3.4 (self-managed auth) plus every outstanding test item carried over from Phases 3.0 – 3.3. Replaces `phase3.3-testing.md`.
|
||||||
|
|
||||||
|
Tick boxes as you verify. Cluster labels in brackets: **[BE]** backend / curl, **[CC]** control_center, **[M]** mitra_app, **[C]** client_app.
|
||||||
|
|
||||||
|
Related docs: [phase3.4.md](./phase3.4.md), [phase3.4-plan.md](./phase3.4-plan.md), [phase3.3.md](./phase3.3.md), [phase3.3-plan.md](./phase3.3-plan.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 1 — Phase 3.4: Self-Managed Auth
|
||||||
|
|
||||||
|
### 1.1 Backend: Schema, Services, Env
|
||||||
|
|
||||||
|
- [ ] **[BE]** `npm run db:migrate` is idempotent — re-running reports "skipping" on existing objects, adds new 3.4 tables/columns, exits 0
|
||||||
|
- [ ] **[BE]** `auth_sessions`, `otp_requests` tables exist with correct columns + indexes
|
||||||
|
- [ ] **[BE]** `customers` has new nullable columns: `email`, `google_sub (UNIQUE)`, `apple_sub (UNIQUE)`
|
||||||
|
- [ ] **[BE]** `control_center_users` has `password_hash`, `failed_login_count`, `lockout_until`
|
||||||
|
- [ ] **[BE]** `firebase_uid` columns still exist but are nullable + unused by code (cleanup migration deferred)
|
||||||
|
- [ ] **[BE]** `app_config` seeded with 6 new OTP / CC-lockout keys at default values
|
||||||
|
- [ ] **[BE]** `npm run db:seed` creates the super admin with bcrypt-hashed password (runs twice → second is a no-op)
|
||||||
|
- [ ] **[BE]** Server boots cleanly on `INTERNAL_PORT=3001` + `PUBLIC_PORT=3000`
|
||||||
|
- [ ] **[BE]** Missing `AUTH_JWT_SECRET` (or <32 chars) → server refuses to start / logs a clear error
|
||||||
|
- [ ] **[BE]** CORS config on internal listener allows `CC_ORIGIN` with `credentials: true`
|
||||||
|
|
||||||
|
### 1.2 Backend: Token & Session Service (curl)
|
||||||
|
|
||||||
|
- [ ] **[BE]** Access token claims: `{ sub, user_type, session_id, iat, exp }` — HS256, 1h TTL
|
||||||
|
- [ ] **[BE]** Refresh token is opaque (uuid.random), 30d TTL, bcrypt-hashed in `auth_sessions.refresh_token_hash`
|
||||||
|
- [ ] **[BE]** Refresh rotates: old refresh after one use → `REFRESH_INVALID`, same `session_id` persists, new access token issued
|
||||||
|
- [ ] **[BE]** Logout deletes the `auth_sessions` row → subsequent refresh → `REFRESH_INVALID`
|
||||||
|
- [ ] **[BE]** Multi-device: same user signs in twice → two `auth_sessions` rows; logout on one doesn't kill the other
|
||||||
|
- [ ] **[BE]** Tampered JWT (signature broken) → 401 `TOKEN_INVALID`
|
||||||
|
- [ ] **[BE]** Expired JWT → 401; api_client auto-refreshes and retries once
|
||||||
|
- [ ] **[BE]** `device_info` JSONB populated with `user_agent` + `ip` on each session
|
||||||
|
- [ ] **[BE]** Session fingerprint survives refresh (no churn)
|
||||||
|
- [ ] **[BE]** Documented 1h access-token revocation window: post-logout access token still verifies until natural expiry (expected — Valkey revoked_sessions pre-wired but not active)
|
||||||
|
|
||||||
|
### 1.3 Backend: OTP Service (stub mode)
|
||||||
|
|
||||||
|
> Fazpass is stubbed. Dev code is logged to backend console as `[OTP STUB] phone=… code=… ref=…`.
|
||||||
|
|
||||||
|
- [ ] **[BE]** `POST /api/client/auth/otp/request` with valid phone → 200 + `otp_request_id`, stub logs code
|
||||||
|
- [ ] **[BE]** `POST /api/mitra/auth/otp/request` same, separate user_type
|
||||||
|
- [ ] **[BE]** Invalid phone format (not E.164) → 422 `PHONE_INVALID`
|
||||||
|
- [ ] **[BE]** Resend within `otp_resend_cooldown_seconds` (default 60) → 429 `OTP_COOLDOWN`
|
||||||
|
- [ ] **[BE]** 4th request in an hour from same phone → 429 `OTP_RATE_LIMIT_PHONE`
|
||||||
|
- [ ] **[BE]** 11th request in an hour from same IP → 429 `OTP_RATE_LIMIT_IP`
|
||||||
|
- [ ] **[BE]** OTP verify with wrong code → 401 `CODE_MISMATCH`, `attempts` incremented
|
||||||
|
- [ ] **[BE]** 6th verify attempt (after 5 wrongs) → 429 `OTP_ATTEMPTS_EXCEEDED`
|
||||||
|
- [ ] **[BE]** OTP verify after `expires_at` (default 5min) → 410 `OTP_EXPIRED`
|
||||||
|
- [ ] **[BE]** OTP verify twice with correct code → second call → 409 `OTP_USED`
|
||||||
|
- [ ] **[BE]** Mitra OTP verified via `/api/client/auth/otp/verify` → 400 `WRONG_FLOW`
|
||||||
|
- [ ] **[BE]** Customer OTP verified via `/api/mitra/auth/otp/verify` → 400 `WRONG_FLOW`
|
||||||
|
|
||||||
|
### 1.4 Backend: Social Identity (post-creds)
|
||||||
|
|
||||||
|
> Deferred until Google/Apple OAuth credentials land. Run this block after setting `GOOGLE_OAUTH_CLIENT_IDS` + `APPLE_*` in `.env` and flipping `ENABLE_SOCIAL_AUTH=true` on client_app.
|
||||||
|
|
||||||
|
- [ ] **[BE]** Google: valid id_token → customer created / upgraded, tokens issued
|
||||||
|
- [ ] **[BE]** Google: wrong audience (id_token from different client) → 401 `INVALID_ID_TOKEN`
|
||||||
|
- [ ] **[BE]** Google: tampered signature → 401 `INVALID_ID_TOKEN`
|
||||||
|
- [ ] **[BE]** Google: `google_sub` already linked to another customer → 409 `IDENTITY_CONFLICT` (merge deferred)
|
||||||
|
- [ ] **[BE]** Apple: valid id_token → customer created / upgraded
|
||||||
|
- [ ] **[BE]** Apple: invalid signature (JWKS mismatch) → 401 `INVALID_ID_TOKEN`
|
||||||
|
- [ ] **[BE]** Apple: missing email (user opted out) → account still created, `email` NULL
|
||||||
|
- [ ] **[BE]** Apple: `apple_sub` already linked elsewhere → 409 `IDENTITY_CONFLICT`
|
||||||
|
|
||||||
|
### 1.5 Backend: Anonymous + Upgrade
|
||||||
|
|
||||||
|
- [ ] **[BE]** `POST /api/shared/auth/anonymous` → creates customer with auto-generated `display_name`, returns tokens + profile
|
||||||
|
- [ ] **[BE]** Anonymous customer row: `phone=NULL`, `google_sub=NULL`, `apple_sub=NULL`
|
||||||
|
- [ ] **[BE]** OTP verify with `anonymous_customer_id` → upgrades SAME customer row; customer UUID unchanged; display_name preserved
|
||||||
|
- [ ] **[BE]** Google verify with `anonymous_customer_id` → upgrades SAME customer row; google_sub added; display_name preserved if present, else backfilled from Google profile
|
||||||
|
- [ ] **[BE]** Apple verify with `anonymous_customer_id` → upgrades SAME row
|
||||||
|
- [ ] **[BE]** Upgrade with `anonymous_customer_id` when identity is ALREADY taken by a different customer → 409 `IDENTITY_CONFLICT` (anonymous row is NOT deleted)
|
||||||
|
- [ ] **[BE]** Upgrade issues a NEW auth_sessions row. Old anonymous refresh still works (separate session) and reflects the upgraded profile on subsequent calls — **intentional for multi-device UX; do not "fix" without discussion**
|
||||||
|
|
||||||
|
### 1.6 Backend: Auth Middleware + Cross-User-Type Guards
|
||||||
|
|
||||||
|
- [ ] **[BE]** Protected route with no `Authorization` header → 401 `AUTH_MISSING`
|
||||||
|
- [ ] **[BE]** Expired access token → 401 → api_client refreshes → retry succeeds
|
||||||
|
- [ ] **[BE]** Customer JWT calling `/api/mitra/auth/me` → 403 `FORBIDDEN`
|
||||||
|
- [ ] **[BE]** Mitra JWT calling `/api/client/auth/me` → 403 `FORBIDDEN`
|
||||||
|
- [ ] **[BE]** Customer JWT calling `/internal/*` → 403 `FORBIDDEN`
|
||||||
|
- [ ] **[BE]** `request.auth = { userType, userId, sessionId }` populated correctly on every protected route (spot-check in logs)
|
||||||
|
|
||||||
|
### 1.7 Backend: Mitra Activation + Inactive Flow
|
||||||
|
|
||||||
|
- [ ] **[BE]** New phone number → auto-creates `mitras` row with `is_active=false`; OTP verify returns 403 `ACCOUNT_INACTIVE`
|
||||||
|
- [ ] **[BE]** Admin activates mitra via CC (or direct DB `UPDATE mitras SET is_active=true`) → re-request OTP → verify succeeds, tokens returned
|
||||||
|
- [ ] **[BE]** Mitra re-OTP after deactivation → 403 `ACCOUNT_INACTIVE` on next verify; existing sessions keep working until natural JWT expiry (documented 1h window)
|
||||||
|
|
||||||
|
### 1.8 Backend: Password / CC Login
|
||||||
|
|
||||||
|
- [ ] **[BE]** `POST /internal/auth/login` with correct creds → sets `cc_refresh_token` httpOnly cookie, returns access token + profile + role/permissions
|
||||||
|
- [ ] **[BE]** Wrong password → 401 `INVALID_CREDENTIALS`, `failed_login_count` incremented
|
||||||
|
- [ ] **[BE]** 5th wrong password → `lockout_until = NOW() + 15min`; subsequent attempts (even with right password) → 423 `ACCOUNT_LOCKED`
|
||||||
|
- [ ] **[BE]** Successful login resets `failed_login_count` + `lockout_until`
|
||||||
|
- [ ] **[BE]** `PATCH /internal/control-center-users/me/password` with wrong current → 401 `INVALID_CREDENTIALS`
|
||||||
|
- [ ] **[BE]** Password complexity rejected: <8 chars → `PASSWORD_TOO_SHORT`; no digit → `PASSWORD_MISSING_DIGIT`; no upper → `PASSWORD_MISSING_UPPERCASE`; no lower → `PASSWORD_MISSING_LOWERCASE`
|
||||||
|
- [ ] **[BE]** Admin-forced reset via `PATCH /internal/control-center-users/:id/password` requires `control_center_users:update` permission
|
||||||
|
- [ ] **[BE]** Create CC user `POST /internal/control-center-users` requires `control_center_users:create`; stores bcrypt hash; rejects weak password with same complexity codes
|
||||||
|
- [ ] **[BE]** Seed script does NOT enforce complexity (intentional bootstrap loophole — dev `admin123` works)
|
||||||
|
|
||||||
|
### 1.9 Backend: WebSocket Auth
|
||||||
|
|
||||||
|
- [ ] **[BE]** WS handshake with valid JWT in first `{type:"auth", token}` frame → `{type:"auth_ok"}`
|
||||||
|
- [ ] **[BE]** WS handshake with missing token → connection closed with code 4401 (or equivalent)
|
||||||
|
- [ ] **[BE]** WS handshake with expired/tampered token → closed
|
||||||
|
- [ ] **[BE]** `request.auth` equivalent available on WS connection (userId + userType + sessionId)
|
||||||
|
- [ ] **[BE]** Old Firebase-era token on WS → rejected (documented expected failure from pre-3.4 builds)
|
||||||
|
|
||||||
|
### 1.10 Control Center: UI + Auth
|
||||||
|
|
||||||
|
- [ ] **[CC]** `firebase` dep is gone from `package.json`; bundle is ~280KB not ~800KB
|
||||||
|
- [ ] **[CC]** Login page: right creds → redirects to `/dashboard`
|
||||||
|
- [ ] **[CC]** Login page: wrong creds → inline error "Email atau password salah."
|
||||||
|
- [ ] **[CC]** Login page: account-locked → shows backend message (or translated Indonesian)
|
||||||
|
- [ ] **[CC]** Refresh cookie persists across a hard browser reload (F5) → user stays logged in
|
||||||
|
- [ ] **[CC]** Close tab + reopen → `AuthContext.bootstrap` calls `/internal/auth/refresh` with the cookie → user stays logged in
|
||||||
|
- [ ] **[CC]** Logout clears access token in memory + calls `/logout` + clears cookie → next action redirects to login
|
||||||
|
- [ ] **[CC]** Access token expires mid-session → next api call 401 → bridge auto-refreshes → request succeeds transparently (simulate by breaking the access token in devtools)
|
||||||
|
- [ ] **[CC]** Refresh fails (cookie expired / revoked) → `onUnauthenticated` → user bounced to login
|
||||||
|
- [ ] **[CC]** Two concurrent 401s (fire two API calls in parallel with a broken token) → only one refresh fires; both retries succeed
|
||||||
|
- [ ] **[CC]** Sidebar "Ganti password" form: wrong current → "Password saat ini salah."
|
||||||
|
- [ ] **[CC]** Sidebar "Ganti password" form: weak new password → shows backend complexity message
|
||||||
|
- [ ] **[CC]** Sidebar "Ganti password" form: success → "Password berhasil diubah." (and next login uses the new password)
|
||||||
|
- [ ] **[CC]** UsersPage create: all fields required; Generate button produces a compliant password; submit creates user
|
||||||
|
- [ ] **[CC]** UsersPage create: duplicate email → inline error ("Email sudah digunakan.")
|
||||||
|
- [ ] **[CC]** UsersPage per-row "Reset password": success → inline "Tersimpan."; the target user can then log in with that password
|
||||||
|
- [ ] **[CC]** All API calls send `credentials: 'include'` (verify via devtools network tab)
|
||||||
|
|
||||||
|
### 1.11 mitra_app: UI + Auth
|
||||||
|
|
||||||
|
- [ ] **[M]** `firebase_auth` dep is gone from `pubspec.yaml`; app builds debug APK clean
|
||||||
|
- [ ] **[M]** First launch with no stored session → lands on `/login`
|
||||||
|
- [ ] **[M]** Phone OTP request → OTP screen; backend console shows `[OTP STUB] phone=… code=…`
|
||||||
|
- [ ] **[M]** Enter stub code → `/home`
|
||||||
|
- [ ] **[M]** Kill app, relaunch → bootstrap reads refresh from secure storage → `/home` (no login screen flash)
|
||||||
|
- [ ] **[M]** Inactive mitra → snackbar "Akun tidak aktif. Hubungi administrator."
|
||||||
|
- [ ] **[M]** Wrong OTP code → snackbar "Kode OTP salah." Fields cleared, focus returns to first digit
|
||||||
|
- [ ] **[M]** OTP expired (wait 5min) → "Kode OTP kedaluwarsa. Minta kode baru."
|
||||||
|
- [ ] **[M]** Resend within cooldown → "Tunggu sebentar..." message
|
||||||
|
- [ ] **[M]** Logout button → storage cleared; next launch hits `/login`
|
||||||
|
- [ ] **[M]** Long session: access token expires mid-chat → api_client auto-refreshes; user sees no disruption
|
||||||
|
- [ ] **[M]** Refresh token expires / revoked → `onUnauthenticated` → app returns to `/login` with current screen cleared
|
||||||
|
- [ ] **[M]** WebSocket handshake uses JWT from `AuthBridge` — verify by logging the token frame in dev
|
||||||
|
- [ ] **[M]** iOS: keychain persists the refresh token across app updates (reinstall DOES wipe it, but update keeps it)
|
||||||
|
- [ ] **[M]** Android: encrypted SharedPrefs persists across app updates
|
||||||
|
|
||||||
|
### 1.12 client_app: UI + Auth
|
||||||
|
|
||||||
|
- [ ] **[C]** `firebase_auth` dep is gone; app builds debug APK clean
|
||||||
|
- [ ] **[C]** First launch → onboarding → welcome screen
|
||||||
|
- [ ] **[C]** "Lanjut sebagai Tamu" → DisplayNameScreen → enter name → `/home` (anonymous customer created, display_name PATCHed)
|
||||||
|
- [ ] **[C]** Kill app, relaunch → bootstrap succeeds → `/home` (anonymous session restored)
|
||||||
|
- [ ] **[C]** "Daftar / Masuk" → phone field → OTP → home (customer created with phone)
|
||||||
|
- [ ] **[C]** From anonymous state, tap "Daftar / Masuk" → OTP upgrade → **same customer_id**, display_name preserved, phone added (verify via `/me`)
|
||||||
|
- [ ] **[C]** OTP error codes all render correct Indonesian messages (`CODE_MISMATCH`, `OTP_EXPIRED`, `IDENTITY_CONFLICT`, etc.)
|
||||||
|
- [ ] **[C]** Anonymity config `anonymity_enabled=false` → after anonymous sign-in, router sends to `/auth/force-register`
|
||||||
|
- [ ] **[C]** From ForceRegister, complete phone OTP → lands in `/home` with upgraded profile
|
||||||
|
- [ ] **[C]** Google/Apple buttons are **hidden** by default (ENABLE_SOCIAL_AUTH=false)
|
||||||
|
- [ ] **[C]** `--dart-define=ENABLE_SOCIAL_AUTH=true` → buttons appear; tapping without backend creds returns clear error (no crash)
|
||||||
|
- [ ] **[C]** After Google/Apple creds are live: sign-in with fresh account works; sign-in with existing-elsewhere account → `IDENTITY_CONFLICT` surfaced as "Akun ini sudah terhubung..."
|
||||||
|
- [ ] **[C]** Logout from home → returns to welcome (storage cleared)
|
||||||
|
- [ ] **[C]** Set display name screen: empty input disabled; success → home
|
||||||
|
- [ ] **[C]** Long session: access token expires → auto-refresh transparent
|
||||||
|
- [ ] **[C]** Refresh expires → unauthenticated → welcome screen, not a broken home
|
||||||
|
|
||||||
|
### 1.13 Cross-App / WebSocket / Regression After Auth Change
|
||||||
|
|
||||||
|
- [ ] **[BE][M][C]** WS auth in chat (`chat_notifier` client + `mitra_chat_notifier`) works with the new JWT
|
||||||
|
- [ ] **[BE][M][C]** WS auth in pairing (`pairing_notifier` client + `chat_request_notifier` mitra) works with the new JWT
|
||||||
|
- [ ] **[BE][M][C]** Full chat flow: pair → chat → extension → closure — using the new JWT end-to-end
|
||||||
|
- [ ] **[BE]** FCM device-token registration endpoint (`/api/shared/device-token`) still works; Authorization header is the new JWT
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 2 — Phase 3.3 Carry-Over: Topic Sensitivity
|
||||||
|
|
||||||
|
### 2.1 Database / Migration
|
||||||
|
|
||||||
|
- [ ] Migration runs cleanly on an existing dev DB (no errors, all `IF NOT EXISTS` / `ON CONFLICT` paths hit)
|
||||||
|
- [ ] `chat_sessions.topic_sensitivity` column exists with default `'regular'` and NOT NULL
|
||||||
|
- [ ] Existing sessions (created before the migration) have `topic_sensitivity = 'regular'` after migration
|
||||||
|
- [ ] `session_sensitivity_log` table exists with correct FKs (sessions, mitras)
|
||||||
|
- [ ] `idx_chat_sessions_topic_sensitivity` index created
|
||||||
|
- [ ] `idx_session_sensitivity_log_session` index created
|
||||||
|
- [ ] `app_config` has `sensitive_flip_confirmation_enabled = true` by default
|
||||||
|
- [ ] `app_config` has `sensitive_flag_one_way_latch = false` by default
|
||||||
|
|
||||||
|
### 2.2 Customer Flow (client_app)
|
||||||
|
|
||||||
|
**Topic selection bottom sheet**
|
||||||
|
- [ ] Tap "Mulai Curhat" → topic selection bottom sheet appears
|
||||||
|
- [ ] Sheet **cannot** be dismissed by tapping outside
|
||||||
|
- [ ] Sheet **cannot** be dismissed by swiping down
|
||||||
|
- [ ] System back button cancels entire "Mulai Curhat" flow (does NOT open pricing)
|
||||||
|
- [ ] Copy matches PRD (title, body, sub-question, helper line)
|
||||||
|
- [ ] "Topik umum" button styling is primary
|
||||||
|
- [ ] "Topik sensitif" button styling is secondary but equal weight (not de-emphasized)
|
||||||
|
|
||||||
|
**Request submission**
|
||||||
|
- [ ] Tap "Topik umum" → pricing sheet opens with topic pre-selected as regular
|
||||||
|
- [ ] Tap "Topik sensitif" → pricing sheet opens with topic pre-selected as sensitive
|
||||||
|
- [ ] After pricing confirm → `POST /api/client/chat/request` body includes `topic_sensitivity: regular|sensitive`
|
||||||
|
- [ ] Backend rejects request with missing `topic_sensitivity` (400 BAD_REQUEST)
|
||||||
|
- [ ] Backend rejects request with invalid `topic_sensitivity` value (e.g., `"other"`)
|
||||||
|
- [ ] Created `chat_sessions` row has correct `topic_sensitivity` value
|
||||||
|
|
||||||
|
**Customer UI after request**
|
||||||
|
- [ ] Chat screen stays pink (no yellow), regardless of flag
|
||||||
|
- [ ] Customer history screen: no badge on any row regardless of flag
|
||||||
|
- [ ] Customer transcript screen: stays pink
|
||||||
|
- [ ] Customer receives `session_topic_updated` WS message after mitra flip → **no UI change**, no error, no crash
|
||||||
|
|
||||||
|
### 2.3 Mitra Flow — Incoming Request (mitra_app)
|
||||||
|
|
||||||
|
- [ ] Regular request: overlay has no badge, no yellow accent
|
||||||
|
- [ ] Sensitive request: overlay shows "Topik sensitif" badge + yellow accent
|
||||||
|
- [ ] Overlay payload from WS includes `topic_sensitivity`
|
||||||
|
- [ ] Overlay payload from FCM fallback includes `topic_sensitivity` (or app fetches on open)
|
||||||
|
- [ ] Overlay payload from `getPendingRequestsForMitra` (app-resume path) includes `topic_sensitivity`
|
||||||
|
- [ ] Mitra accepts sensitive request → lands in active chat screen with correct flag
|
||||||
|
|
||||||
|
### 2.4 Mitra Flow — Active Chat Screen
|
||||||
|
|
||||||
|
- [ ] Sensitive active session: yellow doodle background
|
||||||
|
- [ ] Regular active session: pink doodle (unchanged behavior)
|
||||||
|
- [ ] Header banner shows "Topik sensitif" label only when sensitive
|
||||||
|
- [ ] App-bar toggle icon visible (flag / flag_outlined depending on state)
|
||||||
|
|
||||||
|
**Flip toggle — confirmation enabled (default)**
|
||||||
|
- [ ] Tap toggle regular → sensitive → dialog appears: "Tandai sesi ini sebagai sensitif?"
|
||||||
|
- [ ] "Batal" cancels, no state change, no audit log entry, no WS broadcast
|
||||||
|
- [ ] "Tandai" flips, background turns yellow instantly, log entry created
|
||||||
|
- [ ] Tap toggle sensitive → regular → dialog: "Tandai sesi ini sebagai topik umum?"
|
||||||
|
|
||||||
|
**Flip toggle — confirmation disabled (via CC config)**
|
||||||
|
- [ ] Toggle `sensitive_flip_confirmation_enabled` to `false` in CC
|
||||||
|
- [ ] Flip happens immediately, toast "Sesi ditandai sensitif" / "Sesi ditandai topik umum"
|
||||||
|
- [ ] No dialog appears
|
||||||
|
|
||||||
|
**One-way latch — disabled (default)**
|
||||||
|
- [ ] Can flip regular → sensitive → regular → sensitive freely
|
||||||
|
|
||||||
|
**One-way latch — enabled (via CC config)**
|
||||||
|
- [ ] Toggle `sensitive_flag_one_way_latch` to `true` in CC
|
||||||
|
- [ ] Session that was regular: can flip to sensitive; toggle then disabled
|
||||||
|
- [ ] Session that was already sensitive at latch-enable time: toggle disabled with tooltip
|
||||||
|
- [ ] Attempt to flip sensitive → regular with latch on: API returns 409 `SENSITIVITY_LATCHED`
|
||||||
|
- [ ] Error dialog shown: "Sesi sudah ditandai sensitif dan tidak bisa diubah kembali."
|
||||||
|
|
||||||
|
**Audit trail**
|
||||||
|
- [ ] Every successful flip creates a `session_sensitivity_log` row with correct `from_value`, `to_value`, `changed_by_mitra_id`
|
||||||
|
- [ ] No-op flip (e.g., tap confirm but value didn't actually change) does NOT create a log row
|
||||||
|
- [ ] Log entries ordered correctly (ascending `created_at`)
|
||||||
|
|
||||||
|
### 2.5 Mitra Flow — Extension
|
||||||
|
|
||||||
|
- [ ] Customer requests extension on regular session → mitra extension card has no badge
|
||||||
|
- [ ] Customer requests extension on sensitive session → mitra extension card shows "Topik sensitif" badge + yellow accent
|
||||||
|
- [ ] Mitra flipped session mid-chat regular → sensitive, then customer requests extension → extension card reflects **current** sensitive flag
|
||||||
|
- [ ] Extension accepted → flag carries over unchanged to extended session
|
||||||
|
|
||||||
|
### 2.6 Mitra Flow — History & Transcript
|
||||||
|
|
||||||
|
- [ ] Mitra history list row shows "Topik sensitif" badge for sensitive sessions
|
||||||
|
- [ ] Mitra history list row has no badge for regular sessions
|
||||||
|
- [ ] Mitra transcript view: sensitive session → yellow doodle background
|
||||||
|
- [ ] Mitra transcript view: regular session → pink doodle
|
||||||
|
- [ ] Customer-side history and transcript: always pink, no badge
|
||||||
|
|
||||||
|
### 2.7 Mitra Flow — Edge Cases
|
||||||
|
|
||||||
|
- [ ] Mitra tries to flip flag on a session they don't own → 403 FORBIDDEN
|
||||||
|
- [ ] Mitra tries to flip flag on a `CLOSING` session → 409 SESSION_NOT_ACTIVE
|
||||||
|
- [ ] Mitra tries to flip flag on a `COMPLETED` session → 409 SESSION_NOT_ACTIVE
|
||||||
|
- [ ] Mitra tries to flip flag on an `EXPIRED` session → 409 SESSION_NOT_ACTIVE
|
||||||
|
- [ ] Invalid `topic_sensitivity` value sent to PATCH endpoint → 400 BAD_REQUEST
|
||||||
|
- [ ] Customer tries to call PATCH endpoint → 403 FORBIDDEN (only mitra allowed)
|
||||||
|
|
||||||
|
### 2.8 Control Center — Settings
|
||||||
|
|
||||||
|
- [ ] Settings page has new "Sensitivitas Topik" section
|
||||||
|
- [ ] `sensitive_flip_confirmation_enabled` checkbox reflects current backend value
|
||||||
|
- [ ] `sensitive_flag_one_way_latch` checkbox reflects current backend value
|
||||||
|
- [ ] PATCH `/internal/config/sensitivity` persists changes
|
||||||
|
- [ ] Changes take effect immediately on next mitra flip (no app restart needed on mitra side, if mitra fetches config dynamically)
|
||||||
|
|
||||||
|
### 2.9 Control Center — Sessions Page
|
||||||
|
|
||||||
|
- [ ] Sessions list has new filter dropdown (All / Umum / Sensitif)
|
||||||
|
- [ ] Filter "Sensitif" returns only sessions with `topic_sensitivity = 'sensitive'`
|
||||||
|
- [ ] Filter "Umum" returns only `regular`
|
||||||
|
- [ ] Filter "All" returns everything (backward-compatible)
|
||||||
|
- [ ] Filter works combined with existing status filter
|
||||||
|
- [ ] Session list has new "Topik" column showing badge (green "Umum" / yellow "Sensitif")
|
||||||
|
|
||||||
|
### 2.10 Control Center — Session Detail
|
||||||
|
|
||||||
|
- [ ] Session detail page shows current `topic_sensitivity`
|
||||||
|
- [ ] Session detail shows sensitivity audit trail timeline: "Mitra {name} menandai topik sebagai {from→to} pada {timestamp}"
|
||||||
|
- [ ] Timeline ordered ascending
|
||||||
|
- [ ] Sessions with no flips show empty timeline (no error)
|
||||||
|
|
||||||
|
### 2.11 Control Center — Dashboard
|
||||||
|
|
||||||
|
- [ ] Dashboard shows "Sesi Sensitif" card with total count + 30-day % breakdown
|
||||||
|
- [ ] Percentage math correct (sensitive / total × 100, rounded to 1 decimal)
|
||||||
|
- [ ] Edge case: 0 sessions in last 30 days → shows `0%` not `NaN`
|
||||||
|
|
||||||
|
### 2.12 Control Center — Mitra Activity
|
||||||
|
|
||||||
|
- [ ] Summary table has new columns: Sensitive Total, Sensitive Accepted, Sensitive Rate (%)
|
||||||
|
- [ ] Mitra with 0 sensitive requests shows `—` (not `0%`)
|
||||||
|
- [ ] Sensitive rate computed correctly (sensitive_accepted / sensitive_total × 100)
|
||||||
|
- [ ] Detail log table: optional new "Topik" column with badge
|
||||||
|
- [ ] Date range filter still works with new columns
|
||||||
|
|
||||||
|
### 2.13 Control Center — Integration Regression
|
||||||
|
|
||||||
|
- [ ] Existing settings (anonymity, free-trial, extension-timeout, early-end, mitra-ping, price-tiers) still work under the new auth
|
||||||
|
- [ ] Existing sessions filter (by status) still works
|
||||||
|
- [ ] Existing dashboard cards still render
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 3 — Phase 3.2 Carry-Over
|
||||||
|
|
||||||
|
### 3.1 Chat Request Overlay
|
||||||
|
|
||||||
|
- [ ] **Multiple concurrent chat requests** — verify queue behavior (one shown at a time, next appears when current resolved)
|
||||||
|
- [ ] Stale request: "cancelled by customer" message shown + requires acknowledge (no auto-dismiss)
|
||||||
|
- [ ] Stale request: "accepted by other bestie" message shown + requires acknowledge
|
||||||
|
- [ ] Stale request: "expired" message shown + requires acknowledge
|
||||||
|
- [ ] Swipe-to-dismiss (ignore) does NOT send reject to backend
|
||||||
|
- [ ] Ignored request eventually logs as `ignored` in `chat_request_notifications` after 60s timeout
|
||||||
|
- [ ] Request `missed` (another mitra accepted first) logs correctly
|
||||||
|
- [ ] `active_session_count` captured correctly at notification creation
|
||||||
|
|
||||||
|
### 3.2 End-to-End Flows
|
||||||
|
|
||||||
|
- [ ] Full chat flow: pair → chat → extension → closure (customer + mitra)
|
||||||
|
- [ ] Goodbye flow: session expires → closing → both submit goodbye → completed
|
||||||
|
- [ ] Extension accepted mid-flow → session resumes, timer extends, no grace timer lingering
|
||||||
|
- [ ] Extension rejected → session moves to closing, both see closure UI
|
||||||
|
- [ ] Extension timeout (no mitra response) → closing
|
||||||
|
|
||||||
|
### 3.3 iOS Coverage
|
||||||
|
|
||||||
|
- [ ] OTP login on iOS (customer) — **update:** flow now uses stub Fazpass OTP from backend console, no native reCAPTCHA
|
||||||
|
- [ ] OTP login on iOS (mitra) — same as above
|
||||||
|
- [ ] Push notifications on iOS (customer + mitra)
|
||||||
|
- [ ] FCM token registration on iOS
|
||||||
|
- [ ] Chat screen rendering on iOS
|
||||||
|
- [ ] Back button behavior on iOS (deep-link pop fallback)
|
||||||
|
- [ ] Overlay on iOS (iOS setup started but incomplete per prior memory)
|
||||||
|
- [ ] Splash screen on iOS
|
||||||
|
- [ ] Onboarding carousel on iOS
|
||||||
|
- [ ] Keyboard handling on iOS (chat input, goodbye form)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 4 — Phase 3 / 3.1 Carry-Over
|
||||||
|
|
||||||
|
### 4.1 Session Lifecycle
|
||||||
|
|
||||||
|
- [ ] Server restart mid-session: session timer is restored from DB (`restoreActiveTimers`)
|
||||||
|
- [ ] Stale active sessions auto-complete on restart
|
||||||
|
- [ ] Closing sessions with stale grace timers auto-complete on restart
|
||||||
|
- [ ] Session expired from customer side (5-min countdown display)
|
||||||
|
- [ ] Abandoned session during closure grace period → auto-completes
|
||||||
|
- [ ] **Known limitation**: multi-instance backend sessions not supported until Valkey keyspace notifications implemented (out of scope — see `project_session_timer_scaling` in memory)
|
||||||
|
|
||||||
|
### 4.2 Chat Mechanics
|
||||||
|
|
||||||
|
- [ ] Message status transitions (sent → delivered → read) work correctly
|
||||||
|
- [ ] Typing indicator shows/hides correctly on both sides
|
||||||
|
- [ ] Messages received while backgrounded are marked `delivered` on foreground resume
|
||||||
|
- [ ] Messages viewed are marked `read` and the read receipt propagates back to sender
|
||||||
|
- [ ] Unread badge on home screen updates correctly (client_app + mitra_app)
|
||||||
|
|
||||||
|
### 4.3 Navigation / UI
|
||||||
|
|
||||||
|
- [ ] All navigation uses `GoRouter.context.push/go` (no leftover `Navigator.pushNamed`)
|
||||||
|
- [ ] Deep-linked screens work with `canPop` fallback + `PopScope`
|
||||||
|
- [ ] `notification_service` uses `go` (not `push`) for terminal states
|
||||||
|
- [ ] Splash screen hides auth loading flash on both apps
|
||||||
|
- [ ] Goodbye views use `SingleChildScrollView` (no keyboard overflow)
|
||||||
|
|
||||||
|
### 4.4 Control Center Settings
|
||||||
|
|
||||||
|
- [ ] Free trial config: toggle + duration edit
|
||||||
|
- [ ] Extension timeout: edit seconds
|
||||||
|
- [ ] Early end: toggle mitra / customer independently
|
||||||
|
- [ ] Mitra ping: toggle require + interval
|
||||||
|
- [ ] Price tiers: add / edit / remove tiers and verify client_app pricing sheet reflects changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 5 — Cross-Cutting / Pre-Release
|
||||||
|
|
||||||
|
### 5.1 Regression Checks (after Phase 3.4 merge)
|
||||||
|
|
||||||
|
- [ ] Existing customer auth flow still works (welcome → OTP → register → home) — under new JWT
|
||||||
|
- [ ] Existing mitra auth flow still works — under new JWT
|
||||||
|
- [ ] Existing control center login still works (admin with rotated password) — under new cookie-based refresh
|
||||||
|
- [ ] Pairing flow (mulai curhat → matched) still works end-to-end under new auth
|
||||||
|
- [ ] All existing WS messages still processed (no regressions from `session_topic_updated` handler or JWT handshake)
|
||||||
|
|
||||||
|
### 5.2 Platform Coverage
|
||||||
|
|
||||||
|
- [ ] Android: client_app on emulator (Medium_Phone_API_36.1)
|
||||||
|
- [ ] Android: mitra_app on physical device (SM-A530F, 52002a5db8e0c46b)
|
||||||
|
- [ ] iOS: client_app (Mac + simulator / physical)
|
||||||
|
- [ ] iOS: mitra_app (Mac + simulator / physical)
|
||||||
|
- [ ] Control center: Chrome latest
|
||||||
|
- [ ] Control center: Firefox / Safari (if required)
|
||||||
|
|
||||||
|
### 5.3 Load / Concurrency (sanity)
|
||||||
|
|
||||||
|
- [ ] 2 concurrent customers requesting chat at the same time — both find a mitra (or one waits)
|
||||||
|
- [ ] 1 customer, 5 mitras online — blast notification reaches all 5
|
||||||
|
- [ ] Mitra accepts after another mitra already accepted → receives `missed` with `accepted_by_other`
|
||||||
|
- [ ] Backend restart with active sessions → timers restored, no data loss
|
||||||
|
- [ ] 3 concurrent CC admins logging in → each gets its own `auth_sessions` row; logout on one doesn't affect others
|
||||||
|
|
||||||
|
### 5.4 Config Flag Interactions
|
||||||
|
|
||||||
|
- [ ] `sensitive_flag_one_way_latch = true` + existing sensitive session → toggle disabled
|
||||||
|
- [ ] `sensitive_flip_confirmation_enabled = false` + rapid flips → no race, all logged in order
|
||||||
|
- [ ] Anonymity toggle flip (enabled → disabled) while a user has an active anonymous session → next bootstrap routes to ForceRegister
|
||||||
|
- [ ] Anonymity toggle flip (disabled → enabled) → no-op for already-identified users; new users can go anonymous again
|
||||||
|
|
||||||
|
### 5.5 Security / Negative
|
||||||
|
|
||||||
|
- [ ] JWT secret leak simulation: a manually-signed token with the wrong secret → 401 across all surfaces
|
||||||
|
- [ ] Token from a deleted `auth_sessions` row still verifies for up to 1h (documented window); revoke immediately with future Valkey `revoked_sessions` set
|
||||||
|
- [ ] Refresh token used twice in parallel → only one succeeds (rotation-on-use); second → `REFRESH_INVALID`
|
||||||
|
- [ ] Refresh token stolen + used from a different IP → still works (documented design — rotation + bcrypt is the defense); `device_info` logs the new IP for audit
|
||||||
|
- [ ] Control center cookie `SameSite` / `Secure` / `HttpOnly` flags correct in prod (verify via devtools in staging)
|
||||||
|
|
||||||
|
### 5.6 Known Blockers / Deferred
|
||||||
|
|
||||||
|
Not tests — tracked so they don't get forgotten:
|
||||||
|
|
||||||
|
- [ ] **Valkey keyspace notifications** — required for multi-instance session timers + real token revocation
|
||||||
|
- [ ] **Mitra QC auto-flag** — auto-flagging high-rejection mitras on CC dashboard
|
||||||
|
- [ ] **Merge-on-link** for social login (currently reject-on-existing with `IDENTITY_CONFLICT`)
|
||||||
|
- [ ] **Drop `firebase_uid` columns** — cleanup migration once no code path references them
|
||||||
|
- [ ] **Real Fazpass integration** — replace stub in `otp.service.js` when API docs + creds arrive
|
||||||
|
- [ ] **Google + Apple OAuth creds** — provision then flip `ENABLE_SOCIAL_AUTH=true` and run §1.4 + client_app social blocks
|
||||||
|
- [ ] **Apple Developer setup** — Services ID + `.p8` + Team ID + Key ID before iOS Apple-sign-in E2E
|
||||||
|
- [ ] **JWT secret rotation procedure** — documented but not implemented (dual-secret window plan in `phase3.4-plan.md`)
|
||||||
Reference in New Issue
Block a user