Refactor user model and profile handling for new Keycloak backend support

This commit is contained in:
Mohamad Mahdi Jebeli 2026-07-14 12:31:57 +03:30
parent 5124e469f7
commit d6825e9e5e
5 changed files with 163 additions and 140 deletions

View File

@ -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,
);

View File

@ -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'],

View File

@ -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) {

View File

@ -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);
}
}

View File

@ -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,
),
),
),
],
),
),