diff --git a/lib/models/user.dart b/lib/models/user.dart index 3e4f3fc..80ff0b8 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -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 json, + List 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: , firstName, lastName, phone, avatar, ...}`) - /// کار می‌کند. + /// و هم با شکل Keycloak (attributes آرایه‌ای) کار می‌کند. factory User.fromJson(Map 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, ); diff --git a/lib/providers/user.dart b/lib/providers/user.dart index 6187ee3..da9163a 100644 --- a/lib/providers/user.dart +++ b/lib/providers/user.dart @@ -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 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 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 = {}; + 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 _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 _userinfoToAppShape(Map uinfo) { return { 'id': uinfo['sub'], diff --git a/lib/services/auth/keycloak_auth_service.dart b/lib/services/auth/keycloak_auth_service.dart index 0bf9426..138af1f 100644 --- a/lib/services/auth/keycloak_auth_service.dart +++ b/lib/services/auth/keycloak_auth_service.dart @@ -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) { diff --git a/lib/views/profile/edit_profile/edit_profile.dart b/lib/views/profile/edit_profile/edit_profile.dart index 57ece42..f78494f 100644 --- a/lib/views/profile/edit_profile/edit_profile.dart +++ b/lib/views/profile/edit_profile/edit_profile.dart @@ -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 Timer? _timer; late String fullName; - String? username; String? email; - bool _usernameIsAvailible = true; final _formKey = GlobalKey(); @@ -55,7 +50,6 @@ class _EditProfileState extends State super.initState(); final user = context.read().user; fullName = user.fullName; - username = user.username; email = user.email; _initializeAnimations(); @@ -312,44 +306,12 @@ class _EditProfileState extends State 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 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 bool get _buttonEnabled { final user = context.read().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().checkUsername(value).then((value) { - if (value == false) { - setState(() { - _usernameIsAvailible = false; - }); - } - }); - }); + return !(user.email == email && user.fullName == fullName); } } diff --git a/lib/views/profile/profile.dart b/lib/views/profile/profile.dart index 10ef1d1..02fd2ed 100644 --- a/lib/views/profile/profile.dart +++ b/lib/views/profile/profile.dart @@ -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 ), 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 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, + ), + ), + ), ], ), ),