388 lines
13 KiB
Dart
388 lines
13 KiB
Dart
// lib/services/auth/auth_api_service.dart
|
|
//
|
|
// سرویس برای endpoint های auth در API gateway جدید (Nest microservice).
|
|
// شامل: OTP، required-actions (معادل checkHasPassword)، user info، update profile، ...
|
|
|
|
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:developer' as dev;
|
|
import 'dart:io' show SocketException;
|
|
|
|
// ignore: depend_on_referenced_packages
|
|
import 'package:http/http.dart' as http;
|
|
|
|
import 'package:didvan/config/auth_config.dart';
|
|
import 'package:didvan/services/auth/token_storage.dart';
|
|
|
|
/// در صورت دریافت SocketException (مثلا DNS resolve fail در لحظهی boot
|
|
/// emulator)، تلاش را با تاخیر تکرار میکند. در غیر این صورت همان نتیجهی
|
|
/// اولیه را برمیگرداند.
|
|
Future<T> _withRetry<T>(Future<T> Function() body, {int retries = 2}) async {
|
|
for (var attempt = 0; attempt <= retries; attempt++) {
|
|
try {
|
|
return await body();
|
|
} on SocketException {
|
|
if (attempt == retries) rethrow;
|
|
await Future.delayed(Duration(milliseconds: 250 * (attempt + 1)));
|
|
} on http.ClientException {
|
|
if (attempt == retries) rethrow;
|
|
await Future.delayed(Duration(milliseconds: 250 * (attempt + 1)));
|
|
} on TimeoutException {
|
|
if (attempt == retries) rethrow;
|
|
}
|
|
}
|
|
// unreachable
|
|
throw StateError('retry loop exited without return');
|
|
}
|
|
|
|
class AuthApiResult<T> {
|
|
final bool isSuccess;
|
|
final int? statusCode;
|
|
final T? data;
|
|
final String? errorMessage;
|
|
|
|
const AuthApiResult({
|
|
required this.isSuccess,
|
|
this.statusCode,
|
|
this.data,
|
|
this.errorMessage,
|
|
});
|
|
}
|
|
|
|
class AuthApiService {
|
|
AuthApiService._();
|
|
static final AuthApiService instance = AuthApiService._();
|
|
|
|
String get _base => AuthConfig.apiBaseUrl;
|
|
|
|
Map<String, String> _headers({bool withAuth = false}) {
|
|
final headers = <String, String>{
|
|
'Accept': 'application/json',
|
|
'Content-Type': 'application/json',
|
|
};
|
|
if (withAuth) {
|
|
final t = TokenStorage.accessToken;
|
|
if (t != null && t.isNotEmpty) headers['Authorization'] = 'Bearer $t';
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// معادل checkHasPassword قدیمی.
|
|
// GET /auth/users/required-actions?username=X
|
|
// پاسخ: { id, requiredActions: [...] }
|
|
// - اگر `UPDATE_PASSWORD` در لیست باشد => کاربر هنوز رمز ندارد
|
|
// - اگر id خالی باشد => کاربر موجود نیست (404 منطقی)
|
|
// ---------------------------------------------------------------
|
|
Future<AuthApiResult<Map<String, dynamic>>> getRequiredActions(
|
|
String username,
|
|
) async {
|
|
try {
|
|
final url = Uri.parse(
|
|
'$_base/auth/users/required-actions',
|
|
).replace(queryParameters: {'username': username});
|
|
final response = await _withRetry(
|
|
() => http.get(url, headers: _headers()).timeout(_timeout),
|
|
);
|
|
|
|
final body = _safeDecode(response.body);
|
|
if (response.statusCode == 200) {
|
|
if (body is Map<String, dynamic> &&
|
|
(body['id'] == null || (body['id'] as String).isEmpty)) {
|
|
return const AuthApiResult(
|
|
isSuccess: false,
|
|
statusCode: 404,
|
|
errorMessage: 'کاربر موجود نمیباشد.',
|
|
);
|
|
}
|
|
return AuthApiResult(
|
|
isSuccess: true,
|
|
statusCode: 200,
|
|
data: body is Map<String, dynamic> ? body : null,
|
|
);
|
|
}
|
|
return AuthApiResult(
|
|
isSuccess: false,
|
|
statusCode: response.statusCode,
|
|
errorMessage: _extractError(body) ?? 'خطا در دریافت اطلاعات کاربر.',
|
|
);
|
|
} catch (e) {
|
|
dev.log('getRequiredActions error: $e', name: 'AuthApiService');
|
|
return const AuthApiResult(
|
|
isSuccess: false,
|
|
errorMessage: 'خطا در ارتباط با سرور.',
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// POST /auth/users/generate-otp body: { username }
|
|
// ---------------------------------------------------------------
|
|
Future<AuthApiResult<void>> generateOtp(String username) async {
|
|
return _postJson(
|
|
path: '/auth/users/generate-otp',
|
|
body: {'username': username},
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// POST /auth/users/validate-otp body: { username, otp }
|
|
// پاسخ موفق: { isValid: true|false }
|
|
// ---------------------------------------------------------------
|
|
Future<AuthApiResult<bool>> validateOtp({
|
|
required String username,
|
|
required String otp,
|
|
}) async {
|
|
try {
|
|
final response = await http
|
|
.post(
|
|
Uri.parse('$_base/auth/users/validate-otp'),
|
|
headers: _headers(),
|
|
body: jsonEncode({'username': username, 'otp': otp}),
|
|
)
|
|
.timeout(_timeout);
|
|
final body = _safeDecode(response.body);
|
|
if (response.statusCode == 200 && body is Map) {
|
|
final valid = body['isValid'] == true;
|
|
return AuthApiResult(
|
|
isSuccess: valid,
|
|
statusCode: 200,
|
|
data: valid,
|
|
errorMessage: valid ? null : 'کد وارد شده صحیح نمیباشد.',
|
|
);
|
|
}
|
|
return AuthApiResult(
|
|
isSuccess: false,
|
|
statusCode: response.statusCode,
|
|
errorMessage: _extractError(body) ?? 'کد وارد شده صحیح نمیباشد.',
|
|
);
|
|
} catch (e) {
|
|
dev.log('validateOtp error: $e', name: 'AuthApiService');
|
|
return const AuthApiResult(
|
|
isSuccess: false,
|
|
errorMessage: 'خطا در ارتباط با سرور.',
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// POST /auth/users/reset-password-with-otp
|
|
// body: { username, otp, newPassword }
|
|
// ---------------------------------------------------------------
|
|
Future<AuthApiResult<void>> resetPasswordWithOtp({
|
|
required String username,
|
|
required String otp,
|
|
required String newPassword,
|
|
}) async {
|
|
return _postJson(
|
|
path: '/auth/users/reset-password-with-otp',
|
|
body: {
|
|
'username': username,
|
|
'otp': otp,
|
|
'newPassword': newPassword,
|
|
},
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// GET /auth/users/:id (نیاز به Bearer)
|
|
// ---------------------------------------------------------------
|
|
Future<AuthApiResult<Map<String, dynamic>>> getUserInfo(String id) async {
|
|
try {
|
|
final response = await http
|
|
.get(
|
|
Uri.parse('$_base/auth/users/$id'),
|
|
headers: _headers(withAuth: true),
|
|
)
|
|
.timeout(_timeout);
|
|
final body = _safeDecode(response.body);
|
|
if (response.statusCode == 200 && body is Map<String, dynamic>) {
|
|
return AuthApiResult(
|
|
isSuccess: true,
|
|
statusCode: 200,
|
|
data: body,
|
|
);
|
|
}
|
|
return AuthApiResult(
|
|
isSuccess: false,
|
|
statusCode: response.statusCode,
|
|
errorMessage: _extractError(body) ?? 'خطا در دریافت اطلاعات کاربر.',
|
|
);
|
|
} catch (e) {
|
|
dev.log('getUserInfo error: $e', name: 'AuthApiService');
|
|
return const AuthApiResult(
|
|
isSuccess: false,
|
|
errorMessage: 'خطا در ارتباط با سرور.',
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// PATCH /auth/users/:id (نیاز به Bearer)
|
|
// ---------------------------------------------------------------
|
|
Future<AuthApiResult<Map<String, dynamic>>> updateUser({
|
|
required String id,
|
|
required Map<String, dynamic> data,
|
|
}) async {
|
|
try {
|
|
final response = await http
|
|
.patch(
|
|
Uri.parse('$_base/auth/users/$id'),
|
|
headers: _headers(withAuth: true),
|
|
body: jsonEncode(data),
|
|
)
|
|
.timeout(_timeout);
|
|
final body = _safeDecode(response.body);
|
|
if (response.statusCode == 200 && body is Map<String, dynamic>) {
|
|
return AuthApiResult(
|
|
isSuccess: true,
|
|
statusCode: 200,
|
|
data: body,
|
|
);
|
|
}
|
|
return AuthApiResult(
|
|
isSuccess: false,
|
|
statusCode: response.statusCode,
|
|
errorMessage: _extractError(body) ?? 'خطا در بهروزرسانی اطلاعات.',
|
|
);
|
|
} catch (e) {
|
|
dev.log('updateUser error: $e', name: 'AuthApiService');
|
|
return const AuthApiResult(
|
|
isSuccess: false,
|
|
errorMessage: 'خطا در ارتباط با سرور.',
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// PUT /auth/users/:id/reset-password (نیاز به Bearer)
|
|
// برای تغییر رمز کاربر لاگینشده (بدون OTP).
|
|
// body: { password, temporary? }
|
|
// ---------------------------------------------------------------
|
|
Future<AuthApiResult<void>> setOwnPassword({
|
|
required String id,
|
|
required String newPassword,
|
|
}) async {
|
|
try {
|
|
final response = await http
|
|
.put(
|
|
Uri.parse('$_base/auth/users/$id/reset-password'),
|
|
headers: _headers(withAuth: true),
|
|
body: jsonEncode({
|
|
'password': newPassword,
|
|
'temporary': false,
|
|
}),
|
|
)
|
|
.timeout(_timeout);
|
|
if (response.statusCode == 200 || response.statusCode == 204) {
|
|
return const AuthApiResult(isSuccess: true, statusCode: 200);
|
|
}
|
|
final body = _safeDecode(response.body);
|
|
return AuthApiResult(
|
|
isSuccess: false,
|
|
statusCode: response.statusCode,
|
|
errorMessage: _extractError(body) ?? 'خطا در تغییر رمز عبور.',
|
|
);
|
|
} catch (e) {
|
|
dev.log('setOwnPassword error: $e', name: 'AuthApiService');
|
|
return const AuthApiResult(
|
|
isSuccess: false,
|
|
errorMessage: 'خطا در ارتباط با سرور.',
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// PATCH /auth/users/:id/profile-picture body: { key }
|
|
// کلید S3 (آپلودشده توسط کلاینت) را به اواتار کاربر سینک میکند.
|
|
// ---------------------------------------------------------------
|
|
Future<AuthApiResult<Map<String, dynamic>>> setProfilePicture({
|
|
required String id,
|
|
required String s3Key,
|
|
}) async {
|
|
try {
|
|
final response = await http
|
|
.patch(
|
|
Uri.parse('$_base/auth/users/$id/profile-picture'),
|
|
headers: _headers(withAuth: true),
|
|
body: jsonEncode({'key': s3Key}),
|
|
)
|
|
.timeout(_timeout);
|
|
final body = _safeDecode(response.body);
|
|
if (response.statusCode == 200 && body is Map<String, dynamic>) {
|
|
return AuthApiResult(
|
|
isSuccess: true,
|
|
statusCode: 200,
|
|
data: body,
|
|
);
|
|
}
|
|
return AuthApiResult(
|
|
isSuccess: false,
|
|
statusCode: response.statusCode,
|
|
errorMessage: _extractError(body) ?? 'خطا در تنظیم عکس پروفایل.',
|
|
);
|
|
} catch (e) {
|
|
dev.log('setProfilePicture error: $e', name: 'AuthApiService');
|
|
return const AuthApiResult(
|
|
isSuccess: false,
|
|
errorMessage: 'خطا در ارتباط با سرور.',
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------
|
|
|
|
static const _timeout = Duration(seconds: 30);
|
|
|
|
Future<AuthApiResult<void>> _postJson({
|
|
required String path,
|
|
required Map<String, dynamic> body,
|
|
bool withAuth = false,
|
|
}) async {
|
|
try {
|
|
final response = await http
|
|
.post(
|
|
Uri.parse('$_base$path'),
|
|
headers: _headers(withAuth: withAuth),
|
|
body: jsonEncode(body),
|
|
)
|
|
.timeout(_timeout);
|
|
if (response.statusCode == 200 || response.statusCode == 204) {
|
|
return const AuthApiResult(isSuccess: true, statusCode: 200);
|
|
}
|
|
final decoded = _safeDecode(response.body);
|
|
return AuthApiResult(
|
|
isSuccess: false,
|
|
statusCode: response.statusCode,
|
|
errorMessage: _extractError(decoded) ?? 'خطا در درخواست.',
|
|
);
|
|
} catch (e) {
|
|
dev.log('_postJson error: $e', name: 'AuthApiService');
|
|
return const AuthApiResult(
|
|
isSuccess: false,
|
|
errorMessage: 'خطا در ارتباط با سرور.',
|
|
);
|
|
}
|
|
}
|
|
|
|
dynamic _safeDecode(String body) {
|
|
if (body.isEmpty) return null;
|
|
try {
|
|
return json.decode(body);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
String? _extractError(dynamic body) {
|
|
if (body is Map) {
|
|
final msg = body['message'] ?? body['error'] ?? body['msg'];
|
|
if (msg is String && msg.isNotEmpty) return msg;
|
|
if (msg is List && msg.isNotEmpty) return msg.first.toString();
|
|
}
|
|
return null;
|
|
}
|
|
}
|