From 9b6f5c960a346040f38a3000b02623fd9d382ce0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leon=20B=C3=B6sche?= Date: Thu, 29 Jan 2026 22:42:36 +0100 Subject: [PATCH] Enhance user profile handling by fetching full profile data and updating avatar URLs in AuthBloc and related components --- b0esche_cloud/lib/blocs/auth/auth_bloc.dart | 166 +++++++++++++----- b0esche_cloud/lib/pages/home_page.dart | 18 +- .../lib/widgets/account_settings_dialog.dart | 46 +++-- go_cloud/internal/http/routes.go | 91 ++++++++-- 4 files changed, 246 insertions(+), 75 deletions(-) diff --git a/b0esche_cloud/lib/blocs/auth/auth_bloc.dart b/b0esche_cloud/lib/blocs/auth/auth_bloc.dart index c62e9fc..c21f9ea 100644 --- a/b0esche_cloud/lib/blocs/auth/auth_bloc.dart +++ b/b0esche_cloud/lib/blocs/auth/auth_bloc.dart @@ -6,6 +6,7 @@ import 'auth_event.dart'; import 'auth_state.dart'; import '../../services/api_client.dart'; import '../../models/api_error.dart'; +import '../../models/user.dart'; class AuthBloc extends Bloc { final ApiClient apiClient; @@ -109,14 +110,29 @@ class AuthBloc extends Bloc { sessionBloc.add(SessionStarted(token)); - emit( - AuthAuthenticated( - token: token, - userId: user['id'], - username: user['username'], - email: user['email'], - ), - ); + // Fetch full profile and include it in state when possible + try { + final profile = await apiClient.getUserProfile(); + final fullUser = profile.isNotEmpty ? User.fromJson(profile) : null; + emit( + AuthAuthenticated( + token: token, + userId: user['id'], + username: user['username'], + email: user['email'], + user: fullUser, + ), + ); + } catch (e) { + emit( + AuthAuthenticated( + token: token, + userId: user['id'], + username: user['username'], + email: user['email'], + ), + ); + } } catch (e) { final errorMessage = _extractErrorMessage(e); emit(AuthFailure(errorMessage)); @@ -189,14 +205,29 @@ class AuthBloc extends Bloc { sessionBloc.add(SessionStarted(token)); - emit( - AuthAuthenticated( - token: token, - userId: user['id'], - username: user['username'], - email: user['email'], - ), - ); + // Fetch full profile and include it in state when possible + try { + final profile = await apiClient.getUserProfile(); + final fullUser = profile.isNotEmpty ? User.fromJson(profile) : null; + emit( + AuthAuthenticated( + token: token, + userId: user['id'], + username: user['username'], + email: user['email'], + user: fullUser, + ), + ); + } catch (e) { + emit( + AuthAuthenticated( + token: token, + userId: user['id'], + username: user['username'], + email: user['email'], + ), + ); + } } catch (e) { final errorMessage = _extractErrorMessage(e); emit(AuthFailure(errorMessage)); @@ -229,14 +260,30 @@ class AuthBloc extends Bloc { final user = response['user']; sessionBloc.add(SessionStarted(token)); - emit( - AuthAuthenticated( - token: token, - userId: user['id'], - username: user['username'], - email: user['email'], - ), - ); + + // Fetch full profile and include it in state when possible + try { + final profile = await apiClient.getUserProfile(); + final fullUser = profile.isNotEmpty ? User.fromJson(profile) : null; + emit( + AuthAuthenticated( + token: token, + userId: user['id'], + username: user['username'], + email: user['email'], + user: fullUser, + ), + ); + } catch (e) { + emit( + AuthAuthenticated( + token: token, + userId: user['id'], + username: user['username'], + email: user['email'], + ), + ); + } } catch (e) { final errorMessage = _extractErrorMessage(e); emit(AuthFailure(errorMessage)); @@ -258,16 +305,30 @@ class AuthBloc extends Bloc { final sessionState = sessionBloc.state; if (sessionState is SessionActive) { - // Session already active - emit authenticated state with minimal info - // The full user info will be fetched when needed - emit( - AuthAuthenticated( - token: sessionState.token, - userId: '', - username: '', - email: '', - ), - ); + // Try to fetch full profile immediately so UI can show avatar/displayName + try { + final profile = await apiClient.getUserProfile(); + final fullUser = profile.isNotEmpty ? User.fromJson(profile) : null; + emit( + AuthAuthenticated( + token: sessionState.token, + userId: fullUser?.id ?? '', + username: fullUser?.username ?? '', + email: fullUser?.email ?? '', + user: fullUser, + ), + ); + } catch (e) { + // Fall back to minimal authenticated state if profile fetch fails + emit( + AuthAuthenticated( + token: sessionState.token, + userId: '', + username: '', + email: '', + ), + ); + } } else { emit(AuthUnauthenticated()); } @@ -277,18 +338,35 @@ class AuthBloc extends Bloc { UpdateUserProfile event, Emitter emit, ) async { - // For now, just update the local state - in real app, call API if (state is AuthAuthenticated) { final currentState = state as AuthAuthenticated; - emit( - AuthAuthenticated( - token: currentState.token, - userId: event.updatedUser.id, - username: event.updatedUser.username, - email: event.updatedUser.email, - user: event.updatedUser, - ), - ); + // Try to reload profile from backend to ensure we have canonical avatar URL (with token+version) + try { + final profile = await apiClient.getUserProfile(); + final fullUser = profile.isNotEmpty + ? User.fromJson(profile) + : event.updatedUser; + emit( + AuthAuthenticated( + token: currentState.token, + userId: fullUser.id, + username: fullUser.username, + email: fullUser.email, + user: fullUser, + ), + ); + } catch (e) { + // Fallback to using the provided updatedUser + emit( + AuthAuthenticated( + token: currentState.token, + userId: event.updatedUser.id, + username: event.updatedUser.username, + email: event.updatedUser.email, + user: event.updatedUser, + ), + ); + } } } } diff --git a/b0esche_cloud/lib/pages/home_page.dart b/b0esche_cloud/lib/pages/home_page.dart index 8cc58fb..aa6d053 100644 --- a/b0esche_cloud/lib/pages/home_page.dart +++ b/b0esche_cloud/lib/pages/home_page.dart @@ -400,9 +400,21 @@ class _HomePageState extends State with TickerProviderStateMixin { }); }, child: isAvatar - ? CircleAvatar( - backgroundColor: isSelected ? highlightColor : defaultColor, - child: Icon(icon, color: AppTheme.primaryBackground), + ? BlocBuilder( + builder: (context, state) { + if (state is AuthAuthenticated && + state.user?.avatarUrl != null) { + return CircleAvatar( + radius: 20, + backgroundImage: NetworkImage(state.user!.avatarUrl!), + backgroundColor: Colors.transparent, + ); + } + return CircleAvatar( + backgroundColor: isSelected ? highlightColor : defaultColor, + child: Icon(icon, color: AppTheme.primaryBackground), + ); + }, ) : Column( mainAxisSize: MainAxisSize.min, diff --git a/b0esche_cloud/lib/widgets/account_settings_dialog.dart b/b0esche_cloud/lib/widgets/account_settings_dialog.dart index db28e54..944de1e 100644 --- a/b0esche_cloud/lib/widgets/account_settings_dialog.dart +++ b/b0esche_cloud/lib/widgets/account_settings_dialog.dart @@ -109,24 +109,39 @@ class _AccountSettingsDialogState extends State { ); setState(() { - _avatarUrl = response['avatarUrl'] as String?; _isUploadingAvatar = false; _avatarUploadProgress = 0.0; }); - if (_avatarUrl != null) { - final authState = context.read().state; - if (authState is AuthAuthenticated) { - _avatarUrl = "$_avatarUrl&token=${authState.token}"; - } - } + // Fetch latest profile from backend so we get the canonical avatar URL and version + try { + final apiClient = GetIt.I(); + final profile = await apiClient.getUserProfile(); + final newAvatar = profile['avatarUrl'] as String?; - // Update auth state with new avatar - if (_currentUser != null && _avatarUrl != null) { - final updatedUser = _currentUser!.copyWith(avatarUrl: _avatarUrl); - if (mounted) { + setState(() => _avatarUrl = newAvatar); + + if (profile.isNotEmpty && mounted) { + final updatedUser = User.fromJson(profile); context.read().add(UpdateUserProfile(updatedUser)); } + } catch (e) { + // Fall back to response avatar URL if profile fetch fails + setState(() { + _avatarUrl = response['avatarUrl'] as String?; + final authState = context.read().state; + if (authState is AuthAuthenticated && _avatarUrl != null) { + _avatarUrl = "$_avatarUrl&token=${authState.token}"; + } + if (_currentUser != null && _avatarUrl != null) { + final updatedUser = _currentUser!.copyWith( + avatarUrl: _avatarUrl, + ); + if (mounted) { + context.read().add(UpdateUserProfile(updatedUser)); + } + } + }); } if (mounted) { @@ -168,14 +183,13 @@ class _AccountSettingsDialogState extends State { setState(() => _isLoading = true); try { final apiClient = GetIt.I(); - await apiClient.updateUserProfile( + final response = await apiClient.updateUserProfile( displayName: _displayNameController.text, ); - final updatedUser = _currentUser!.copyWith( - displayName: _displayNameController.text, - avatarUrl: _avatarUrl, - ); + // Use returned profile to update auth state (ensures avatarUrl + version are present) + final updatedProfile = response; + final updatedUser = User.fromJson(updatedProfile); if (mounted) { // Update auth state diff --git a/go_cloud/internal/http/routes.go b/go_cloud/internal/http/routes.go index 1a6562d..7cc4717 100644 --- a/go_cloud/internal/http/routes.go +++ b/go_cloud/internal/http/routes.go @@ -3830,12 +3830,13 @@ func getUserProfileHandler(w http.ResponseWriter, r *http.Request, db *database. AvatarURL *string `json:"avatarUrl"` CreatedAt time.Time `json:"createdAt"` LastLoginAt *time.Time `json:"lastLoginAt"` + UpdatedAt *time.Time `json:"-"` } err = db.QueryRowContext(r.Context(), - `SELECT id, username, email, display_name, avatar_url, created_at, last_login_at + `SELECT id, username, email, display_name, avatar_url, created_at, last_login_at, updated_at FROM users WHERE id = $1`, userID). - Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.AvatarURL, &user.CreatedAt, &user.LastLoginAt) + Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.AvatarURL, &user.CreatedAt, &user.LastLoginAt, &user.UpdatedAt) if err != nil { if err == sql.ErrNoRows { errors.WriteError(w, errors.CodeNotFound, "User not found", http.StatusNotFound) @@ -3848,7 +3849,20 @@ func getUserProfileHandler(w http.ResponseWriter, r *http.Request, db *database. // If avatar exists, return the backend URL instead of the internal WebDAV URL if user.AvatarURL != nil && *user.AvatarURL != "" { - user.AvatarURL = &[]string{fmt.Sprintf("https://go.b0esche.cloud/user/avatar?v=%d", time.Now().Unix())}[0] + // Use updated_at for versioning if available to allow cache busting when avatar changes + var v int64 + if user.UpdatedAt != nil { + v = user.UpdatedAt.Unix() + } else { + v = time.Now().Unix() + } + // Include token in the avatar URL so frontends that cannot set headers (Image.network) can fetch it + token, ok := middleware.GetToken(r.Context()) + if ok && token != "" { + user.AvatarURL = &[]string{fmt.Sprintf("https://go.b0esche.cloud/user/avatar?v=%d&token=%s", v, token)}[0] + } else { + user.AvatarURL = &[]string{fmt.Sprintf("https://go.b0esche.cloud/user/avatar?v=%d", v)}[0] + } } w.Header().Set("Content-Type", "application/json") @@ -3929,8 +3943,44 @@ func updateUserProfileHandler(w http.ResponseWriter, r *http.Request, db *databa Metadata: metadata, }) + // Return updated profile JSON + var updatedUser struct { + ID uuid.UUID `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + DisplayName *string `json:"displayName"` + AvatarURL *string `json:"avatarUrl"` + CreatedAt time.Time `json:"createdAt"` + LastLoginAt *time.Time `json:"lastLoginAt"` + UpdatedAt *time.Time `json:"-"` + } + + err = db.QueryRowContext(r.Context(), + `SELECT id, username, email, display_name, avatar_url, created_at, last_login_at, updated_at + FROM users WHERE id = $1`, userID). + Scan(&updatedUser.ID, &updatedUser.Username, &updatedUser.Email, &updatedUser.DisplayName, &updatedUser.AvatarURL, &updatedUser.CreatedAt, &updatedUser.LastLoginAt, &updatedUser.UpdatedAt) + if err != nil { + errors.LogError(r, err, "Failed to fetch updated user profile") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + + if updatedUser.AvatarURL != nil && *updatedUser.AvatarURL != "" { + var v int64 + if updatedUser.UpdatedAt != nil { + v = updatedUser.UpdatedAt.Unix() + } else { + v = time.Now().Unix() + } + if token, ok := middleware.GetToken(r.Context()); ok && token != "" { + updatedUser.AvatarURL = &[]string{fmt.Sprintf("https://go.b0esche.cloud/user/avatar?v=%d&token=%s", v, token)}[0] + } else { + updatedUser.AvatarURL = &[]string{fmt.Sprintf("https://go.b0esche.cloud/user/avatar?v=%d", v)}[0] + } + } + w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]string{"message": "Profile updated"}) + json.NewEncoder(w).Encode(updatedUser) } // changePasswordHandler changes the current user's password @@ -4020,14 +4070,14 @@ func uploadUserAvatarHandler(w http.ResponseWriter, r *http.Request, db *databas return } - // Generate unique filename + // Generate deterministic filename based on user ID ext := filepath.Ext(header.Filename) if ext == "" { - ext = ".jpg" // default extension + ext = ".png" // default extension } - filename := fmt.Sprintf("%s%s", uuid.New().String(), ext) + filename := fmt.Sprintf("%s%s", userID.String(), ext) - // Upload to Nextcloud + // Upload to Nextcloud at .avatars/. client := storage.NewWebDAVClient(cfg) avatarPath := fmt.Sprintf(".avatars/%s", filename) err = client.Upload(r.Context(), avatarPath, bytes.NewReader(fileBytes), header.Size) @@ -4037,12 +4087,9 @@ func uploadUserAvatarHandler(w http.ResponseWriter, r *http.Request, db *databas return } - // Get public URL - for now, construct it manually since Nextcloud doesn't provide direct public URLs - // In a real setup, you'd configure Nextcloud to serve public URLs or use a CDN - publicURL := fmt.Sprintf("https://go.b0esche.cloud/user/avatar?v=%d", time.Now().Unix()) webdavURL := fmt.Sprintf("%s/%s", client.BaseURL, avatarPath) - // Update user profile with avatar URL + // Update user profile with avatar URL and updated_at _, err = db.ExecContext(r.Context(), `UPDATE users SET avatar_url = $1, updated_at = NOW() WHERE id = $2`, webdavURL, userID) @@ -4063,6 +4110,19 @@ func uploadUserAvatarHandler(w http.ResponseWriter, r *http.Request, db *databas }, }) + // Build public URL including version based on updated_at and include token if available + var version int64 = time.Now().Unix() + // Try to use updated_at from DB to be more accurate + var updatedAt time.Time + err = db.QueryRowContext(r.Context(), `SELECT updated_at FROM users WHERE id = $1`, userID).Scan(&updatedAt) + if err == nil { + version = updatedAt.Unix() + } + publicURL := fmt.Sprintf("https://go.b0esche.cloud/user/avatar?v=%d", version) + if token, ok := middleware.GetToken(r.Context()); ok && token != "" { + publicURL = fmt.Sprintf("%s&token=%s", publicURL, token) + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Avatar uploaded successfully", @@ -4072,7 +4132,14 @@ func uploadUserAvatarHandler(w http.ResponseWriter, r *http.Request, db *databas // getUserAvatarHandler serves the user's avatar image func getUserAvatarHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, cfg *config.Config) { + // Accept token via query param or Authorization header (Bearer) tokenString := r.URL.Query().Get("token") + if tokenString == "" { + authHeader := r.Header.Get("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + tokenString = strings.TrimPrefix(authHeader, "Bearer ") + } + } if tokenString == "" { errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized) return