Fix auth: auto-create customer, display name flow, OTP auto-verify
- Backend: getOrCreateCustomer with phone fallback for re-login - Backend: PATCH /api/client/auth/profile for display name update - Client app: AuthNeedsDisplayNameData state + SetDisplayNameScreen - Client app: ApiClient.patch method - Both apps: handle verificationCompleted for auto-verify (test numbers) - Both apps: skip credential sign-in if already auto-verified - Remove debug prints from mitra auth + OTP screens - Fix ChatRequestNotifier.startListening skips when accepting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,18 @@
|
|||||||
import { authenticate } from '../../plugins/auth.js'
|
import { authenticate } from '../../plugins/auth.js'
|
||||||
import { getCustomerByFirebaseUid } from '../../services/customer.service.js'
|
import { getOrCreateCustomer, getCustomerByFirebaseUid, updateCustomerDisplayName } from '../../services/customer.service.js'
|
||||||
|
|
||||||
export const clientAuthRoutes = async (app) => {
|
export const clientAuthRoutes = async (app) => {
|
||||||
app.post('/verify', { preHandler: authenticate }, async (request, reply) => {
|
app.post('/verify', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const { uid, phone_number, name } = request.firebaseUser
|
||||||
|
const customer = await getOrCreateCustomer({
|
||||||
|
firebase_uid: uid,
|
||||||
|
phone: phone_number || null,
|
||||||
|
display_name: name || null,
|
||||||
|
})
|
||||||
|
return reply.send({ success: true, data: customer })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch('/profile', { preHandler: authenticate }, async (request, reply) => {
|
||||||
const customer = await getCustomerByFirebaseUid(request.firebaseUser.uid)
|
const customer = await getCustomerByFirebaseUid(request.firebaseUser.uid)
|
||||||
if (!customer) {
|
if (!customer) {
|
||||||
return reply.code(404).send({
|
return reply.code(404).send({
|
||||||
@@ -10,6 +20,14 @@ export const clientAuthRoutes = async (app) => {
|
|||||||
error: { code: 'NOT_FOUND', message: 'Customer account not found' },
|
error: { code: 'NOT_FOUND', message: 'Customer account not found' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return reply.send({ success: true, data: customer })
|
const { display_name } = request.body || {}
|
||||||
|
if (!display_name || typeof display_name !== 'string' || display_name.trim().length === 0) {
|
||||||
|
return reply.code(422).send({
|
||||||
|
success: false,
|
||||||
|
error: { code: 'VALIDATION_ERROR', message: 'display_name is required' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const updated = await updateCustomerDisplayName(customer.id, display_name.trim())
|
||||||
|
return reply.send({ success: true, data: updated })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,3 +48,40 @@ export const getCustomerByFirebaseUid = async (firebase_uid) => {
|
|||||||
`
|
`
|
||||||
return customer
|
return customer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getOrCreateCustomer = async ({ firebase_uid, phone, display_name }) => {
|
||||||
|
// Return existing customer if already linked to this Firebase UID
|
||||||
|
const existing = await getCustomerByFirebaseUid(firebase_uid)
|
||||||
|
if (existing) return existing
|
||||||
|
|
||||||
|
// Check if a customer with this phone already exists (re-login with new Firebase UID)
|
||||||
|
if (phone) {
|
||||||
|
const [byPhone] = await sql`
|
||||||
|
SELECT id, display_name, is_anonymous, phone, created_at
|
||||||
|
FROM customers WHERE phone = ${phone}
|
||||||
|
`
|
||||||
|
if (byPhone) {
|
||||||
|
// Link the new Firebase UID to the existing phone-based customer
|
||||||
|
await sql`UPDATE customers SET firebase_uid = ${firebase_uid} WHERE id = ${byPhone.id}`
|
||||||
|
return { ...byPhone, firebase_uid }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-create a registered (non-anonymous) customer for phone/social login
|
||||||
|
// display_name is null — user must set it on first login
|
||||||
|
const [customer] = await sql`
|
||||||
|
INSERT INTO customers (firebase_uid, phone, display_name, is_anonymous)
|
||||||
|
VALUES (${firebase_uid}, ${phone || null}, ${display_name || null}, false)
|
||||||
|
RETURNING id, display_name, is_anonymous, phone, created_at
|
||||||
|
`
|
||||||
|
return customer
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateCustomerDisplayName = async (customerId, displayName) => {
|
||||||
|
const [customer] = await sql`
|
||||||
|
UPDATE customers SET display_name = ${displayName}
|
||||||
|
WHERE id = ${customerId}
|
||||||
|
RETURNING id, display_name, is_anonymous, phone, created_at
|
||||||
|
`
|
||||||
|
return customer
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const notifyMitra = async (mitraId, data) => {
|
|||||||
// Send notification to customer via WebSocket, fall back to FCM if offline
|
// Send notification to customer via WebSocket, fall back to FCM if offline
|
||||||
const notifyCustomer = async (customerId, data) => {
|
const notifyCustomer = async (customerId, data) => {
|
||||||
const sent = sendToUser(UserType.CUSTOMER, customerId, data)
|
const sent = sendToUser(UserType.CUSTOMER, customerId, data)
|
||||||
|
console.log(`[notifyCustomer] customerId=${customerId} type=${data.type} ws_sent=${sent}`)
|
||||||
if (!sent) {
|
if (!sent) {
|
||||||
if (data.type === WsMessage.PAIRED) {
|
if (data.type === WsMessage.PAIRED) {
|
||||||
await sendPushNotification(UserType.CUSTOMER, customerId, {
|
await sendPushNotification(UserType.CUSTOMER, customerId, {
|
||||||
|
|||||||
@@ -32,4 +32,9 @@ class ApiClient {
|
|||||||
final response = await _dio.get(path, queryParameters: queryParameters);
|
final response = await _dio.get(path, queryParameters: queryParameters);
|
||||||
return response.data as Map<String, dynamic>;
|
return response.data as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> patch(String path, {Map<String, dynamic>? data}) async {
|
||||||
|
final response = await _dio.patch(path, data: data);
|
||||||
|
return response.data as Map<String, dynamic>;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ class AuthForceRegisterData extends AuthData {
|
|||||||
const AuthForceRegisterData({required this.customerId, required this.displayName});
|
const AuthForceRegisterData({required this.customerId, required this.displayName});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class AuthNeedsDisplayNameData extends AuthData {
|
||||||
|
final Map<String, dynamic> profile;
|
||||||
|
const AuthNeedsDisplayNameData(this.profile);
|
||||||
|
}
|
||||||
|
|
||||||
@Riverpod(keepAlive: true)
|
@Riverpod(keepAlive: true)
|
||||||
class Auth extends _$Auth {
|
class Auth extends _$Auth {
|
||||||
FirebaseAuth get _auth => FirebaseAuth.instance;
|
FirebaseAuth get _auth => FirebaseAuth.instance;
|
||||||
@@ -132,7 +137,11 @@ class Auth extends _$Auth {
|
|||||||
final completer = Completer<void>();
|
final completer = Completer<void>();
|
||||||
await _auth.verifyPhoneNumber(
|
await _auth.verifyPhoneNumber(
|
||||||
phoneNumber: phone,
|
phoneNumber: phone,
|
||||||
verificationCompleted: (_) {
|
verificationCompleted: (credential) async {
|
||||||
|
try {
|
||||||
|
await _auth.signInWithCredential(credential);
|
||||||
|
state = AsyncData(await _verifyAndReturn());
|
||||||
|
} catch (_) {}
|
||||||
if (!completer.isCompleted) completer.complete();
|
if (!completer.isCompleted) completer.complete();
|
||||||
},
|
},
|
||||||
verificationFailed: (e) {
|
verificationFailed: (e) {
|
||||||
@@ -153,11 +162,14 @@ class Auth extends _$Auth {
|
|||||||
Future<void> verifyOtp(String verificationId, String smsCode) async {
|
Future<void> verifyOtp(String verificationId, String smsCode) async {
|
||||||
state = const AsyncLoading();
|
state = const AsyncLoading();
|
||||||
try {
|
try {
|
||||||
|
// If already signed in via auto-verification, skip credential sign-in
|
||||||
|
if (_auth.currentUser == null || _auth.currentUser!.isAnonymous) {
|
||||||
final credential = PhoneAuthProvider.credential(
|
final credential = PhoneAuthProvider.credential(
|
||||||
verificationId: verificationId,
|
verificationId: verificationId,
|
||||||
smsCode: smsCode,
|
smsCode: smsCode,
|
||||||
);
|
);
|
||||||
await _auth.signInWithCredential(credential);
|
await _auth.signInWithCredential(credential);
|
||||||
|
}
|
||||||
state = AsyncData(await _verifyAndReturn());
|
state = AsyncData(await _verifyAndReturn());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state = AsyncError('Invalid OTP. Please try again.', StackTrace.current);
|
state = AsyncError('Invalid OTP. Please try again.', StackTrace.current);
|
||||||
@@ -191,8 +203,24 @@ class Auth extends _$Auth {
|
|||||||
state = const AsyncData(AuthInitialData());
|
state = const AsyncData(AuthInitialData());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setDisplayName(String displayName) async {
|
||||||
|
state = const AsyncLoading();
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.patch('/api/client/auth/profile', data: {
|
||||||
|
'display_name': displayName,
|
||||||
|
});
|
||||||
|
state = AsyncData(AuthAuthenticatedData(response['data'] as Map<String, dynamic>));
|
||||||
|
} catch (e) {
|
||||||
|
state = AsyncError('Gagal menyimpan nama. Coba lagi.', StackTrace.current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<AuthData> _verifyAndReturn() async {
|
Future<AuthData> _verifyAndReturn() async {
|
||||||
final response = await _apiClient.post('/api/client/auth/verify');
|
final response = await _apiClient.post('/api/client/auth/verify');
|
||||||
return AuthAuthenticatedData(response['data'] as Map<String, dynamic>);
|
final profile = response['data'] as Map<String, dynamic>;
|
||||||
|
if (profile['display_name'] == null || (profile['display_name'] as String).isEmpty) {
|
||||||
|
return AuthNeedsDisplayNameData(profile);
|
||||||
|
}
|
||||||
|
return AuthAuthenticatedData(profile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../../core/auth/auth_notifier.dart';
|
||||||
|
|
||||||
|
class SetDisplayNameScreen extends ConsumerStatefulWidget {
|
||||||
|
const SetDisplayNameScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<SetDisplayNameScreen> createState() => _SetDisplayNameScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SetDisplayNameScreenState extends ConsumerState<SetDisplayNameScreen> {
|
||||||
|
final _controller = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submit() {
|
||||||
|
final name = _controller.text.trim();
|
||||||
|
if (name.isEmpty) return;
|
||||||
|
ref.read(authProvider.notifier).setDisplayName(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final authState = ref.watch(authProvider);
|
||||||
|
final isLoading = authState is AsyncLoading;
|
||||||
|
|
||||||
|
ref.listen(authProvider, (prev, next) {
|
||||||
|
if (next is AsyncError) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Siapa namamu?')),
|
||||||
|
body: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Text('Pilih nama yang ingin kamu gunakan. Nama ini akan terlihat oleh Bestie kamu.'),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
TextField(
|
||||||
|
controller: _controller,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Nama panggilan',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: isLoading ? null : _submit,
|
||||||
|
child: isLoading
|
||||||
|
? const CircularProgressIndicator()
|
||||||
|
: const Text('Lanjut'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import 'features/auth/screens/display_name_screen.dart';
|
|||||||
import 'features/auth/screens/register_screen.dart';
|
import 'features/auth/screens/register_screen.dart';
|
||||||
import 'features/auth/screens/otp_screen.dart';
|
import 'features/auth/screens/otp_screen.dart';
|
||||||
import 'features/auth/screens/force_register_screen.dart';
|
import 'features/auth/screens/force_register_screen.dart';
|
||||||
|
import 'features/auth/screens/set_display_name_screen.dart';
|
||||||
import 'features/splash/splash_screen.dart';
|
import 'features/splash/splash_screen.dart';
|
||||||
import 'features/home/home_screen.dart';
|
import 'features/home/home_screen.dart';
|
||||||
import 'features/chat/screens/searching_screen.dart';
|
import 'features/chat/screens/searching_screen.dart';
|
||||||
@@ -55,6 +56,7 @@ GoRouter buildRouter(Ref ref) {
|
|||||||
if (data is AuthAuthenticatedData || data is AuthAnonymousData) {
|
if (data is AuthAuthenticatedData || data is AuthAnonymousData) {
|
||||||
return (isSplash || isAuthRoute) ? '/home' : null;
|
return (isSplash || isAuthRoute) ? '/home' : null;
|
||||||
}
|
}
|
||||||
|
if (data is AuthNeedsDisplayNameData) return '/auth/set-name';
|
||||||
if (data is AuthForceRegisterData) return '/auth/force-register';
|
if (data is AuthForceRegisterData) return '/auth/force-register';
|
||||||
if (!isAuthRoute && !isSplash) return '/welcome';
|
if (!isAuthRoute && !isSplash) return '/welcome';
|
||||||
if (isSplash) return '/welcome';
|
if (isSplash) return '/welcome';
|
||||||
@@ -66,6 +68,7 @@ GoRouter buildRouter(Ref ref) {
|
|||||||
GoRoute(path: '/auth/display-name', builder: (_, __) => const DisplayNameScreen()),
|
GoRoute(path: '/auth/display-name', builder: (_, __) => const DisplayNameScreen()),
|
||||||
GoRoute(path: '/auth/register', builder: (_, __) => const RegisterScreen()),
|
GoRoute(path: '/auth/register', builder: (_, __) => const RegisterScreen()),
|
||||||
GoRoute(path: '/auth/otp', builder: (context, state) => OtpScreen(phone: state.extra as String)),
|
GoRoute(path: '/auth/otp', builder: (context, state) => OtpScreen(phone: state.extra as String)),
|
||||||
|
GoRoute(path: '/auth/set-name', builder: (_, __) => const SetDisplayNameScreen()),
|
||||||
GoRoute(path: '/auth/force-register', builder: (_, __) => const ForceRegisterScreen()),
|
GoRoute(path: '/auth/force-register', builder: (_, __) => const ForceRegisterScreen()),
|
||||||
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
|
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
|
||||||
GoRoute(path: '/chat/searching', builder: (_, __) => const SearchingScreen()),
|
GoRoute(path: '/chat/searching', builder: (_, __) => const SearchingScreen()),
|
||||||
|
|||||||
@@ -54,7 +54,11 @@ class MitraAuth extends _$MitraAuth {
|
|||||||
final completer = Completer<void>();
|
final completer = Completer<void>();
|
||||||
await _auth.verifyPhoneNumber(
|
await _auth.verifyPhoneNumber(
|
||||||
phoneNumber: phone,
|
phoneNumber: phone,
|
||||||
verificationCompleted: (_) {
|
verificationCompleted: (credential) async {
|
||||||
|
try {
|
||||||
|
await _auth.signInWithCredential(credential);
|
||||||
|
state = AsyncData(await _verifyAndReturn());
|
||||||
|
} catch (_) {}
|
||||||
if (!completer.isCompleted) completer.complete();
|
if (!completer.isCompleted) completer.complete();
|
||||||
},
|
},
|
||||||
verificationFailed: (e) {
|
verificationFailed: (e) {
|
||||||
@@ -78,7 +82,8 @@ class MitraAuth extends _$MitraAuth {
|
|||||||
try {
|
try {
|
||||||
if (kIsWeb && _webConfirmationResult != null) {
|
if (kIsWeb && _webConfirmationResult != null) {
|
||||||
await _webConfirmationResult!.confirm(smsCode);
|
await _webConfirmationResult!.confirm(smsCode);
|
||||||
} else {
|
} else if (_auth.currentUser == null) {
|
||||||
|
// Only sign in if not already signed in via auto-verification
|
||||||
final credential = PhoneAuthProvider.credential(
|
final credential = PhoneAuthProvider.credential(
|
||||||
verificationId: verificationId,
|
verificationId: verificationId,
|
||||||
smsCode: smsCode,
|
smsCode: smsCode,
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ class ChatRequest extends _$ChatRequest {
|
|||||||
ChatRequestData build() => const ChatRequestIdleData();
|
ChatRequestData build() => const ChatRequestIdleData();
|
||||||
|
|
||||||
Future<void> startListening() async {
|
Future<void> startListening() async {
|
||||||
|
// Don't reset state if actively accepting/accepted — would lose navigation
|
||||||
|
if (state is ChatRequestAcceptingData || state is ChatRequestAcceptedData) return;
|
||||||
_closeWebSocket();
|
_closeWebSocket();
|
||||||
state = const ChatRequestListeningData();
|
state = const ChatRequestListeningData();
|
||||||
await _connectWebSocket();
|
await _connectWebSocket();
|
||||||
|
|||||||
Reference in New Issue
Block a user