Enhance user profile handling by fetching full profile data and updating avatar URLs in AuthBloc and related components
This commit is contained in:
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user