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 '../../services/api_client.dart';
import '../../models/api_error.dart';
import '../../models/user.dart';
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final ApiClient apiClient;
@@ -109,14 +110,29 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
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<AuthEvent, AuthState> {
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<AuthEvent, AuthState> {
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<AuthEvent, AuthState> {
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<AuthEvent, AuthState> {
UpdateUserProfile event,
Emitter<AuthState> 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,
),
);
}
}
}
}

View File

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

View File

@@ -109,24 +109,39 @@ class _AccountSettingsDialogState extends State<AccountSettingsDialog> {
);
setState(() {
_avatarUrl = response['avatarUrl'] as String?;
_isUploadingAvatar = false;
_avatarUploadProgress = 0.0;
});
if (_avatarUrl != null) {
final authState = context.read<AuthBloc>().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<ApiClient>();
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<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;
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<AuthBloc>().add(UpdateUserProfile(updatedUser));
}
}
});
}
if (mounted) {
@@ -168,14 +183,13 @@ class _AccountSettingsDialogState extends State<AccountSettingsDialog> {
setState(() => _isLoading = true);
try {
final apiClient = GetIt.I<ApiClient>();
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

View File

@@ -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/<user-id>.<ext>
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