Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions RustRconServerManager.Backend/Controllers/AccountController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public IActionResult GetBasicInfo()
return Ok(new
{
Email = user.Email,
Nickname = user.Nickname,
DisplayName = user.DisplayName,
IsModerator = user.IsModerator,
isAdmin = user.isAdmin
});
Expand Down Expand Up @@ -94,7 +94,7 @@ public IActionResult AccountInformation()
Account_AccountInformationDTO dto = new Account_AccountInformationDTO();

dto.Email = accountInformation.Email;
dto.Nickname = accountInformation.Nickname;
dto.DisplayName = accountInformation.DisplayName;
dto.CreatedAt = accountInformation.CreatedAt;
dto.isAdmin = accountInformation.isAdmin;
dto.IsModerator = accountInformation.IsModerator;
Expand All @@ -104,8 +104,35 @@ public IActionResult AccountInformation()
return Ok(dto);
}

/// <summary>
/// Sets the current user's display name - shown across the panel (navbar, moderator
/// lists, audit log) instead of their email address. Required for every account.
/// </summary>
[HttpPut("DisplayName")]
public async Task<IActionResult> SetDisplayName([FromBody] Account_SetDisplayNameDTO dto)
{
var trimmed = dto.DisplayName?.Trim() ?? string.Empty;

if (string.IsNullOrEmpty(trimmed))
return BadRequest("Display name is required.");

if (trimmed.Length > 50)
return BadRequest("Display name must be 50 characters or fewer.");

var userEmail = User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value;

if (string.IsNullOrEmpty(userEmail))
return Unauthorized("No email claim found");

var user = await _dbContext.Users.FirstOrDefaultAsync(x => x.Email == userEmail);

if (user == null)
return NotFound();

user.DisplayName = trimmed;
await _dbContext.SaveChangesAsync();

return Ok(new { DisplayName = user.DisplayName });
}
}
}
6 changes: 6 additions & 0 deletions RustRconServerManager.Backend/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ public async Task<IActionResult> Setup(Authorization_SetupRequestDTO model)
return BadRequest(new { message = "Passwords do not match" });
}

if (string.IsNullOrWhiteSpace(model.DisplayName))
{
return BadRequest(new { message = "Display name is required" });
}

// Get or create default SystemProfile
var systemProfile = await _dbContext.SystemProfiles.FirstOrDefaultAsync();
if (systemProfile == null)
Expand Down Expand Up @@ -108,6 +113,7 @@ public async Task<IActionResult> Setup(Authorization_SetupRequestDTO model)
{
UserName = model.Email,
Email = model.Email,
DisplayName = model.DisplayName.Trim(),
SystemProfileId = systemProfile.Id,
isAdmin = true,
EmailConfirmed = true, // Auto-confirm email for initial admin
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public async Task<IActionResult> List()
{
Id = m.Id,
Username = m.UserName ?? string.Empty,
DisplayName = m.Nickname,
DisplayName = m.DisplayName,
Email = m.Email,
IsActive = !m.isLoginBlocked,
CreatedAt = m.CreatedAt,
Expand Down Expand Up @@ -117,7 +117,7 @@ public async Task<IActionResult> Create([FromBody] Moderator_CreateDTO dto)
{
UserName = dto.Username,
Email = dto.Email ?? $"{dto.Username}@moderator.local",
Nickname = dto.DisplayName,
DisplayName = dto.DisplayName,
SystemProfileId = profile.Id,
IsModerator = true,
isLoginBlocked = false,
Expand Down Expand Up @@ -150,7 +150,7 @@ public async Task<IActionResult> Create([FromBody] Moderator_CreateDTO dto)
{
Id = moderator.Id,
Username = moderator.UserName ?? string.Empty,
DisplayName = moderator.Nickname,
DisplayName = moderator.DisplayName,
Email = moderator.Email,
IsActive = !moderator.isLoginBlocked,
LastLoginAt = moderator.LastLoginAt,
Expand Down Expand Up @@ -204,7 +204,7 @@ public async Task<IActionResult> Update(string id, [FromBody] Moderator_UpdateDT
}

// Update moderator properties
moderator.Nickname = dto.DisplayName;
moderator.DisplayName = dto.DisplayName;
moderator.Email = dto.Email;
moderator.isLoginBlocked = !dto.IsActive;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ public async Task<IActionResult> PurgeData([FromBody] PurgeDataRequestDto reques
{
try
{
if (request.OlderThanDays < 1)
if (!request.Instant && request.OlderThanDays < 1)
return BadRequest(new { error = "OlderThanDays must be at least 1" });

var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
Expand All @@ -172,7 +172,9 @@ public async Task<IActionResult> PurgeData([FromBody] PurgeDataRequestDto reques
.Select(s => s.Id)
.ToListAsync();

var cutoff = DateTime.UtcNow.AddDays(-request.OlderThanDays);
// When Instant is set, cutoff is "now" so every existing record older-than check
// still holds true for anything already in the database.
var cutoff = request.Instant ? DateTime.UtcNow : DateTime.UtcNow.AddDays(-request.OlderThanDays);
int deleted = 0;

switch (request.Category)
Expand Down Expand Up @@ -217,8 +219,9 @@ public async Task<IActionResult> PurgeData([FromBody] PurgeDataRequestDto reques
return BadRequest(new { error = $"Unknown or non-purgeable category: {request.Category}" });
}

_logger.LogInformation("[PanelSettingsController] User {UserId} purged {Count} records from {Category} (older than {Days} days)",
userId, deleted, request.Category?.Replace("\r", "").Replace("\n", ""), request.OlderThanDays);
_logger.LogInformation("[PanelSettingsController] User {UserId} purged {Count} records from {Category} ({Scope})",
userId, deleted, request.Category?.Replace("\r", "").Replace("\n", ""),
request.Instant ? "instant - all records" : $"older than {request.OlderThanDays} days");

return Ok(new { deleted, category = request.Category });
}
Expand Down
71 changes: 71 additions & 0 deletions RustRconServerManager.Backend/Controllers/SecurityController.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RustRconServerManager.Backend.Database;
using RustRconServerManager.Backend.Extensions;
using RustRconServerManager.Backend.Models;
using RustRconServerManager.Backend.Helpers;
using RustRconServerManager.Shared.Security;
using System.Net.Mail;
using System.Text;
using System.Text.Encodings.Web;

Expand Down Expand Up @@ -78,6 +80,75 @@ public async Task<IActionResult> ChangePassword([FromBody] Security_ChangePasswo
}
}

[HttpPost("change-email")]
public async Task<IActionResult> ChangeEmail([FromBody] Security_ChangeEmailDTO dto)
{
try
{
var user = await User.GetUser(_dbContext);

if (string.IsNullOrWhiteSpace(dto.NewEmail) || !MailAddress.TryCreate(dto.NewEmail, out _))
{
return BadRequest("A valid email address is required.");
}

var passwordValid = await _userManager.CheckPasswordAsync(user, dto.CurrentPassword);
if (!passwordValid)
{
return BadRequest("Current password is incorrect.");
}

var existingUser = await _userManager.FindByEmailAsync(dto.NewEmail);
if (existingUser != null && existingUser.Id != user.Id)
{
return BadRequest("This email address is already in use.");
}

var setEmailResult = await _userManager.SetEmailAsync(user, dto.NewEmail);
if (!setEmailResult.Succeeded)
{
return BadRequest(string.Join(", ", setEmailResult.Errors.Select(e => e.Description)));
}

await _userManager.SetUserNameAsync(user, dto.NewEmail);
user.EmailConfirmed = true;
await _userManager.UpdateAsync(user);

// The current session's JWT still carries the OLD email claim, and
// SecurityBindingMiddleware re-resolves the user by that claim on every
// request (including the frontend's follow-up logout call and even loading
// the login page itself) - once the email no longer matches any user, that
// middleware would 401 all of those with a raw, unstyled error response
// instead of letting the app load. Revoke the session and clear the cookie
// here so the browser has no stale credential left to send.
var tokenHash = User.FindFirst(System.Security.Claims.ClaimTypes.Hash)?.Value;
if (!string.IsNullOrEmpty(tokenHash))
{
var userSession = await _dbContext.UserSessions
.FirstOrDefaultAsync(s => s.UserId == user.Id && s.SessionHash == tokenHash);
if (userSession != null)
{
userSession.IsRevoked = true;
await _dbContext.SaveChangesAsync();
}
}

Response.Cookies.Delete("rrsm_auth", new CookieOptions
{
HttpOnly = true,
Secure = Request.IsHttps,
SameSite = SameSiteMode.Strict,
Path = "/"
});

return Ok(new { message = "Email changed successfully. Please log in again with your new email address." });
}
catch (Exception ex)
{
return BadRequest(ApiErrorHelper.FormatError("Failed to change email", ex));
}
}

[HttpGet("2fa/status")]
public async Task<IActionResult> Get2FAStatus()
{
Expand Down
Loading
Loading