Refactor user model and profile handling for new Keycloak backend support
This commit is contained in:
parent
5124e469f7
commit
d6825e9e5e
|
|
@ -6,7 +6,11 @@
|
|||
// فیلد `id` نگه داشته شده است (0 در صورت نبود معادل عددی).
|
||||
// - فیلدهای جدید بکاند: firstName, lastName, phone, avatar
|
||||
// معادل قدیمی: fullName, phoneNumber, photo
|
||||
// - متد `fromJson` هر دو شکل قدیمی و جدید را قبول میکند.
|
||||
// - Keycloak custom attribute ها (مثل phone/avatar) معمولا داخل
|
||||
// `attributes: { phone: ["..."], avatar: ["..."] }` به شکل آرایه
|
||||
// برمیگردند، نه رشتهی ساده.
|
||||
// - متد `fromJson` هر دو شکل قدیمی و جدید (و شکل آرایهای Keycloak) را
|
||||
// بدون کرش قبول میکند.
|
||||
|
||||
class User {
|
||||
/// Identifier عددی قدیمی (سازگاری). در بکاند جدید معمولا 0 خواهد بود.
|
||||
|
|
@ -35,9 +39,43 @@ class User {
|
|||
this.lastName,
|
||||
});
|
||||
|
||||
/// مقدار خام یک فیلد را به `String?` امن تبدیل میکند.
|
||||
/// - اگر رشته باشد همان را (یا null در صورت خالی بودن) برمیگرداند.
|
||||
/// - اگر آرایه باشد (قرارداد Keycloak برای attributes)، اولین عضو را
|
||||
/// میگیرد، بهجای اینکه با یک `as String` نادرست کرش کند.
|
||||
static String? _asString(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is String) return value.isEmpty ? null : value;
|
||||
if (value is List) {
|
||||
return value.isEmpty ? null : _asString(value.first);
|
||||
}
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
/// یک کلید را ابتدا در سطح بالای json و در صورت نبود، داخل
|
||||
/// `attributes` (قرارداد Keycloak: `attributes: { key: [...] }`) جستوجو
|
||||
/// میکند.
|
||||
static String? _lookup(
|
||||
Map<String, dynamic> json,
|
||||
List<String> keys,
|
||||
) {
|
||||
for (final key in keys) {
|
||||
final value = _asString(json[key]);
|
||||
if (value != null) return value;
|
||||
}
|
||||
final attributes = json['attributes'];
|
||||
if (attributes is Map) {
|
||||
for (final key in keys) {
|
||||
final value = _asString(attributes[key]);
|
||||
if (value != null) return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// سازندهای که هم با شکل قدیمی (`{id, fullName, phoneNumber, photo, ...}`)
|
||||
/// و هم با شکل جدید (`{id: <uuid>, firstName, lastName, phone, avatar, ...}`)
|
||||
/// کار میکند.
|
||||
/// و هم با شکل Keycloak (attributes آرایهای) کار میکند.
|
||||
factory User.fromJson(Map<String, dynamic> json) {
|
||||
// id ممکن است int یا String (UUID) باشد.
|
||||
final dynamic rawId = json['id'];
|
||||
|
|
@ -51,39 +89,39 @@ class User {
|
|||
}
|
||||
|
||||
// اولویت با فیلد جدید keycloakId اگر صریح ارسال شده باشد.
|
||||
if (json['keycloakId'] is String) {
|
||||
keycloakId = json['keycloakId'] as String;
|
||||
final explicitKeycloakId = _asString(json['keycloakId']);
|
||||
if (explicitKeycloakId != null) {
|
||||
keycloakId = explicitKeycloakId;
|
||||
}
|
||||
|
||||
// ساخت fullName: اگر مستقیم آمده استفاده میکنیم،
|
||||
// وگرنه از firstName + lastName میسازیم.
|
||||
final firstName = json['firstName'] as String?;
|
||||
final lastName = json['lastName'] as String?;
|
||||
String fullName;
|
||||
if (json['fullName'] is String &&
|
||||
(json['fullName'] as String).isNotEmpty) {
|
||||
fullName = json['fullName'] as String;
|
||||
} else {
|
||||
fullName = [
|
||||
firstName ?? '',
|
||||
lastName ?? '',
|
||||
].where((s) => s.isNotEmpty).join(' ');
|
||||
}
|
||||
final firstName = _lookup(json, const ['firstName']);
|
||||
final lastName = _lookup(json, const ['lastName']);
|
||||
final directFullName = _lookup(json, const ['fullName', 'name']);
|
||||
final fullName = directFullName ??
|
||||
[
|
||||
firstName ?? '',
|
||||
lastName ?? '',
|
||||
].where((s) => s.isNotEmpty).join(' ');
|
||||
|
||||
// phoneNumber: قدیم `phoneNumber`، جدید `phone`.
|
||||
final phone = (json['phoneNumber'] ?? json['phone'] ?? '') as String;
|
||||
// phoneNumber: قدیم `phoneNumber`، جدید `phone` (احتمالا داخل attributes).
|
||||
final phone = _lookup(json, const ['phoneNumber', 'phone']) ?? '';
|
||||
|
||||
// photo: قدیم `photo`، جدید `avatar`.
|
||||
final photo = (json['photo'] ?? json['avatar']) as String?;
|
||||
// photo: قدیم `photo`، جدید `avatar` (احتمالا داخل attributes).
|
||||
final photo = _lookup(json, const ['photo', 'avatar', 'picture']);
|
||||
|
||||
// username: معمولا سطح بالا است، اما بهعنوان fallback هم بررسی میشود.
|
||||
final username = _lookup(json, const ['username', 'preferred_username']);
|
||||
|
||||
return User(
|
||||
id: intId,
|
||||
keycloakId: keycloakId,
|
||||
username: json['username'] as String?,
|
||||
username: username,
|
||||
phoneNumber: phone,
|
||||
photo: photo,
|
||||
fullName: fullName,
|
||||
email: json['email'] as String?,
|
||||
email: _lookup(json, const ['email']),
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import 'package:didvan/models/user.dart';
|
|||
import 'package:didvan/models/view/alert_data.dart';
|
||||
import 'package:didvan/providers/core.dart';
|
||||
import 'package:didvan/services/auth/auth_api_service.dart';
|
||||
import 'package:didvan/services/auth/jwt_utils.dart';
|
||||
import 'package:didvan/services/auth/keycloak_auth_service.dart';
|
||||
import 'package:didvan/services/auth/token_storage.dart';
|
||||
import 'package:didvan/services/network/request.dart';
|
||||
|
|
@ -216,19 +217,45 @@ class UserProvider extends CoreProvier {
|
|||
return false;
|
||||
}
|
||||
|
||||
final userinfo = await KeycloakAuthService.instance.fetchUserInfo();
|
||||
if (userinfo == null) {
|
||||
print("UserProvider: userinfo failed (likely 401).");
|
||||
isAuthenticated = false;
|
||||
return false;
|
||||
}
|
||||
// 1) Local profile from the JWT (no network — always available).
|
||||
// Keycloak includes `name/given_name/family_name/preferred_username/email`
|
||||
// in the access token, so we can populate the UI even if BOTH the OIDC
|
||||
// userinfo endpoint AND api2/auth/users/:id are unreachable.
|
||||
Map<String, dynamic> userinfo = _profileFromToken();
|
||||
|
||||
// 2) Try the OIDC userinfo endpoint but do NOT block on it — some networks
|
||||
// (grey-cloud vs ArvanCloud reachability) cause 30s hangs on this call.
|
||||
// 3-second budget: if it comes back fast, we merge its data; if not, we
|
||||
// just keep the JWT payload. Runs in parallel with the api2 call below.
|
||||
final userinfoFuture = KeycloakAuthService.instance
|
||||
.fetchUserInfo()
|
||||
.timeout(const Duration(seconds: 3), onTimeout: () => null)
|
||||
.catchError((_) => null);
|
||||
|
||||
final result = await AuthApiService.instance.getUserInfo(keycloakId);
|
||||
|
||||
// Fold in the userinfo response if it beat the 3s budget.
|
||||
final remoteUserinfo = await userinfoFuture;
|
||||
if (remoteUserinfo != null) {
|
||||
userinfo = {...userinfo, ...remoteUserinfo};
|
||||
}
|
||||
|
||||
Map<String, dynamic> mergedData;
|
||||
if (result.isSuccess && result.data != null) {
|
||||
mergedData = {..._userinfoToAppShape(userinfo), ...result.data!};
|
||||
print("UserProvider: User info merged from userinfo + auth-service.");
|
||||
// The auth-service response can carry null/empty values for fields the
|
||||
// user hasn't filled in (fullName, firstName, lastName, phone, avatar).
|
||||
// Merging naively would BLANK OUT the good values from the OIDC userinfo
|
||||
// (which always has name/given_name/family_name for logged-in users).
|
||||
// So keep only non-empty overrides.
|
||||
final overrides = <String, dynamic>{};
|
||||
result.data!.forEach((k, v) {
|
||||
if (v == null) return;
|
||||
if (v is String && v.trim().isEmpty) return;
|
||||
if (v is List && v.isEmpty) return;
|
||||
overrides[k] = v;
|
||||
});
|
||||
mergedData = {..._userinfoToAppShape(userinfo), ...overrides};
|
||||
print("UserProvider: User info merged from userinfo + auth-service (${overrides.length} overrides).");
|
||||
} else {
|
||||
print("UserProvider: auth-service ${result.statusCode ?? 'n/a'} -> "
|
||||
"fallback to userinfo only.");
|
||||
|
|
@ -274,6 +301,25 @@ class UserProvider extends CoreProvier {
|
|||
}
|
||||
}
|
||||
|
||||
/// Extract OIDC-style profile claims straight from the current access token.
|
||||
/// Used as a network-free fallback when the userinfo endpoint is unreachable.
|
||||
Map<String, dynamic> _profileFromToken() {
|
||||
final token = TokenStorage.accessToken ?? '';
|
||||
if (token.isEmpty) return {};
|
||||
final claims = JwtUtils.decodePayload(token) ?? {};
|
||||
return {
|
||||
if (claims['sub'] != null) 'sub': claims['sub'],
|
||||
if (claims['preferred_username'] != null)
|
||||
'preferred_username': claims['preferred_username'],
|
||||
if (claims['given_name'] != null) 'given_name': claims['given_name'],
|
||||
if (claims['family_name'] != null) 'family_name': claims['family_name'],
|
||||
if (claims['name'] != null) 'name': claims['name'],
|
||||
if (claims['email'] != null) 'email': claims['email'],
|
||||
if (claims['phone'] != null) 'phone': claims['phone'],
|
||||
if (claims['picture'] != null) 'picture': claims['picture'],
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _userinfoToAppShape(Map<String, dynamic> uinfo) {
|
||||
return {
|
||||
'id': uinfo['sub'],
|
||||
|
|
|
|||
|
|
@ -55,7 +55,11 @@ class KeycloakAuthService {
|
|||
'client_id': AuthConfig.keycloakClientId,
|
||||
'username': username,
|
||||
'password': password,
|
||||
'scope': 'openid',
|
||||
// `offline_access` requests an OFFLINE refresh token that is NOT bound
|
||||
// to the SSO session idle timeout. Result: the app effectively never
|
||||
// logs the user out on its own — the offline session persists until
|
||||
// the user or an admin explicitly revokes it.
|
||||
'scope': 'openid offline_access',
|
||||
};
|
||||
final secret = AuthConfig.keycloakClientSecret;
|
||||
if (secret != null && secret.isNotEmpty) {
|
||||
|
|
@ -153,6 +157,10 @@ class KeycloakAuthService {
|
|||
'grant_type': 'refresh_token',
|
||||
'client_id': AuthConfig.keycloakClientId,
|
||||
'refresh_token': refreshToken,
|
||||
// Keep offline_access on rotation so the renewed refresh token is
|
||||
// also an offline token — otherwise a normal refresh would downgrade
|
||||
// us to a session-bound refresh that expires with SSO idle.
|
||||
'scope': 'openid offline_access',
|
||||
};
|
||||
final secret = AuthConfig.keycloakClientSecret;
|
||||
if (secret != null && secret.isNotEmpty) {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,12 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:didvan/config/design_config.dart';
|
||||
import 'package:didvan/config/theme_data.dart';
|
||||
import 'package:didvan/constants/assets.dart';
|
||||
import 'package:didvan/providers/user.dart';
|
||||
import 'package:didvan/routes/routes.dart';
|
||||
import 'package:didvan/views/profile/edit_profile/widgets/profile_photo.dart';
|
||||
import 'package:didvan/views/widgets/animated_visibility.dart';
|
||||
import 'package:didvan/views/widgets/didvan/button.dart';
|
||||
import 'package:didvan/views/widgets/didvan/scaffold.dart';
|
||||
import 'package:didvan/views/widgets/didvan/text.dart';
|
||||
import 'package:didvan/views/widgets/didvan/text_field.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/svg.dart';
|
||||
|
|
@ -27,9 +24,7 @@ class _EditProfileState extends State<EditProfile>
|
|||
Timer? _timer;
|
||||
|
||||
late String fullName;
|
||||
String? username;
|
||||
String? email;
|
||||
bool _usernameIsAvailible = true;
|
||||
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
|
|
@ -55,7 +50,6 @@ class _EditProfileState extends State<EditProfile>
|
|||
super.initState();
|
||||
final user = context.read<UserProvider>().user;
|
||||
fullName = user.fullName;
|
||||
username = user.username;
|
||||
email = user.email;
|
||||
|
||||
_initializeAnimations();
|
||||
|
|
@ -312,44 +306,12 @@ class _EditProfileState extends State<EditProfile>
|
|||
position: _field3SlideAnimation,
|
||||
child: FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DidvanTextField(
|
||||
title: 'نام کاربری',
|
||||
hintText: 'انتخاب نام کاربری (اختیاری)',
|
||||
onChanged: _onUsernameChanged,
|
||||
initialValue: state.user.username,
|
||||
acceptSpace: false,
|
||||
prefixSvgPath: 'lib/assets/icons/user.svg',
|
||||
suffixSvgPath:
|
||||
'lib/assets/icons/edit-3.svg',
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 8.0),
|
||||
child: DidvanText(
|
||||
'نام کاربری میتواند شامل کاراکترهای انگلیسی و اعداد باشد.',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelMedium
|
||||
?.copyWith(
|
||||
color: const Color.fromARGB(
|
||||
255, 184, 184, 184)),
|
||||
),
|
||||
),
|
||||
AnimatedVisibility(
|
||||
duration: DesignConfig.lowAnimationDuration,
|
||||
isVisible: !_usernameIsAvailible,
|
||||
child: DidvanText(
|
||||
'نام کاربری در دسترس نمیباشد',
|
||||
style:
|
||||
Theme.of(context).textTheme.bodySmall,
|
||||
color:
|
||||
Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: DidvanTextField(
|
||||
title: 'نام کاربری',
|
||||
enabled: false,
|
||||
hintText: state.user.username ?? '',
|
||||
initialValue: state.user.username,
|
||||
prefixSvgPath: 'lib/assets/icons/user.svg',
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -400,9 +362,15 @@ class _EditProfileState extends State<EditProfile>
|
|||
key: UniqueKey(),
|
||||
title: 'ذخیره تغییرات',
|
||||
onPressed: () {
|
||||
if (_formKey.currentState!.validate() &&
|
||||
_usernameIsAvailible) {
|
||||
state.editProfile(fullName, username, email);
|
||||
if (_formKey.currentState!.validate()) {
|
||||
// Username is intentionally locked on the app; pass
|
||||
// the current value unchanged so the server never
|
||||
// sees a rename request from here.
|
||||
state.editProfile(
|
||||
fullName,
|
||||
state.user.username,
|
||||
email,
|
||||
);
|
||||
}
|
||||
},
|
||||
imagepath: 'lib/assets/icons/verify.svg',
|
||||
|
|
@ -425,27 +393,6 @@ class _EditProfileState extends State<EditProfile>
|
|||
|
||||
bool get _buttonEnabled {
|
||||
final user = context.read<UserProvider>().user;
|
||||
return !(user.email == email &&
|
||||
user.fullName == fullName &&
|
||||
user.username == username);
|
||||
}
|
||||
|
||||
void _onUsernameChanged(String value) {
|
||||
_usernameIsAvailible = true;
|
||||
_setButtonState();
|
||||
username = value;
|
||||
_timer?.cancel();
|
||||
if (value.length < 5) {
|
||||
return;
|
||||
}
|
||||
_timer = Timer(const Duration(seconds: 1), () {
|
||||
context.read<UserProvider>().checkUsername(value).then((value) {
|
||||
if (value == false) {
|
||||
setState(() {
|
||||
_usernameIsAvailible = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
return !(user.email == email && user.fullName == fullName);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@ import 'package:didvan/providers/theme.dart';
|
|||
import 'package:didvan/providers/user.dart';
|
||||
import 'package:didvan/routes/routes.dart';
|
||||
import 'package:didvan/services/network/request.dart';
|
||||
import 'package:didvan/services/auth/jwt_utils.dart';
|
||||
import 'package:didvan/services/auth/token_storage.dart';
|
||||
import 'package:didvan/utils/action_sheet.dart';
|
||||
import 'package:didvan/views/profile/general_settings/settings_state.dart';
|
||||
import 'package:didvan/views/widgets/animated_visibility.dart';
|
||||
|
|
@ -186,7 +184,11 @@ class _ProfilePageState extends State<ProfilePage>
|
|||
),
|
||||
const SizedBox(height: 16),
|
||||
DidvanText(
|
||||
user.fullName,
|
||||
// Fall back to username / phone if fullName is empty
|
||||
// so the header never renders a blank Text widget.
|
||||
user.fullName.trim().isNotEmpty
|
||||
? user.fullName
|
||||
: (user.username ?? user.phoneNumber),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
|
|
@ -195,43 +197,25 @@ class _ProfilePageState extends State<ProfilePage>
|
|||
color:
|
||||
const Color.fromARGB(255, 0, 126, 167)),
|
||||
),
|
||||
// Multi-tenant: نمایش نام گروه کاربر (فرتاک/مبارکه/...)
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final token = TokenStorage.accessToken ?? '';
|
||||
if (token.isEmpty) return const SizedBox.shrink();
|
||||
final slug = JwtUtils.tenantSlugOf(token);
|
||||
const slugToLabel = {
|
||||
'fartak': 'فرتاک',
|
||||
'mobarakeh': 'فولاد مبارکه',
|
||||
'gohar-zamin': 'گوهر زمین',
|
||||
};
|
||||
final label = slugToLabel[slug];
|
||||
if (label == null) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color.fromARGB(255, 0, 126, 167)
|
||||
.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: DidvanText(
|
||||
label,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color.fromARGB(
|
||||
255, 0, 126, 167)),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
// Show a short secondary line (username / phone) if
|
||||
// it's different from the primary text — helpful for
|
||||
// users whose fullName isn't set yet.
|
||||
if (user.fullName.trim().isNotEmpty &&
|
||||
(user.username ?? user.phoneNumber).isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: DidvanText(
|
||||
user.username ?? user.phoneNumber,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
Loading…
Reference in New Issue