didvan-app/lib/services/auth/auth_api_service.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;
}
}