Add player onboarding and tactical viewer updates

This commit is contained in:
2026-04-06 17:12:44 -04:00
parent 706e1cda8f
commit 63a9f808bb
52 changed files with 2699 additions and 577 deletions

View File

@@ -0,0 +1,22 @@
using FastEndpoints;
using SpaceGame.Api.Universe.Bootstrap;
namespace SpaceGame.Api.Auth.Api;
public sealed class GetRacesHandler(IStaticDataProvider staticData) : EndpointWithoutRequest<IReadOnlyList<RaceSnapshot>>
{
public override void Configure()
{
Get("/api/auth/races");
AllowAnonymous();
}
public override async Task HandleAsync(CancellationToken cancellationToken)
{
var races = staticData.RaceDefinitions.Values
.OrderBy(race => race.Name, StringComparer.Ordinal)
.Select(race => new RaceSnapshot(race.Id, race.Name, race.Description, race.Icon))
.ToList();
await SendOkAsync(races, cancellationToken);
}
}

View File

@@ -2,7 +2,7 @@ using FastEndpoints;
namespace SpaceGame.Api.Auth.Api;
public sealed class RegisterHandler(AuthService authService) : Endpoint<RegisterRequest, AuthSessionResponse>
public sealed class RegisterHandler(AuthService authService) : Endpoint<RegisterRequest, RegisterResponse>
{
public override void Configure()
{

View File

@@ -37,6 +37,11 @@ public sealed record AuthSessionResponse(
string RefreshToken,
DateTimeOffset RefreshTokenExpiresAtUtc);
public sealed record RegisterResponse(
Guid UserId,
string Email,
bool RequiresLogin);
public sealed record ForgotPasswordResponse(
bool Accepted,
string? ResetToken = null);

View File

@@ -0,0 +1,7 @@
namespace SpaceGame.Api.Auth.Contracts;
public sealed record RaceSnapshot(
string Id,
string Name,
string Description,
string Icon);

View File

@@ -7,7 +7,7 @@ public sealed class AuthService(
RefreshTokenFactory refreshTokenFactory,
IPasswordResetDelivery passwordResetDelivery)
{
public async Task<AuthSessionResponse> RegisterAsync(RegisterRequest request, CancellationToken cancellationToken)
public async Task<RegisterResponse> RegisterAsync(RegisterRequest request, CancellationToken cancellationToken)
{
var email = NormalizeEmail(request.Email);
ValidatePassword(request.Password);
@@ -18,7 +18,7 @@ public sealed class AuthService(
}
var user = await authRepository.CreateUserAsync(email, passwordHasher.HashPassword(request.Password), [], cancellationToken);
return await CreateSessionAsync(user, cancellationToken);
return new RegisterResponse(user.Id, user.Email, true);
}
public async Task<AuthSessionResponse> LoginAsync(LoginRequest request, CancellationToken cancellationToken)

View File

@@ -5,6 +5,8 @@ namespace SpaceGame.Api.Auth.Simulation;
public sealed class HttpContextPlayerIdentityResolver(IHttpContextAccessor httpContextAccessor) : IPlayerIdentityResolver
{
public const string EffectivePlayerHeaderName = "X-Act-As-Player-Id";
public Guid? GetCurrentPlayerId()
{
var subject = httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)
@@ -15,6 +17,21 @@ public sealed class HttpContextPlayerIdentityResolver(IHttpContextAccessor httpC
public Guid GetRequiredPlayerId() =>
GetCurrentPlayerId() ?? throw new InvalidOperationException("Authenticated player identity is required.");
public Guid? GetEffectivePlayerId()
{
var currentPlayerId = GetCurrentPlayerId();
if (!CanAccessGm())
{
return currentPlayerId;
}
var requestedIdentity = httpContextAccessor.HttpContext?.Request.Headers[EffectivePlayerHeaderName].FirstOrDefault();
return Guid.TryParse(requestedIdentity, out var effectivePlayerId) ? effectivePlayerId : currentPlayerId;
}
public Guid GetRequiredEffectivePlayerId() =>
GetEffectivePlayerId() ?? throw new InvalidOperationException("Authenticated player identity is required.");
public bool CanAccessGm()
{
var user = httpContextAccessor.HttpContext?.User;

View File

@@ -4,6 +4,7 @@ public interface IAuthRepository
{
Task<UserAccount?> FindUserByEmailAsync(string email, CancellationToken cancellationToken);
Task<UserAccount?> FindUserByIdAsync(Guid userId, CancellationToken cancellationToken);
Task<IReadOnlyList<UserAccount>> ListUsersAsync(CancellationToken cancellationToken);
Task<UserAccount> CreateUserAsync(string email, string passwordHash, IReadOnlyCollection<string> roles, CancellationToken cancellationToken);
Task<UserAccount> UpsertUserAsync(string email, string passwordHash, IReadOnlyCollection<string> roles, CancellationToken cancellationToken);
Task StoreRefreshTokenAsync(Guid userId, string tokenHash, DateTimeOffset expiresAtUtc, CancellationToken cancellationToken);

View File

@@ -4,5 +4,7 @@ public interface IPlayerIdentityResolver
{
Guid? GetCurrentPlayerId();
Guid GetRequiredPlayerId();
Guid? GetEffectivePlayerId();
Guid GetRequiredEffectivePlayerId();
bool CanAccessGm();
}

View File

@@ -28,6 +28,23 @@ public sealed class PostgresAuthRepository(NpgsqlDataSource dataSource) : IAuthR
return await reader.ReadAsync(cancellationToken) ? ReadUser(reader) : null;
}
public async Task<IReadOnlyList<UserAccount>> ListUsersAsync(CancellationToken cancellationToken)
{
await using var command = dataSource.CreateCommand("""
select id, email, password_hash, created_at_utc, roles
from auth_users
order by email asc
""");
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
var users = new List<UserAccount>();
while (await reader.ReadAsync(cancellationToken))
{
users.Add(ReadUser(reader));
}
return users;
}
public async Task<UserAccount> CreateUserAsync(string email, string passwordHash, IReadOnlyCollection<string> roles, CancellationToken cancellationToken)
{
var userId = Guid.NewGuid();