all the things

This commit is contained in:
2026-01-14 22:04:37 -06:00
parent a3f23b199a
commit 114025fcfb
13 changed files with 233 additions and 99 deletions

View File

@@ -2,17 +2,20 @@
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Shogi.BackEnd.Identity; using Shogi.BackEnd.Identity;
using Shogi.BackEnd.Repositories;
using System.Security.Claims; using System.Security.Claims;
namespace Shogi.BackEnd.Controllers; namespace Shogi.BackEnd.Controllers;
[Authorize] [Authorize]
[Route("[controller]")] [Route("backend/[controller]")]
[ApiController] [ApiController]
public class AccountController( public class AccountController(
SignInManager<ShogiUser> signInManager, SignInManager<ShogiUser> signInManager,
UserManager<ShogiUser> UserManager, UserManager<ShogiUser> UserManager,
IConfiguration configuration) : ControllerBase IConfiguration configuration,
SessionRepository sessionRepository,
QueryRepository queryRepository) : ControllerBase
{ {
[Authorize("Admin")] [Authorize("Admin")]
[HttpPost("TestAccount")] [HttpPost("TestAccount")]
@@ -36,7 +39,31 @@ public class AccountController(
return this.Created(); return this.Created();
} }
[HttpPost("/logout")] [Authorize("Admin")]
[HttpDelete("TestAccount")]
public async Task<IActionResult> DeleteTestAccounts()
{
var testUsers = new[] { "aat-account", "aat-account-2" };
foreach (var username in testUsers)
{
var user = await UserManager.FindByNameAsync(username);
if (user != null)
{
var sessions = await queryRepository.ReadSessionsMetadata(user.Id);
foreach (var session in sessions)
{
await sessionRepository.DeleteSession(session.Id);
}
await UserManager.DeleteAsync(user);
}
}
return this.Ok();
}
[HttpPost("/backend/logout")]
public async Task<IActionResult> Logout([FromBody] object empty) public async Task<IActionResult> Logout([FromBody] object empty)
{ {
// https://learn.microsoft.com/aspnet/core/blazor/security/webassembly/standalone-with-identity#antiforgery-support // https://learn.microsoft.com/aspnet/core/blazor/security/webassembly/standalone-with-identity#antiforgery-support
@@ -50,7 +77,7 @@ public class AccountController(
return this.Unauthorized(); return this.Unauthorized();
} }
[HttpGet("/roles")] [HttpGet("/backend/roles")]
public IActionResult GetRoles() public IActionResult GetRoles()
{ {
if (this.User.Identity is not null && this.User.Identity.IsAuthenticated) if (this.User.Identity is not null && this.User.Identity.IsAuthenticated)

View File

@@ -47,7 +47,7 @@ public static class MyIdentityApiEndpointRouteBuilderExtensions
// We'll figure out a unique endpoint name based on the final route pattern during endpoint generation. // We'll figure out a unique endpoint name based on the final route pattern during endpoint generation.
string? confirmEmailEndpointName = null; string? confirmEmailEndpointName = null;
var routeGroup = endpoints.MapGroup(""); var routeGroup = endpoints.MapGroup("backend");
// NOTE: We cannot inject UserManager<TUser> directly because the TUser generic parameter is currently unsupported by RDG. // NOTE: We cannot inject UserManager<TUser> directly because the TUser generic parameter is currently unsupported by RDG.
// https://github.com/dotnet/aspnetcore/issues/47338 // https://github.com/dotnet/aspnetcore/issues/47338

View File

@@ -9,7 +9,7 @@ namespace Shogi.BackEnd.Controllers;
[Authorize] [Authorize]
[ApiController] [ApiController]
[Route("[controller]")] [Route("backend/[controller]")]
public class SessionsController( public class SessionsController(
SessionRepository sessionRepository, SessionRepository sessionRepository,
ShogiApplication application) : ControllerBase ShogiApplication application) : ControllerBase

View File

@@ -10,7 +10,11 @@ using System.Text.Json;
/// <summary> /// <summary>
/// Handles state for cookie-based auth. /// Handles state for cookie-based auth.
/// </summary> /// </summary>
public class CookieAuthenticationStateProvider : AuthenticationStateProvider, IAccountManagement /// <remarks>
/// Create a new instance of the auth provider.
/// </remarks>
/// <param name="httpClientFactory">Factory to retrieve auth client.</param>
public class CookieAuthenticationStateProvider(IHttpClientFactory httpClientFactory) : AuthenticationStateProvider, IAccountManagement
{ {
/// <summary> /// <summary>
/// Map the JavaScript-formatted properties to C#-formatted classes. /// Map the JavaScript-formatted properties to C#-formatted classes.
@@ -23,12 +27,12 @@ public class CookieAuthenticationStateProvider : AuthenticationStateProvider, IA
/// <summary> /// <summary>
/// Special auth client. /// Special auth client.
/// </summary> /// </summary>
private readonly HttpClient _httpClient; private readonly HttpClient httpClient = httpClientFactory.CreateClient("Auth");
/// <summary> /// <summary>
/// Authentication state. /// Authentication state.
/// </summary> /// </summary>
private bool _authenticated = false; private bool authenticated = false;
/// <summary> /// <summary>
/// Default principal for anonymous (not authenticated) users. /// Default principal for anonymous (not authenticated) users.
@@ -36,13 +40,6 @@ public class CookieAuthenticationStateProvider : AuthenticationStateProvider, IA
private readonly ClaimsPrincipal Unauthenticated = private readonly ClaimsPrincipal Unauthenticated =
new(new ClaimsIdentity()); new(new ClaimsIdentity());
/// <summary>
/// Create a new instance of the auth provider.
/// </summary>
/// <param name="httpClientFactory">Factory to retrieve auth client.</param>
public CookieAuthenticationStateProvider(IHttpClientFactory httpClientFactory)
=> _httpClient = httpClientFactory.CreateClient("Auth");
/// <summary> /// <summary>
/// Register a new user. /// Register a new user.
/// </summary> /// </summary>
@@ -57,7 +54,7 @@ public class CookieAuthenticationStateProvider : AuthenticationStateProvider, IA
try try
{ {
// make the request // make the request
var result = await _httpClient.PostAsJsonAsync("register", new var result = await httpClient.PostAsJsonAsync("backend/register", new
{ {
email, email,
password password
@@ -118,7 +115,7 @@ public class CookieAuthenticationStateProvider : AuthenticationStateProvider, IA
try try
{ {
// login with cookies // login with cookies
var result = await _httpClient.PostAsJsonAsync("login?useCookies=true", new var result = await httpClient.PostAsJsonAsync("backend/login?useCookies=true", new
{ {
email, email,
password password
@@ -154,7 +151,7 @@ public class CookieAuthenticationStateProvider : AuthenticationStateProvider, IA
/// <returns>The authentication state asynchronous request.</returns> /// <returns>The authentication state asynchronous request.</returns>
public override async Task<AuthenticationState> GetAuthenticationStateAsync() public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{ {
_authenticated = false; authenticated = false;
// default to not authenticated // default to not authenticated
var user = Unauthenticated; var user = Unauthenticated;
@@ -162,7 +159,7 @@ public class CookieAuthenticationStateProvider : AuthenticationStateProvider, IA
try try
{ {
// the user info endpoint is secured, so if the user isn't logged in this will fail // the user info endpoint is secured, so if the user isn't logged in this will fail
var userResponse = await _httpClient.GetAsync("manage/info"); var userResponse = await httpClient.GetAsync("backend/manage/info");
// throw if user info wasn't retrieved // throw if user info wasn't retrieved
userResponse.EnsureSuccessStatusCode(); userResponse.EnsureSuccessStatusCode();
@@ -187,7 +184,7 @@ public class CookieAuthenticationStateProvider : AuthenticationStateProvider, IA
.Select(c => new Claim(c.Key, c.Value))); .Select(c => new Claim(c.Key, c.Value)));
// tap the roles endpoint for the user's roles // tap the roles endpoint for the user's roles
var rolesResponse = await _httpClient.GetAsync("roles"); var rolesResponse = await httpClient.GetAsync("backend/roles");
// throw if request fails // throw if request fails
rolesResponse.EnsureSuccessStatusCode(); rolesResponse.EnsureSuccessStatusCode();
@@ -213,7 +210,7 @@ public class CookieAuthenticationStateProvider : AuthenticationStateProvider, IA
// set the principal // set the principal
var id = new ClaimsIdentity(claims, nameof(CookieAuthenticationStateProvider)); var id = new ClaimsIdentity(claims, nameof(CookieAuthenticationStateProvider));
user = new ClaimsPrincipal(id); user = new ClaimsPrincipal(id);
_authenticated = true; authenticated = true;
} }
} }
catch { } catch { }
@@ -226,14 +223,14 @@ public class CookieAuthenticationStateProvider : AuthenticationStateProvider, IA
{ {
const string Empty = "{}"; const string Empty = "{}";
var emptyContent = new StringContent(Empty, Encoding.UTF8, "application/json"); var emptyContent = new StringContent(Empty, Encoding.UTF8, "application/json");
await _httpClient.PostAsync("logout", emptyContent); await httpClient.PostAsync("backend/logout", emptyContent);
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
} }
public async Task<bool> CheckAuthenticatedAsync() public async Task<bool> CheckAuthenticatedAsync()
{ {
await GetAuthenticationStateAsync(); await GetAuthenticationStateAsync();
return _authenticated; return authenticated;
} }
/// <summary> /// <summary>
@@ -242,7 +239,7 @@ public class CookieAuthenticationStateProvider : AuthenticationStateProvider, IA
/// <remarks>Do not surface errors from this to users which may tell bad actors if emails do or do not exist in the system.</remarks> /// <remarks>Do not surface errors from this to users which may tell bad actors if emails do or do not exist in the system.</remarks>
public async Task<HttpResponseMessage> RequestPasswordReset(string email) public async Task<HttpResponseMessage> RequestPasswordReset(string email)
{ {
return await _httpClient.PostAsJsonAsync("forgotPassword", new { email }); return await httpClient.PostAsJsonAsync("backend/forgotPassword", new { email });
} }
public async Task<FormResult> ChangePassword(string email, string resetCode, string newPassword) public async Task<FormResult> ChangePassword(string email, string resetCode, string newPassword)
@@ -253,7 +250,7 @@ public class CookieAuthenticationStateProvider : AuthenticationStateProvider, IA
resetCode, resetCode,
newPassword newPassword
}; };
var response = await _httpClient.PostAsJsonAsync("resetPassword", body); var response = await httpClient.PostAsJsonAsync("backend/resetPassword", body);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
return new FormResult { Succeeded = true }; return new FormResult { Succeeded = true };

View File

@@ -3,7 +3,6 @@
@* Desktop view *@ @* Desktop view *@
<nav class="NavMenu PrimaryTheme ThemeVariant--Contrast"> <nav class="NavMenu PrimaryTheme ThemeVariant--Contrast">
<h1>Shogi</h1>
<a href="">Home</a> <a href="">Home</a>
<a href="search">Search</a> <a href="search">Search</a>
@@ -12,7 +11,8 @@
<button class="href" @onclick="CreateSession">Create</button> <button class="href" @onclick="CreateSession">Create</button>
</AuthorizeView> </AuthorizeView>
<div class="spacer" /> <h1>Shogi</h1>
@* <div class="spacer" /> *@
<AuthorizeView> <AuthorizeView>
<Authorized> <Authorized>

View File

@@ -1,8 +1,9 @@
.NavMenu { .NavMenu {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 0.75rem; justify-content: center;
padding: 0 0.5rem; gap: 2rem;
padding: 0 2rem;
} }
.NavMenu .spacer { .NavMenu .spacer {

View File

@@ -4,32 +4,66 @@
@using System.Text @using System.Text
<main class="shogi PrimaryTheme"> <main class="shogi PrimaryTheme">
<h2>What is Shogi?</h2> <div class="shogi-toc">
<h3>Table of Contents</h3>
<ul>
<li><a href="#what-is-shogi">What is Shogi?</a></li>
<li>
<a href="#how-to-play">How to Play</a>
<ul>
<li><a href="#setup">Setup</a></li>
<li><a href="#pieces-and-movement">Pieces and Movement</a></li>
<li><a href="#promotion">Promotion</a></li>
<li><a href="#capturing-and-the-hand">Capturing and the Hand</a></li>
<li><a href="#check">The King and "Check"</a></li>
<li><a href="#victory">Victory</a></li>
</ul>
</li>
</ul>
</div>
<div class="shogi-content">
<h2 id="what-is-shogi">What is Shogi?</h2>
<p>Shogi is a two-player strategy game where each player simultaneously protects their king while capturing their opponent's.</p> <p>Shogi is a two-player strategy game where each player simultaneously protects their king while capturing their opponent's.</p>
<p>Players take turns, moving one piece each turn until check-mate is achieved.</p> <p>Players take turns, moving one piece each turn until check-mate is achieved.</p>
<h2>How to Play</h2> <h2 id="how-to-play">How to Play</h2>
<h3>Setup</h3> <h3 id="setup">Setup</h3>
<p>Arrange the board so it looks like this. Take note of the Rook and Bishop positions for each player.</p> <p>Arrange the board so it looks like this. Take note of the Rook and Bishop positions for each player.</p>
<BoardSetupVisualAid /> <BoardSetupVisualAid />
<!-- Margin top is because chromium browsers do not render nested grids the same as Firefox --> <!-- Margin top is because chromium browsers do not render nested grids the same as Firefox -->
<h3 style="margin-top: 2rem">Pieces and Movement</h3> <h3 id="pieces-and-movement" style="margin-top: 2rem">Pieces and Movement</h3>
<p>Each piece has a unique set of moves. Some pieces, like the Pawn, may move only one tile per turn. Other pieces, like the Bishop, may move multiple tiles per turn.</p> <p>Each piece has a unique set of moves. Some pieces, like the Pawn, may move only one tile per turn. Other pieces, like the Bishop, may move multiple tiles per turn.</p>
<p>A tile may only hold one piece and, except for the Knight, pieces may never move through each other.</p> <p>A tile may only hold one piece and, except for the Knight, pieces may never move through each other.</p>
<p>Should your piece enter the tile of an opponent's piece, you must stop there and capture the opponent's piece.</p> <p>Should your piece enter the tile of an opponent's piece, you must stop there and capture the opponent's piece.</p>
<PieceMovesVisualAid /> <PieceMovesVisualAid />
<h3>Promotion</h3> <h3 id="promotion">Promotion</h3>
<p>The furthest three ranks from your starting position is an area called the <b>promotion zone</b>. A piece may promote at the end of the turn when it moves in to, out of, or within the promotion zone.</p> <p>The furthest three ranks from your starting position is an area called the <b>promotion zone</b>. A piece may promote at the end of the turn when it moves in to, out of, or within the promotion zone.</p>
<p>Promoting changes the move-set available to the peice, and a piece <em>must promote</em> if it has no legal, future moves. An example of this is a Pawn moving the the furthest rank on the board such that it cannot go further. In this case, the Pawn must promote.</p> <p>Promoting changes the move-set available to the peice, and a piece <em>must promote</em> if it has no legal, future moves. An example of this is a Pawn moving the the furthest rank on the board such that it cannot go further. In this case, the Pawn must promote.</p>
<p>All pieces may promote <b>except for</b> the Gold General and King.</p> <p>All pieces may promote <b>except for</b> the Gold General and King.</p>
<PromotedPieceVisualAid /> <PromotedPieceVisualAid />
<h3>Capturing and the Hand</h3> <h3 id="capturing-and-the-hand">Capturing and the Hand</h3>
<h3>The King and "Check"</h3> <p>When you capture an opponent's piece, it becomes yours. It is placed in your "hand" and can be returned to the board later.</p>
<h3>Victory</h3> <p>On any turn, instead of moving a piece, you may "drop" a captured piece onto any empty tile. This makes Shogi very dynamic as the board can change rapidly.</p>
<p>There are rules for drops:</p>
<ul>
<li>Dropped pieces always start unpromoted.</li>
<li>You may not drop a piece where it has no future moves (like a Pawn on the last rank).</li>
<li>You cannot drop a Pawn into a column that already has another of your unpromoted Pawns.</li>
<li>You cannot drop a Pawn to deliver immediate checkmate.</li>
</ul>
<h3 id="check">The King and "Check"</h3>
<p>If your King is under attack, it is in "check". You must make a move to protect the King immediately.</p>
<p>You can move the King to safety, capture the attacking piece, or block the attack with another piece.</p>
<h3 id="victory">Victory</h3>
<p>The goal is to capture the opponent's King. If the King is in check and cannot escape, it is "checkmate" and you win.</p>
</div>
</main> </main>
@code { @code {

View File

@@ -2,6 +2,73 @@
background-color: var(--primary-color); background-color: var(--primary-color);
color: white; color: white;
padding: 1rem; padding: 1rem;
margin: auto;
width: 100%;
max-width: 80ch;
padding-top: 0; padding-top: 0;
overflow: auto; }
.shogi-toc {
display: none;
}
.shogi-content h2 {
text-align: center;
border-bottom: 1px solid white;
margin-bottom: 1.5rem;
}
@media (min-width: 1025px) {
.shogi {
max-width: 140ch;
display: grid;
grid-template-columns: 250px 1fr;
gap: 4rem;
align-items: start;
}
.shogi-toc {
display: block;
position: sticky;
top: 2rem;
max-height: calc(100vh - 4rem);
overflow-y: auto;
}
.shogi-toc h3 {
margin-top: 0;
font-size: 1.25rem;
border-bottom: 1px solid rgba(255,255,255,0.3);
padding-bottom: 0.5rem;
margin-bottom: 1rem;
}
.shogi-toc ul {
list-style: none;
padding-left: 0;
}
.shogi-toc > ul > li {
margin-bottom: 0.75rem;
font-weight: bold;
}
.shogi-toc ul ul {
padding-left: 1rem;
margin-top: 0.25rem;
font-weight: normal;
font-size: 0.9em;
}
.shogi-toc a {
color: rgba(255,255,255,0.8);
text-decoration: none;
display: block;
padding: 0.1rem 0;
}
.shogi-toc a:hover {
color: white;
text-decoration: underline;
}
} }

View File

@@ -22,17 +22,21 @@
<LanceMoves /> <LanceMoves />
</div> </div>
</section>
<section>
<div class="moves"> <div class="moves">
<h5>Knight</h5> <h5>Knight</h5>
<KnightMoves /> <KnightMoves />
</div> </div>
</section>
<section>
<div class="moves"> <div class="moves">
<h5>Silver General</h5> <h5>Silver General</h5>
<SilverMoves /> <SilverMoves />
</div> </div>
</section>
<section>
<div class="moves"> <div class="moves">
<h5>Gold General</h5> <h5>Gold General</h5>

View File

@@ -21,14 +21,14 @@
<h5>Promoted Lance</h5> <h5>Promoted Lance</h5>
<PromotedLanceMoves /> <PromotedLanceMoves />
</div> </div>
</section>
<section>
<div class="moves"> <div class="moves">
<h5>Promoted Knight</h5> <h5>Promoted Knight</h5>
<PromotedKnightMoves /> <PromotedKnightMoves />
</div> </div>
</section>
<section>
<div class="moves"> <div class="moves">
<h5>Promoted Silver General</h5> <h5>Promoted Silver General</h5>
<PromotedSilverMoves /> <PromotedSilverMoves />

View File

@@ -25,7 +25,7 @@
} }
<label for="email" style="grid-area: emailLabel">Email</label> <label for="email" style="grid-area: emailLabel">Email</label>
<input required id="email" name="emailInput" type="email" style="grid-area: emailControl" @bind-value="email" readonly="@isEmailSubmitted" /> <input required id="email" name="emailInput" type="text" style="grid-area: emailControl" @bind-value="email" readonly="@isEmailSubmitted" />
@if (isEmailSubmitted) @if (isEmailSubmitted)
{ {

View File

@@ -4,11 +4,11 @@ using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Shogi; using Shogi;
using Shogi.BackEnd.Application; using Shogi.BackEnd.Application;
using Shogi.FrontEnd.Components;
using Shogi.BackEnd.Controllers; using Shogi.BackEnd.Controllers;
using Shogi.BackEnd.Identity; using Shogi.BackEnd.Identity;
using Shogi.BackEnd.Repositories; using Shogi.BackEnd.Repositories;
using Shogi.FrontEnd.Client; using Shogi.FrontEnd.Client;
using Shogi.FrontEnd.Components;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);

View File

@@ -82,6 +82,10 @@
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.1" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.1" /> <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.0" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.0" />
<PackageReference Include="System.Data.SqlClient" Version="4.9.0" /> <PackageReference Include="System.Data.SqlClient" Version="4.9.0" />
</ItemGroup> </ItemGroup>