Enhance user profile handling by fetching full profile data and updating avatar URLs in AuthBloc and related components

This commit is contained in:
Leon Bösche
2026-01-29 22:42:36 +01:00
parent c27f4be6cb
commit 9b6f5c960a
4 changed files with 246 additions and 75 deletions

View File

@@ -6,6 +6,7 @@ import 'auth_event.dart';
import 'auth_state.dart'; import 'auth_state.dart';
import '../../services/api_client.dart'; import '../../services/api_client.dart';
import '../../models/api_error.dart'; import '../../models/api_error.dart';
import '../../models/user.dart';
class AuthBloc extends Bloc<AuthEvent, AuthState> { class AuthBloc extends Bloc<AuthEvent, AuthState> {
final ApiClient apiClient; final ApiClient apiClient;
@@ -109,6 +110,20 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
sessionBloc.add(SessionStarted(token)); sessionBloc.add(SessionStarted(token));
// 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( emit(
AuthAuthenticated( AuthAuthenticated(
token: token, token: token,
@@ -117,6 +132,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
email: user['email'], email: user['email'],
), ),
); );
}
} catch (e) { } catch (e) {
final errorMessage = _extractErrorMessage(e); final errorMessage = _extractErrorMessage(e);
emit(AuthFailure(errorMessage)); emit(AuthFailure(errorMessage));
@@ -189,6 +205,20 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
sessionBloc.add(SessionStarted(token)); sessionBloc.add(SessionStarted(token));
// 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( emit(
AuthAuthenticated( AuthAuthenticated(
token: token, token: token,
@@ -197,6 +227,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
email: user['email'], email: user['email'],
), ),
); );
}
} catch (e) { } catch (e) {
final errorMessage = _extractErrorMessage(e); final errorMessage = _extractErrorMessage(e);
emit(AuthFailure(errorMessage)); emit(AuthFailure(errorMessage));
@@ -229,6 +260,21 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
final user = response['user']; final user = response['user'];
sessionBloc.add(SessionStarted(token)); sessionBloc.add(SessionStarted(token));
// 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( emit(
AuthAuthenticated( AuthAuthenticated(
token: token, token: token,
@@ -237,6 +283,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
email: user['email'], email: user['email'],
), ),
); );
}
} catch (e) { } catch (e) {
final errorMessage = _extractErrorMessage(e); final errorMessage = _extractErrorMessage(e);
emit(AuthFailure(errorMessage)); emit(AuthFailure(errorMessage));
@@ -258,8 +305,21 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
final sessionState = sessionBloc.state; final sessionState = sessionBloc.state;
if (sessionState is SessionActive) { if (sessionState is SessionActive) {
// Session already active - emit authenticated state with minimal info // Try to fetch full profile immediately so UI can show avatar/displayName
// The full user info will be fetched when needed 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( emit(
AuthAuthenticated( AuthAuthenticated(
token: sessionState.token, token: sessionState.token,
@@ -268,6 +328,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
email: '', email: '',
), ),
); );
}
} else { } else {
emit(AuthUnauthenticated()); emit(AuthUnauthenticated());
} }
@@ -277,9 +338,25 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
UpdateUserProfile event, UpdateUserProfile event,
Emitter<AuthState> emit, Emitter<AuthState> emit,
) async { ) async {
// For now, just update the local state - in real app, call API
if (state is AuthAuthenticated) { if (state is AuthAuthenticated) {
final currentState = state as AuthAuthenticated; final currentState = state as AuthAuthenticated;
// 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( emit(
AuthAuthenticated( AuthAuthenticated(
token: currentState.token, token: currentState.token,
@@ -291,4 +368,5 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
); );
} }
} }
}
} }

View File

@@ -400,9 +400,21 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
}); });
}, },
child: isAvatar child: isAvatar
? CircleAvatar( ? BlocBuilder<AuthBloc, AuthState>(
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, backgroundColor: isSelected ? highlightColor : defaultColor,
child: Icon(icon, color: AppTheme.primaryBackground), child: Icon(icon, color: AppTheme.primaryBackground),
);
},
) )
: Column( : Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,

View File

@@ -109,25 +109,40 @@ class _AccountSettingsDialogState extends State<AccountSettingsDialog> {
); );
setState(() { setState(() {
_avatarUrl = response['avatarUrl'] as String?;
_isUploadingAvatar = false; _isUploadingAvatar = false;
_avatarUploadProgress = 0.0; _avatarUploadProgress = 0.0;
}); });
if (_avatarUrl != null) { // Fetch latest profile from backend so we get the canonical avatar URL and version
try {
final apiClient = GetIt.I<ApiClient>();
final profile = await apiClient.getUserProfile();
final newAvatar = profile['avatarUrl'] as String?;
setState(() => _avatarUrl = newAvatar);
if (profile.isNotEmpty && mounted) {
final updatedUser = User.fromJson(profile);
context.read<AuthBloc>().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<AuthBloc>().state; final authState = context.read<AuthBloc>().state;
if (authState is AuthAuthenticated) { if (authState is AuthAuthenticated && _avatarUrl != null) {
_avatarUrl = "$_avatarUrl&token=${authState.token}"; _avatarUrl = "$_avatarUrl&token=${authState.token}";
} }
}
// Update auth state with new avatar
if (_currentUser != null && _avatarUrl != null) { if (_currentUser != null && _avatarUrl != null) {
final updatedUser = _currentUser!.copyWith(avatarUrl: _avatarUrl); final updatedUser = _currentUser!.copyWith(
avatarUrl: _avatarUrl,
);
if (mounted) { if (mounted) {
context.read<AuthBloc>().add(UpdateUserProfile(updatedUser)); context.read<AuthBloc>().add(UpdateUserProfile(updatedUser));
} }
} }
});
}
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -168,14 +183,13 @@ class _AccountSettingsDialogState extends State<AccountSettingsDialog> {
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
final apiClient = GetIt.I<ApiClient>(); final apiClient = GetIt.I<ApiClient>();
await apiClient.updateUserProfile( final response = await apiClient.updateUserProfile(
displayName: _displayNameController.text, displayName: _displayNameController.text,
); );
final updatedUser = _currentUser!.copyWith( // Use returned profile to update auth state (ensures avatarUrl + version are present)
displayName: _displayNameController.text, final updatedProfile = response;
avatarUrl: _avatarUrl, final updatedUser = User.fromJson(updatedProfile);
);
if (mounted) { if (mounted) {
// Update auth state // Update auth state

View File

@@ -3830,12 +3830,13 @@ func getUserProfileHandler(w http.ResponseWriter, r *http.Request, db *database.
AvatarURL *string `json:"avatarUrl"` AvatarURL *string `json:"avatarUrl"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
LastLoginAt *time.Time `json:"lastLoginAt"` LastLoginAt *time.Time `json:"lastLoginAt"`
UpdatedAt *time.Time `json:"-"`
} }
err = db.QueryRowContext(r.Context(), 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). 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 != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
errors.WriteError(w, errors.CodeNotFound, "User not found", http.StatusNotFound) 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 avatar exists, return the backend URL instead of the internal WebDAV URL
if user.AvatarURL != nil && *user.AvatarURL != "" { 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") w.Header().Set("Content-Type", "application/json")
@@ -3929,8 +3943,44 @@ func updateUserProfileHandler(w http.ResponseWriter, r *http.Request, db *databa
Metadata: metadata, 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) 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 // changePasswordHandler changes the current user's password
@@ -4020,14 +4070,14 @@ func uploadUserAvatarHandler(w http.ResponseWriter, r *http.Request, db *databas
return return
} }
// Generate unique filename // Generate deterministic filename based on user ID
ext := filepath.Ext(header.Filename) ext := filepath.Ext(header.Filename)
if ext == "" { 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/<user-id>.<ext>
client := storage.NewWebDAVClient(cfg) client := storage.NewWebDAVClient(cfg)
avatarPath := fmt.Sprintf(".avatars/%s", filename) avatarPath := fmt.Sprintf(".avatars/%s", filename)
err = client.Upload(r.Context(), avatarPath, bytes.NewReader(fileBytes), header.Size) 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 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) 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(), _, err = db.ExecContext(r.Context(),
`UPDATE users SET avatar_url = $1, updated_at = NOW() WHERE id = $2`, `UPDATE users SET avatar_url = $1, updated_at = NOW() WHERE id = $2`,
webdavURL, userID) 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") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Avatar uploaded successfully", "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 // getUserAvatarHandler serves the user's avatar image
func getUserAvatarHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, cfg *config.Config) { 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") 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 == "" { if tokenString == "" {
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized) errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return return