Add profile management (display name, email, password change, account deletion)
Full-stack profile section: DisplayName column on User model, profile CRUD endpoints on AuthController, and complete Settings > Profile tab UI with profile editing, password change, account info, and account deletion. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -43,4 +43,40 @@ public class AuthController : ControllerBase
|
||||
await _authService.RevokeRefreshTokenAsync(userId);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpGet("profile")]
|
||||
public async Task<ActionResult<UserProfileResponse>> GetProfile()
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
var profile = await _authService.GetProfileAsync(userId);
|
||||
return Ok(profile);
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpPut("profile")]
|
||||
public async Task<ActionResult<UserProfileResponse>> UpdateProfile(UpdateProfileRequest request)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
var profile = await _authService.UpdateProfileAsync(userId, request);
|
||||
return Ok(profile);
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpPost("change-password")]
|
||||
public async Task<ActionResult> ChangePassword(ChangePasswordRequest request)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
await _authService.ChangePasswordAsync(userId, request);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpDelete("account")]
|
||||
public async Task<ActionResult> DeleteAccount()
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
await _authService.DeleteAccountAsync(userId);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,54 @@ public class AuthService : IAuthService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<UserProfileResponse> GetProfileAsync(Guid userId)
|
||||
{
|
||||
var user = await _db.Users.FindAsync(userId)
|
||||
?? throw new KeyNotFoundException("User not found");
|
||||
|
||||
return new UserProfileResponse(user.Username, user.DisplayName, user.Email, user.CreatedAt, user.LastLoginAt);
|
||||
}
|
||||
|
||||
public async Task<UserProfileResponse> UpdateProfileAsync(Guid userId, UpdateProfileRequest request)
|
||||
{
|
||||
var user = await _db.Users.FindAsync(userId)
|
||||
?? throw new KeyNotFoundException("User not found");
|
||||
|
||||
if (request.DisplayName != null)
|
||||
user.DisplayName = request.DisplayName;
|
||||
|
||||
if (request.Email != null && request.Email != user.Email)
|
||||
{
|
||||
if (await _db.Users.AnyAsync(u => u.Email == request.Email && u.Id != userId))
|
||||
throw new InvalidOperationException("Email already in use");
|
||||
user.Email = request.Email;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return new UserProfileResponse(user.Username, user.DisplayName, user.Email, user.CreatedAt, user.LastLoginAt);
|
||||
}
|
||||
|
||||
public async Task ChangePasswordAsync(Guid userId, ChangePasswordRequest request)
|
||||
{
|
||||
var user = await _db.Users.FindAsync(userId)
|
||||
?? throw new KeyNotFoundException("User not found");
|
||||
|
||||
if (!BCrypt.Net.BCrypt.Verify(request.CurrentPassword, user.PasswordHash))
|
||||
throw new InvalidOperationException("Current password is incorrect");
|
||||
|
||||
user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.NewPassword);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task DeleteAccountAsync(Guid userId)
|
||||
{
|
||||
var user = await _db.Users.FindAsync(userId)
|
||||
?? throw new KeyNotFoundException("User not found");
|
||||
|
||||
_db.Users.Remove(user);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private AuthResponse GenerateAuthResponse(User user)
|
||||
{
|
||||
var expiresAt = DateTime.UtcNow.AddHours(12);
|
||||
@@ -92,7 +140,7 @@ public class AuthService : IAuthService
|
||||
user.RefreshTokenExpiresAt = DateTime.UtcNow.AddDays(30);
|
||||
_db.SaveChanges();
|
||||
|
||||
return new AuthResponse(token, refreshToken, expiresAt, user.Username);
|
||||
return new AuthResponse(token, refreshToken, expiresAt, user.Username, user.DisplayName);
|
||||
}
|
||||
|
||||
private string GenerateJwtToken(User user, DateTime expiresAt)
|
||||
|
||||
@@ -2,5 +2,16 @@ namespace Purrse.Core.DTOs;
|
||||
|
||||
public record RegisterRequest(string Username, string Email, string Password);
|
||||
public record LoginRequest(string Username, string Password);
|
||||
public record AuthResponse(string Token, string RefreshToken, DateTime ExpiresAt, string Username);
|
||||
public record AuthResponse(string Token, string RefreshToken, DateTime ExpiresAt, string Username, string? DisplayName);
|
||||
public record RefreshRequest(string RefreshToken);
|
||||
|
||||
public record UserProfileResponse(
|
||||
string Username,
|
||||
string? DisplayName,
|
||||
string Email,
|
||||
DateTime CreatedAt,
|
||||
DateTime? LastLoginAt);
|
||||
|
||||
public record UpdateProfileRequest(string? DisplayName, string? Email);
|
||||
|
||||
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
|
||||
|
||||
@@ -8,4 +8,8 @@ public interface IAuthService
|
||||
Task<AuthResponse> LoginAsync(LoginRequest request);
|
||||
Task<AuthResponse> RefreshTokenAsync(string refreshToken);
|
||||
Task RevokeRefreshTokenAsync(Guid userId);
|
||||
Task<UserProfileResponse> GetProfileAsync(Guid userId);
|
||||
Task<UserProfileResponse> UpdateProfileAsync(Guid userId, UpdateProfileRequest request);
|
||||
Task ChangePasswordAsync(Guid userId, ChangePasswordRequest request);
|
||||
Task DeleteAccountAsync(Guid userId);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ public class User
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string? DisplayName { get; set; }
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string PasswordHash { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
@@ -11,6 +11,7 @@ public class UserConfiguration : IEntityTypeConfiguration<User>
|
||||
builder.ToTable("users");
|
||||
builder.HasKey(u => u.Id);
|
||||
builder.Property(u => u.Username).HasMaxLength(100).IsRequired();
|
||||
builder.Property(u => u.DisplayName).HasMaxLength(100);
|
||||
builder.Property(u => u.Email).HasMaxLength(255).IsRequired();
|
||||
builder.Property(u => u.PasswordHash).HasMaxLength(255).IsRequired();
|
||||
builder.HasIndex(u => u.Username).IsUnique();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Purrse.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDisplayName : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "DisplayName",
|
||||
table: "users",
|
||||
type: "character varying(100)",
|
||||
maxLength: 100,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DisplayName",
|
||||
table: "users");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -998,6 +998,10 @@ namespace Purrse.Data.Migrations
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
|
||||
Reference in New Issue
Block a user