feat(auth): transition from email to phone-based OTP authentication

- Replace Email+Password login with Phone+OTP flow
- Remove RegisterCubit and registration screens (email verification)
- Remove ResetPasswordCubit and reset password screens
- Add phone normalization and international dial code support
- Update LoginCubit with sendCode/resend cooldown logic
- Add new widgets: phone prefix selector, confirm sheet
- Update all auth API endpoints: /otp/send, /phone-session
- Update form inputs: Email -> Phone with E.164 validation
- Update tests for new auth flow
This commit is contained in:
qzl
2026-03-19 18:42:05 +08:00
parent 636b37ee5a
commit 0661016827
29 changed files with 615 additions and 2030 deletions
+4 -48
View File
@@ -9,34 +9,13 @@ class AuthApi {
AuthApi(this._client);
Future<VerificationCreateResponse> createVerification(
SignupStartRequest request,
) async {
final response = await _client.post(
'$_prefix/verifications',
data: request.toJson(),
);
return VerificationCreateResponse.fromJson(response.data);
Future<void> sendOtp(OtpSendRequest request) async {
await _client.post('$_prefix/otp/send', data: request.toJson());
}
Future<AuthResponse> verifyVerification(SignupVerifyRequest request) async {
Future<AuthResponse> createPhoneSession(LoginRequest request) async {
final response = await _client.post(
'$_prefix/verify',
data: {'type': 'signup', ...request.toJson()},
);
return AuthResponse.fromJson(response.data);
}
Future<void> resendVerification(SignupResendRequest request) async {
await _client.post(
'$_prefix/resend',
data: {'type': 'signup', ...request.toJson()},
);
}
Future<AuthResponse> createSession(LoginRequest request) async {
final response = await _client.post(
'$_prefix/sessions',
'$_prefix/phone-session',
data: request.toJson(),
);
return AuthResponse.fromJson(response.data);
@@ -53,27 +32,4 @@ class AuthApi {
Future<void> deleteSession(LogoutRequest request) async {
await _client.delete('$_prefix/sessions', data: request.toJson());
}
Future<void> requestPasswordReset(String email) async {
await _client.post(
'$_prefix/resend',
data: {'type': 'recovery', 'email': email},
);
}
Future<void> confirmPasswordReset({
required String email,
required String token,
required String newPassword,
}) async {
await _client.post(
'$_prefix/verify',
data: {
'type': 'recovery',
'email': email,
'token': token,
'new_password': newPassword,
},
);
}
}
@@ -1,24 +1,15 @@
import 'package:social_app/features/auth/data/models/signup_request.dart';
import 'package:social_app/features/auth/data/models/login_request.dart';
import 'package:social_app/features/auth/data/models/auth_response.dart';
abstract class AuthRepository {
Future<VerificationCreateResponse> createVerification(
SignupStartRequest request,
);
Future<AuthResponse> verifyVerification(SignupVerifyRequest request);
Future<void> resendVerification(SignupResendRequest request);
Future<AuthResponse> createSession(LoginRequest request);
Future<void> sendOtp(String phone);
Future<AuthResponse> createPhoneSession({
required String phone,
required String token,
});
Future<AuthResponse> refreshSession(String refreshToken);
Future<void> deleteSession();
Future<void> clearSessionLocalOnly();
Future<String?> getAccessToken();
Future<String?> getRefreshToken();
Future<bool> isAuthenticated();
Future<void> requestPasswordReset(String email);
Future<void> confirmPasswordReset({
required String email,
required String token,
required String newPassword,
});
}
@@ -19,30 +19,18 @@ class AuthRepositoryImpl implements AuthRepository {
_onLogout = onLogout;
@override
Future<VerificationCreateResponse> createVerification(
SignupStartRequest request,
) {
return _api.createVerification(request);
Future<void> sendOtp(String phone) {
return _api.sendOtp(OtpSendRequest(phone: phone));
}
@override
Future<AuthResponse> verifyVerification(SignupVerifyRequest request) async {
final response = await _api.verifyVerification(request);
await _tokenStorage.saveTokens(
access: response.accessToken,
refresh: response.refreshToken,
Future<AuthResponse> createPhoneSession({
required String phone,
required String token,
}) async {
final response = await _api.createPhoneSession(
LoginRequest(phone: phone, token: token),
);
return response;
}
@override
Future<void> resendVerification(SignupResendRequest request) {
return _api.resendVerification(request);
}
@override
Future<AuthResponse> createSession(LoginRequest request) async {
final response = await _api.createSession(request);
await _tokenStorage.saveTokens(
access: response.accessToken,
refresh: response.refreshToken,
@@ -94,22 +82,4 @@ class AuthRepositoryImpl implements AuthRepository {
final token = await _tokenStorage.getAccessToken();
return token != null;
}
@override
Future<void> requestPasswordReset(String email) {
return _api.requestPasswordReset(email);
}
@override
Future<void> confirmPasswordReset({
required String email,
required String token,
required String newPassword,
}) {
return _api.confirmPasswordReset(
email: email,
token: token,
newPassword: newPassword,
);
}
}
@@ -1,11 +1,11 @@
class AuthUser {
final String id;
final String email;
final String phone;
const AuthUser({required this.id, required this.email});
const AuthUser({required this.id, required this.phone});
factory AuthUser.fromJson(Map<String, dynamic> json) {
return AuthUser(id: json['id'] as String, email: json['email'] as String);
return AuthUser(id: json['id'] as String, phone: json['phone'] as String);
}
}
@@ -34,13 +34,3 @@ class AuthResponse {
);
}
}
class VerificationCreateResponse {
final String email;
const VerificationCreateResponse({required this.email});
factory VerificationCreateResponse.fromJson(Map<String, dynamic> json) {
return VerificationCreateResponse(email: json['email'] as String);
}
}
@@ -1,10 +1,22 @@
class LoginRequest {
final String email;
final String password;
final String phone;
final String token;
const LoginRequest({required this.email, required this.password});
const LoginRequest({required this.phone, required this.token});
Map<String, dynamic> toJson() => {'email': email, 'password': password};
Map<String, dynamic> toJson() => {
'phone': _normalizePhone(phone),
'token': token,
};
}
String _normalizePhone(String input) {
var normalized = input.trim();
normalized = normalized.replaceAll(RegExp(r'[\s\-\(\)]'), '');
if (normalized.startsWith('00') && normalized.length > 2) {
return '+${normalized.substring(2)}';
}
return normalized;
}
class RefreshRequest {
@@ -1,37 +1,28 @@
class SignupStartRequest {
final String username;
final String email;
final String password;
final String? inviteCode;
class OtpSendRequest {
final String phone;
const SignupStartRequest({
required this.username,
required this.email,
required this.password,
this.inviteCode,
});
const OtpSendRequest({required this.phone});
Map<String, dynamic> toJson() => {'phone': _normalizePhone(phone)};
}
class PhoneSessionRequest {
final String phone;
final String token;
const PhoneSessionRequest({required this.phone, required this.token});
Map<String, dynamic> toJson() => {
'username': username,
'email': email,
'password': password,
if (inviteCode != null) 'invite_code': inviteCode,
'phone': _normalizePhone(phone),
'token': token,
};
}
class SignupVerifyRequest {
final String email;
final String token;
const SignupVerifyRequest({required this.email, required this.token});
Map<String, dynamic> toJson() => {'email': email, 'token': token};
}
class SignupResendRequest {
final String email;
const SignupResendRequest({required this.email});
Map<String, dynamic> toJson() => {'email': email};
String _normalizePhone(String input) {
var normalized = input.trim();
normalized = normalized.replaceAll(RegExp(r'[\s\-\(\)]'), '');
if (normalized.startsWith('00') && normalized.length > 2) {
return '+${normalized.substring(2)}';
}
return normalized;
}