reintroduce microsoft login. upgrade a bunch of stuff.

This commit is contained in:
2023-01-19 16:20:41 -06:00
parent 2a423bcb93
commit 1d0beaf69f
29 changed files with 601 additions and 483 deletions

View File

@@ -38,23 +38,8 @@ public class UserController : ControllerBase
}; };
} }
[HttpPut("GuestLogout")]
public async Task<IActionResult> GuestLogout()
{
var signoutTask = HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
var userId = User?.GetGuestUserId();
if (!string.IsNullOrEmpty(userId))
{
connectionManager.Unsubscribe(userId);
}
await signoutTask;
return Ok();
}
[HttpGet("Token")] [HttpGet("Token")]
public ActionResult<CreateTokenResponse> GetToken() public ActionResult<CreateTokenResponse> GetWebSocketToken()
{ {
var userId = User.GetShogiUserId(); var userId = User.GetShogiUserId();
var displayName = User.DisplayName(); var displayName = User.DisplayName();
@@ -83,4 +68,19 @@ public class UserController : ControllerBase
} }
return Ok(); return Ok();
} }
[HttpPut("GuestLogout")]
public async Task<IActionResult> GuestLogout()
{
var signOutTask = HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
var userId = User?.GetGuestUserId();
if (!string.IsNullOrEmpty(userId))
{
connectionManager.Unsubscribe(userId);
}
await signOutTask;
return Ok();
}
} }

View File

@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Http.Json; using Microsoft.AspNetCore.Http.Json;
using Microsoft.AspNetCore.HttpLogging; using Microsoft.AspNetCore.HttpLogging;
using Microsoft.Identity.Web;
using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Models;
using Shogi.Api.Managers; using Shogi.Api.Managers;
using Shogi.Api.Repositories; using Shogi.Api.Repositories;
@@ -119,9 +120,9 @@ namespace Shogi.Api
static void AddJwtAuth(WebApplicationBuilder builder) static void AddJwtAuth(WebApplicationBuilder builder)
{ {
//builder.Services builder.Services
// .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
//.AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd")); .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"));
} }
static void AddCookieAuth(WebApplicationBuilder builder) static void AddCookieAuth(WebApplicationBuilder builder)

View File

@@ -7,41 +7,46 @@ namespace Shogi.Api.Repositories;
public class UserRepository : IUserRepository public class UserRepository : IUserRepository
{ {
private readonly string connectionString; private readonly string connectionString;
public UserRepository(IConfiguration configuration) public UserRepository(IConfiguration configuration)
{ {
connectionString = configuration.GetConnectionString("ShogiDatabase"); var connectionString = configuration.GetConnectionString("ShogiDatabase");
} if (string.IsNullOrEmpty(connectionString))
{
throw new InvalidOperationException("Connection string for database is empty.");
}
this.connectionString = connectionString;
}
public async Task CreateUser(User user) public async Task CreateUser(User user)
{ {
using var connection = new SqlConnection(connectionString); using var connection = new SqlConnection(connectionString);
await connection.ExecuteAsync( await connection.ExecuteAsync(
"user.CreateUser", "user.CreateUser",
new new
{ {
Name = user.Id, Name = user.Id,
DisplayName = user.DisplayName, DisplayName = user.DisplayName,
Platform = user.LoginPlatform.ToString() Platform = user.LoginPlatform.ToString()
}, },
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
} }
public async Task<User?> ReadUser(string id) public async Task<User?> ReadUser(string id)
{ {
using var connection = new SqlConnection(connectionString); using var connection = new SqlConnection(connectionString);
var results = await connection.QueryAsync<User>( var results = await connection.QueryAsync<User>(
"user.ReadUser", "user.ReadUser",
new { Name = id }, new { Name = id },
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
return results.FirstOrDefault(); return results.FirstOrDefault();
} }
} }
public interface IUserRepository public interface IUserRepository
{ {
Task CreateUser(User user); Task CreateUser(User user);
Task<User?> ReadUser(string id); Task<User?> ReadUser(string id);
} }

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<EnableNETAnalyzers>true</EnableNETAnalyzers> <EnableNETAnalyzers>true</EnableNETAnalyzers>
<AnalysisLevel>5</AnalysisLevel> <AnalysisLevel>5</AnalysisLevel>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
@@ -24,12 +24,13 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.2.2" /> <PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.2.2" />
<PackageReference Include="Azure.Identity" Version="1.6.1" /> <PackageReference Include="Azure.Identity" Version="1.8.1" />
<PackageReference Include="Dapper" Version="2.0.123" /> <PackageReference Include="Dapper" Version="2.0.123" />
<PackageReference Include="FluentValidation" Version="11.2.0" /> <PackageReference Include="FluentValidation" Version="11.4.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.8" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="6.0.8" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="7.0.2" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" /> <PackageReference Include="Microsoft.Identity.Web" Version="1.25.10" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="System.Data.SqlClient" Version="4.8.5" /> <PackageReference Include="System.Data.SqlClient" Version="4.8.5" />
</ItemGroup> </ItemGroup>

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<EnableNETAnalyzers>true</EnableNETAnalyzers> <EnableNETAnalyzers>true</EnableNETAnalyzers>
<AnalysisLevel>5</AnalysisLevel> <AnalysisLevel>5</AnalysisLevel>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>disable</ImplicitUsings> <ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>

View File

@@ -3,7 +3,7 @@
<Router AppAssembly="@typeof(App).Assembly"> <Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData"> <Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" /> <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"> @*<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
<Authorizing> <Authorizing>
Authorizing!! Authorizing!!
</Authorizing> </Authorizing>
@@ -17,7 +17,7 @@
<p role="alert">You are not authorized to access this resource.</p> <p role="alert">You are not authorized to access this resource.</p>
} }
</NotAuthorized> </NotAuthorized>
</AuthorizeRouteView> </AuthorizeRouteView>*@
<FocusOnNavigate RouteData="@routeData" Selector="h1" /> <FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found> </Found>
<NotFound> <NotFound>

View File

@@ -1,7 +1,10 @@
@page "/authentication/{action}" @page "/authentication/{action}"
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication @using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@*<RemoteAuthenticatorView Action="@Action" />*@
<RemoteAuthenticatorView Action="@Action" />
@code{ @code{
[Parameter] public string? Action { get; set; } [Parameter] public string? Action { get; set; }
// https://github.com/dotnet/aspnetcore/blob/main/src/Components/WebAssembly/WebAssembly.Authentication/src/Models/RemoteAuthenticationActions.cs
// https://github.com/dotnet/aspnetcore/blob/7c810658463f35c39c54d5fb8a8dbbfd463bf747/src/Components/WebAssembly/WebAssembly.Authentication/src/RemoteAuthenticatorViewCore.cs
} }

View File

@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Shogi.UI.Pages.Home.Api; using Shogi.UI.Pages.Home.Api;
using Shogi.UI.Shared; using Shogi.UI.Shared;
@@ -7,132 +8,144 @@ namespace Shogi.UI.Pages.Home.Account;
public class AccountManager public class AccountManager
{ {
private readonly AccountState accountState; private readonly AccountState accountState;
private readonly IShogiApi shogiApi; private readonly IShogiApi shogiApi;
private readonly IConfiguration configuration; private readonly IConfiguration configuration;
private readonly ILocalStorage localStorage; private readonly ILocalStorage localStorage;
private readonly AuthenticationStateProvider authState; private readonly AuthenticationStateProvider authState;
private readonly NavigationManager navigation; private readonly NavigationManager navigation;
private readonly ShogiSocket shogiSocket; private readonly ShogiSocket shogiSocket;
public AccountManager( public AccountManager(
AccountState accountState, AccountState accountState,
IShogiApi unauthenticatedClient, IShogiApi unauthenticatedClient,
IConfiguration configuration, IConfiguration configuration,
AuthenticationStateProvider authState, AuthenticationStateProvider authState,
ILocalStorage localStorage, ILocalStorage localStorage,
NavigationManager navigation, NavigationManager navigation,
ShogiSocket shogiSocket) ShogiSocket shogiSocket)
{
this.accountState = accountState;
this.shogiApi = unauthenticatedClient;
this.configuration = configuration;
this.authState = authState;
this.localStorage = localStorage;
this.navigation = navigation;
this.shogiSocket = shogiSocket;
}
private User? User { get => accountState.User; set => accountState.User = value; }
public async Task LoginWithGuestAccount()
{
var response = await shogiApi.GetToken();
if (response != null)
{ {
User = new User this.accountState = accountState;
{ this.shogiApi = unauthenticatedClient;
DisplayName = response.DisplayName, this.configuration = configuration;
Id = response.UserId, this.authState = authState;
OneTimeSocketToken = response.OneTimeToken, this.localStorage = localStorage;
WhichAccountPlatform = WhichAccountPlatform.Guest this.navigation = navigation;
}; this.shogiSocket = shogiSocket;
await shogiSocket.OpenAsync(User.OneTimeSocketToken.ToString());
await localStorage.SetAccountPlatform(WhichAccountPlatform.Guest);
}
}
public async Task LoginWithMicrosoftAccount()
{
var state = await authState.GetAuthenticationStateAsync();
if (state.User?.Identity?.Name == null || state.User?.Identity?.IsAuthenticated != true)
{
navigation.NavigateTo("authentication/login");
return;
} }
var response = await shogiApi.GetToken(); private User? MyUser { get => accountState.User; set => accountState.User = value; }
if (response != null)
{
User = new User
{
DisplayName = response.DisplayName,
Id = response.UserId,
OneTimeSocketToken = response.OneTimeToken,
WhichAccountPlatform = WhichAccountPlatform.Microsoft,
};
await shogiSocket.OpenAsync(User.OneTimeSocketToken.ToString()); public async Task LoginWithGuestAccount()
await localStorage.SetAccountPlatform(WhichAccountPlatform.Microsoft);
}
}
/// <summary>
/// Try to log in with the account used from the previous browser session.
/// </summary>
/// <returns></returns>
public async Task<bool> TryLoginSilentAsync()
{
var platform = await localStorage.GetAccountPlatform();
if (platform == WhichAccountPlatform.Guest)
{ {
var response = await shogiApi.GetToken(); var response = await shogiApi.GetToken();
if (response != null) if (response != null)
{
User = new User
{ {
DisplayName = response.DisplayName, MyUser = new User
Id = response.UserId, {
OneTimeSocketToken = response.OneTimeToken, DisplayName = response.DisplayName,
WhichAccountPlatform = WhichAccountPlatform.Guest Id = response.UserId,
}; OneTimeSocketToken = response.OneTimeToken,
} WhichAccountPlatform = WhichAccountPlatform.Guest
};
await shogiSocket.OpenAsync(MyUser.Value.OneTimeSocketToken.ToString());
await localStorage.SetAccountPlatform(WhichAccountPlatform.Guest);
}
} }
else if (platform == WhichAccountPlatform.Microsoft)
public async Task LoginWithMicrosoftAccount()
{ {
Console.WriteLine("Login Microsoft"); var state = await authState.GetAuthenticationStateAsync();
throw new NotImplementedException();
//var state = await authState.GetAuthenticationStateAsync(); if (state.User?.Identity?.Name == null || state.User?.Identity?.IsAuthenticated == false)
//if (state.User?.Identity?.Name != null) {
//{ // Set the login platform so that we know to log in with microsoft after being redirected away from the UI.
// var id = state.User.Identity; await localStorage.SetAccountPlatform(WhichAccountPlatform.Microsoft);
// User = new User navigation.NavigateToLogin("authentication/login");
// { return;
// DisplayName = id.Name, }
// Id = id.Name
// }; //var response = await shogiApi.GetToken();
// var token = await shogiApi.GetToken(); //if (response != null)
// if (token.HasValue) //{
// { // MyUser = new User
// User.OneTimeSocketToken = token.Value; // {
// } // DisplayName = response.DisplayName,
//} // Id = response.UserId,
// TODO: If this fails then platform saved to localStorage should get cleared // OneTimeSocketToken = response.OneTimeToken,
// WhichAccountPlatform = WhichAccountPlatform.Microsoft,
// };
// await shogiSocket.OpenAsync(MyUser.Value.OneTimeSocketToken.ToString());
// await localStorage.SetAccountPlatform(WhichAccountPlatform.Microsoft);
//}
} }
if (User != null) /// <summary>
/// Try to log in with the account used from the previous browser session.
/// </summary>
/// <returns></returns>
public async Task<bool> TryLoginSilentAsync()
{ {
await shogiSocket.OpenAsync(User.OneTimeSocketToken.ToString()); var platform = await localStorage.GetAccountPlatform();
return true; Console.WriteLine($"Try Login Silent - {platform}");
if (platform == WhichAccountPlatform.Guest)
{
var response = await shogiApi.GetToken();
if (response != null)
{
MyUser = new User
{
DisplayName = response.DisplayName,
Id = response.UserId,
OneTimeSocketToken = response.OneTimeToken,
WhichAccountPlatform = WhichAccountPlatform.Guest
};
}
}
else if (platform == WhichAccountPlatform.Microsoft)
{
var state = await authState.GetAuthenticationStateAsync();
if (state.User?.Identity?.Name != null)
{
var response = await shogiApi.GetToken();
if (response == null)
{
// Login failed, so reset local storage to avoid putting the user in a broken state.
await localStorage.DeleteAccountPlatform();
return false;
}
var id = state.User.Identity;
MyUser = new User
{
DisplayName = id.Name,
Id = id.Name,
OneTimeSocketToken = response.OneTimeToken
};
}
}
if (MyUser != null)
{
await shogiSocket.OpenAsync(MyUser.Value.OneTimeSocketToken.ToString());
return true;
}
return false;
} }
return false; public async Task LogoutAsync()
} {
MyUser = null;
var platform = await localStorage.GetAccountPlatform();
await localStorage.DeleteAccountPlatform();
public async Task LogoutAsync() if (platform == WhichAccountPlatform.Guest)
{ {
await Task.WhenAll(shogiApi.GuestLogout(), localStorage.DeleteAccountPlatform()); await shogiApi.GuestLogout();
User = null; } else if (platform == WhichAccountPlatform.Microsoft)
} {
navigation.NavigateToLogout("authentication/logout");
}
}
} }

View File

@@ -2,27 +2,27 @@
public class AccountState public class AccountState
{ {
public event EventHandler<LoginEventArgs>? LoginChangedEvent; public event EventHandler<LoginEventArgs>? LoginChangedEvent;
private User? user; private User? user;
public User? User public User? User
{ {
get => user; get => user;
set set
{ {
if (user != value) if (user != value)
{ {
user = value; user = value;
EmitLoginChangedEvent(); EmitLoginChangedEvent();
} }
} }
} }
private void EmitLoginChangedEvent() private void EmitLoginChangedEvent()
{ {
LoginChangedEvent?.Invoke(this, new LoginEventArgs LoginChangedEvent?.Invoke(this, new LoginEventArgs
{ {
User = User User = User
}); });
} }
} }

View File

@@ -1,12 +1,10 @@
namespace Shogi.UI.Pages.Home.Account namespace Shogi.UI.Pages.Home.Account
{ {
public class User public readonly record struct User(
{ string Id,
public string Id { get; set; } string DisplayName,
public string DisplayName { get; set; } WhichAccountPlatform WhichAccountPlatform,
Guid OneTimeSocketToken)
public WhichAccountPlatform WhichAccountPlatform { get; set; } {
}
public Guid OneTimeSocketToken { get; set; }
}
} }

View File

@@ -7,74 +7,75 @@ using System.Text.Json;
namespace Shogi.UI.Pages.Home.Api namespace Shogi.UI.Pages.Home.Api
{ {
public class ShogiApi : IShogiApi public class ShogiApi : IShogiApi
{
public const string GuestClientName = "Guest";
public const string MsalClientName = "Msal";
public const string AnonymouseClientName = "Anonymous";
private readonly JsonSerializerOptions serializerOptions;
private readonly IHttpClientFactory clientFactory;
private readonly AccountState accountState;
public ShogiApi(IHttpClientFactory clientFactory, AccountState accountState)
{ {
serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web); public const string GuestClientName = "Guest";
this.clientFactory = clientFactory; public const string MsalClientName = "Msal";
this.accountState = accountState; //public const string AnonymousClientName = "Anonymous";
private readonly JsonSerializerOptions serializerOptions;
private readonly IHttpClientFactory clientFactory;
private readonly AccountState accountState;
public ShogiApi(IHttpClientFactory clientFactory, AccountState accountState)
{
serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
this.clientFactory = clientFactory;
this.accountState = accountState;
}
private HttpClient HttpClient => accountState.User?.WhichAccountPlatform switch
{
WhichAccountPlatform.Guest => clientFactory.CreateClient(GuestClientName),
WhichAccountPlatform.Microsoft => clientFactory.CreateClient(MsalClientName),
_ => clientFactory.CreateClient(GuestClientName)
};
public async Task GuestLogout()
{
var response = await HttpClient.PutAsync(new Uri("User/GuestLogout", UriKind.Relative), null);
response.EnsureSuccessStatusCode();
}
public async Task<Session?> GetSession(string name)
{
var response = await HttpClient.GetAsync(new Uri($"Sessions/{name}", UriKind.Relative));
if (response.IsSuccessStatusCode)
{
return (await response.Content.ReadFromJsonAsync<ReadSessionResponse>(serializerOptions))?.Session;
}
return null;
}
public async Task<ReadSessionsPlayerCountResponse?> GetSessionsPlayerCount()
{
var response = await HttpClient.GetAsync(new Uri("Sessions/PlayerCount", UriKind.Relative));
if (response.IsSuccessStatusCode)
{
return await response.Content.ReadFromJsonAsync<ReadSessionsPlayerCountResponse>(serializerOptions);
}
return null;
}
public async Task<CreateTokenResponse?> GetToken()
{
Console.WriteLine($"GetToken() - {accountState.User?.WhichAccountPlatform}");
var response = await HttpClient.GetFromJsonAsync<CreateTokenResponse>(new Uri("User/Token", UriKind.Relative), serializerOptions);
return response;
}
public async Task Move(string sessionName, MovePieceCommand command)
{
await this.HttpClient.PatchAsync($"Sessions/{sessionName}/Move", JsonContent.Create(command));
}
public async Task<HttpStatusCode> PostSession(string name, bool isPrivate)
{
var response = await HttpClient.PostAsJsonAsync(new Uri("Sessions", UriKind.Relative), new CreateSessionCommand
{
Name = name,
});
return response.StatusCode;
}
} }
private HttpClient HttpClient => accountState.User?.WhichAccountPlatform switch
{
WhichAccountPlatform.Guest => clientFactory.CreateClient(GuestClientName),
WhichAccountPlatform.Microsoft => clientFactory.CreateClient(MsalClientName),
_ => clientFactory.CreateClient(AnonymouseClientName)
};
public async Task GuestLogout()
{
var response = await HttpClient.PutAsync(new Uri("User/GuestLogout", UriKind.Relative), null);
response.EnsureSuccessStatusCode();
}
public async Task<Session?> GetSession(string name)
{
var response = await HttpClient.GetAsync(new Uri($"Sessions/{name}", UriKind.Relative));
if (response.IsSuccessStatusCode)
{
return (await response.Content.ReadFromJsonAsync<ReadSessionResponse>(serializerOptions))?.Session;
}
return null;
}
public async Task<ReadSessionsPlayerCountResponse?> GetSessionsPlayerCount()
{
var response = await HttpClient.GetAsync(new Uri("Sessions/PlayerCount", UriKind.Relative));
if (response.IsSuccessStatusCode)
{
return await response.Content.ReadFromJsonAsync<ReadSessionsPlayerCountResponse>(serializerOptions);
}
return null;
}
public async Task<CreateTokenResponse?> GetToken()
{
var response = await HttpClient.GetFromJsonAsync<CreateTokenResponse>(new Uri("User/Token", UriKind.Relative), serializerOptions);
return response;
}
public async Task Move(string sessionName, MovePieceCommand command)
{
await this.HttpClient.PatchAsync($"Sessions/{sessionName}/Move", JsonContent.Create(command));
}
public async Task<HttpStatusCode> PostSession(string name, bool isPrivate)
{
var response = await HttpClient.PostAsJsonAsync(new Uri("Sessions", UriKind.Relative), new CreateSessionCommand
{
Name = name,
});
return response.StatusCode;
}
}
} }

View File

@@ -1,185 +0,0 @@
@using Shogi.Contracts.Api
@using Shogi.Contracts.Types;
@using System.Text.RegularExpressions;
@inject IShogiApi ShogiApi
@inject AccountState Account;
@inject PromotePrompt PromotePrompt;
<article class="game-board">
<!-- Game board -->
<section class="board" data-perspective="@Perspective">
@for (var rank = 1; rank < 10; rank++)
{
foreach (var file in Files)
{
var position = $"{file}{rank}";
var piece = session?.BoardState.Board[position];
<div class="tile"
data-position="@(position)"
data-selected="@(piece != null && selectedPosition == position)"
style="grid-area: @(position)"
@onclick="() => OnClickTile(piece, position)">
<GamePiece Piece="piece" Perspective="Perspective" />
</div>
}
}
<div class="ruler vertical" style="grid-area: rank">
<span>9</span>
<span>8</span>
<span>7</span>
<span>6</span>
<span>5</span>
<span>4</span>
<span>3</span>
<span>2</span>
<span>1</span>
</div>
<div class="ruler" style="grid-area: file">
<span>A</span>
<span>B</span>
<span>C</span>
<span>D</span>
<span>E</span>
<span>F</span>
<span>G</span>
<span>H</span>
<span>I</span>
</div>
<!-- Promote prompt -->
<div class="promote-prompt" data-visible="@PromotePrompt.IsVisible">
<p>Do you wish to promote?</p>
<div>
<button type="button">Yes</button>
<button type="button">No</button>
<button type="button">Cancel</button>
</div>
</div>
</section>
<!-- Side board -->
@if (session != null)
{
<aside class="side-board">
<div class="hand">
@foreach (var piece in OpponentHand)
{
<div class="tile">
<GamePiece Piece="piece" Perspective="Perspective" />
</div>
}
</div>
<div class="spacer" />
<div class="hand">
@foreach (var piece in UserHand)
{
<div class="title" @onclick="() => OnClickHand(piece)">
<GamePiece Piece="piece" Perspective="Perspective" />
</div>
}
</div>
</aside>
}
</article>
@code {
static readonly string[] Files = new[] { "A", "B", "C", "D", "E", "F", "G", "H", "I" };
[Parameter]
public string? SessionName { get; set; }
WhichPlayer Perspective => Account.User?.Id == session?.Player1
? WhichPlayer.Player1
: WhichPlayer.Player2;
Session? session;
IReadOnlyCollection<Piece> OpponentHand
{
get
{
if (this.session == null) return Array.Empty<Piece>();
return Perspective == WhichPlayer.Player1
? this.session.BoardState.Player1Hand
: this.session.BoardState.Player2Hand;
}
}
IReadOnlyCollection<Piece> UserHand
{
get
{
if (this.session == null) return Array.Empty<Piece>();
return Perspective == WhichPlayer.Player1
? this.session.BoardState.Player1Hand
: this.session.BoardState.Player2Hand;
}
}
bool IsMyTurn => session?.BoardState.WhoseTurn == Perspective;
string? selectedPosition;
WhichPiece? selectedPiece;
protected override async Task OnParametersSetAsync()
{
if (!string.IsNullOrWhiteSpace(SessionName))
{
this.session = await ShogiApi.GetSession(SessionName);
}
}
bool ShouldPromptForPromotion(string position)
{
if (Perspective == WhichPlayer.Player1 && Regex.IsMatch(position, ".[7-9]"))
{
return true;
}
if (Perspective == WhichPlayer.Player2 && Regex.IsMatch(position, ".[1-3]"))
{
return true;
}
return false;
}
async void OnClickTile(Piece? piece, string position)
{
if (SessionName == null || !IsMyTurn) return;
if (selectedPosition == null || piece?.Owner == Perspective)
{
// Select a position.
selectedPosition = position;
return;
}
if (selectedPosition == position)
{
// Deselect the selected position.
selectedPosition = null;
return;
}
if (piece == null)
{
if (ShouldPromptForPromotion(position) || ShouldPromptForPromotion(selectedPosition))
{
PromotePrompt.Show(SessionName, new MovePieceCommand
{
From = selectedPosition,
To = position
});
}
else
{
await ShogiApi.Move(SessionName, new MovePieceCommand
{
From = selectedPosition,
IsPromotion = false,
To = position
});
}
}
}
void OnClickHand(Piece piece)
{
selectedPiece = piece.WhichPiece;
}
}

View File

@@ -0,0 +1,11 @@
@using Contracts.Types;
<GameBoardPresentation Perspective="WhichPlayer.Player1" />
@code {
protected override void OnInitialized()
{
base.OnInitialized();
Console.WriteLine("Empty Game Board.");
}
}

View File

@@ -0,0 +1,45 @@
@using Shogi.Contracts.Api
@using Shogi.Contracts.Types;
@using System.Text.RegularExpressions;
@inject IShogiApi ShogiApi
@inject AccountState Account;
@inject PromotePrompt PromotePrompt;
@if (session == null)
{
<EmptyGameBoard />
}
else if (isSpectating)
{
<SpectatorGameBoard Session="session" />
}
else
{
<SeatedGameBoard Perspective="perspective" Session="session" />
}
@code {
[Parameter]
public string? SessionName { get; set; }
Session? session;
private WhichPlayer perspective;
private bool isSpectating;
protected override async Task OnParametersSetAsync()
{
if (!string.IsNullOrWhiteSpace(SessionName))
{
this.session = await ShogiApi.GetSession(SessionName);
if (this.session != null)
{
var accountId = Account.User?.Id;
this.perspective = accountId == session.Player2 ? WhichPlayer.Player2 : WhichPlayer.Player1;
this.isSpectating = !(accountId == this.session.Player1 || accountId == this.session.Player2);
Console.WriteLine($"IsSpectating - {isSpectating}. AccountId - {accountId}. Player1 - {this.session.Player1}. Player2 - {this.session.Player2}");
}
}
}
}

View File

@@ -0,0 +1,116 @@
@using Shogi.Contracts.Types;
@inject PromotePrompt PromotePrompt;
<article class="game-board">
<!-- Game board -->
<section class="board" data-perspective="@Perspective">
@for (var rank = 1; rank < 10; rank++)
{
foreach (var file in Files)
{
var position = $"{file}{rank}";
var piece = Session?.BoardState.Board[position];
var isSelected = piece != null && SelectedPosition == position;
<div class="tile" @onclick="OnClickTileInternal(piece, position)"
data-position="@(position)"
data-selected="@(isSelected)"
style="grid-area: @position">
<GamePiece Piece="piece" Perspective="Perspective" />
</div>
}
}
<div class="ruler vertical" style="grid-area: rank">
<span>9</span>
<span>8</span>
<span>7</span>
<span>6</span>
<span>5</span>
<span>4</span>
<span>3</span>
<span>2</span>
<span>1</span>
</div>
<div class="ruler" style="grid-area: file">
<span>A</span>
<span>B</span>
<span>C</span>
<span>D</span>
<span>E</span>
<span>F</span>
<span>G</span>
<span>H</span>
<span>I</span>
</div>
<!-- Promote prompt -->
<div class="promote-prompt" data-visible="@PromotePrompt.IsVisible">
<p>Do you wish to promote?</p>
<div>
<button type="button">Yes</button>
<button type="button">No</button>
<button type="button">Cancel</button>
</div>
</div>
</section>
<!-- Side board -->
@if (Session != null)
{
<aside class="side-board">
<div class="hand">
@foreach (var piece in OpponentHand)
{
<div class="tile">
<GamePiece Piece="piece" Perspective="Perspective" />
</div>
}
</div>
<div class="spacer" />
<div class="hand">
@foreach (var piece in UserHand)
{
<div class="title" @onclick="OnClickHandInternal(piece)">
<GamePiece Piece="piece" Perspective="Perspective" />
</div>
}
</div>
</aside>
}
</article>
@code {
[Parameter] public WhichPlayer Perspective { get; set; }
[Parameter] public Session? Session { get; set; }
[Parameter] public string? SelectedPosition { get; set; }
// TODO: Exchange these OnClick actions for events like "SelectionChangedEvent" and "MoveFromBoardEvent" and "MoveFromHandEvent".
[Parameter] public Action<Piece?, string>? OnClickTile { get; set; }
[Parameter] public Action<Piece>? OnClickHand { get; set; }
static readonly string[] Files = new[] { "A", "B", "C", "D", "E", "F", "G", "H", "I" };
private IReadOnlyCollection<Piece> OpponentHand
{
get
{
if (this.Session == null) return Array.Empty<Piece>();
return Perspective == WhichPlayer.Player1
? this.Session.BoardState.Player1Hand
: this.Session.BoardState.Player2Hand;
}
}
IReadOnlyCollection<Piece> UserHand
{
get
{
if (this.Session == null) return Array.Empty<Piece>();
return Perspective == WhichPlayer.Player1
? this.Session.BoardState.Player1Hand
: this.Session.BoardState.Player2Hand;
}
}
private Action OnClickTileInternal(Piece? piece, string position) => () => OnClickTile?.Invoke(piece, position);
private Action OnClickHandInternal(Piece piece) => () => OnClickHand?.Invoke(piece);
}

View File

@@ -0,0 +1,71 @@
@using Shogi.Contracts.Api;
@using Shogi.Contracts.Types;
@using System.Text.RegularExpressions;
@inject PromotePrompt PromotePrompt;
@inject IShogiApi ShogiApi;
<GameBoardPresentation OnClickHand="OnClickHand" OnClickTile="OnClickTile" Session="Session" Perspective="Perspective" />
@code {
[Parameter] public WhichPlayer Perspective { get; set; }
[Parameter] public Session Session { get; set; }
private bool IsMyTurn => Session?.BoardState.WhoseTurn == Perspective;
private string? selectedBoardPosition;
private WhichPiece? selectedPieceFromHand;
bool ShouldPromptForPromotion(string position)
{
if (Perspective == WhichPlayer.Player1 && Regex.IsMatch(position, ".[7-9]"))
{
return true;
}
if (Perspective == WhichPlayer.Player2 && Regex.IsMatch(position, ".[1-3]"))
{
return true;
}
return false;
}
async void OnClickTile(Piece? piece, string position)
{
if (!IsMyTurn) return;
if (selectedBoardPosition == null || piece?.Owner == Perspective)
{
// Select a position.
selectedBoardPosition = position;
return;
}
if (selectedBoardPosition == position)
{
// Deselect the selected position.
selectedBoardPosition = null;
return;
}
if (piece == null)
{
if (ShouldPromptForPromotion(position) || ShouldPromptForPromotion(selectedBoardPosition))
{
PromotePrompt.Show(Session.SessionName, new MovePieceCommand
{
From = selectedBoardPosition,
To = position
});
}
else
{
await ShogiApi.Move(Session.SessionName, new MovePieceCommand
{
From = selectedBoardPosition,
IsPromotion = false,
To = position
});
}
}
}
void OnClickHand(Piece piece)
{
selectedPieceFromHand = piece.WhichPiece;
}
}

View File

@@ -0,0 +1,8 @@
@using Contracts.Types;
<p>You are spectating</p>
<GameBoardPresentation Perspective="WhichPlayer.Player1" Session="Session" />
@code {
[Parameter] public Session Session { get; set; }
}

View File

@@ -1,6 +1,7 @@
@using Shogi.Contracts.Types @using Shogi.Contracts.Types;
@using System.ComponentModel.DataAnnotations @using System.ComponentModel.DataAnnotations;
@using System.Net @using System.Net;
@using System.Text.Json;
@inject IShogiApi ShogiApi; @inject IShogiApi ShogiApi;
@inject ShogiSocket ShogiSocket; @inject ShogiSocket ShogiSocket;
@inject AccountState Account; @inject AccountState Account;
@@ -17,12 +18,27 @@
<div class="tab-content"> <div class="tab-content">
<div class="tab-pane fade show active" id="search-pane"> <div class="tab-pane fade show active" id="search-pane">
<h3>Games you&apos;re seated at</h3>
<div class="list-group"> <div class="list-group">
@if (!sessions.Any()) @if (!joinedSessions.Any())
{ {
<p>No games exist</p> <p>You have not joined any games.</p>
} }
@foreach (var session in sessions) @foreach (var session in joinedSessions)
{
<button class="list-group-item list-group-item-action @ActiveCss(session)" @onclick="() => OnClickSession(session)">
<span>@session.Name</span>
<span>(@session.PlayerCount/2)</span>
</button>
}
</div>
<h3>Other games</h3>
<div class="list-group">
@if (!otherSessions.Any())
{
<p>You have not joined any games.</p>
}
@foreach (var session in otherSessions)
{ {
<button class="list-group-item list-group-item-action @ActiveCss(session)" @onclick="() => OnClickSession(session)"> <button class="list-group-item list-group-item-action @ActiveCss(session)" @onclick="() => OnClickSession(session)">
<span>@session.Name</span> <span>@session.Name</span>
@@ -69,16 +85,19 @@
@code { @code {
[Parameter] [Parameter]
public Action<SessionMetadata>? ActiveSessionChanged { get; set; } public Action<SessionMetadata>? ActiveSessionChanged { get; set; }
CreateForm createForm = new();
SessionMetadata[] sessions = Array.Empty<SessionMetadata>(); private CreateForm createForm = new();
SessionMetadata? activeSession; private SessionMetadata[] joinedSessions = Array.Empty<SessionMetadata>();
HttpStatusCode? createSessionStatusCode; private SessionMetadata[] otherSessions = Array.Empty<SessionMetadata>();
private SessionMetadata? activeSession;
private HttpStatusCode? createSessionStatusCode;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
ShogiSocket.OnCreateGameMessage += async (sender, message) => await FetchSessions(); ShogiSocket.OnCreateGameMessage += async (sender, message) => await FetchSessions();
Account.LoginChangedEvent += async (sender, message) => Account.LoginChangedEvent += async (sender, message) =>
{ {
Console.WriteLine($"LoginEvent. Message={JsonSerializer.Serialize(message)}.");
if (message.User != null) if (message.User != null)
{ {
await FetchSessions(); await FetchSessions();
@@ -99,7 +118,8 @@
var sessions = await ShogiApi.GetSessionsPlayerCount(); var sessions = await ShogiApi.GetSessionsPlayerCount();
if (sessions != null) if (sessions != null)
{ {
this.sessions = sessions.PlayerHasJoinedSessions.Concat(sessions.AllOtherSessions).ToArray(); this.joinedSessions = sessions.PlayerHasJoinedSessions.ToArray();
this.otherSessions = sessions.AllOtherSessions.ToArray();
StateHasChanged(); StateHasChanged();
} }
} }

View File

@@ -15,7 +15,14 @@
} }
<PageHeader /> <PageHeader />
<GameBrowser ActiveSessionChanged="OnChangeSession" /> <GameBrowser ActiveSessionChanged="OnChangeSession" />
<GameBoard SessionName="@activeSessionName" /> @if (Account.User == null || activeSessionName == null)
{
<EmptyGameBoard />
}
else
{
<GameBoard SessionName="@activeSessionName" />
}
</main> </main>
@code { @code {

View File

@@ -6,7 +6,7 @@
@if (user != null) @if (user != null)
{ {
<div class="user"> <div class="user">
<div>@user.DisplayName</div> <div>@user.Value.DisplayName</div>
<button type="button" class="logout" @onclick="AccountManager.LogoutAsync">Logout</button> <button type="button" class="logout" @onclick="AccountManager.LogoutAsync">Logout</button>
</div> </div>
} }

View File

@@ -28,14 +28,15 @@ static void ConfigureDependencies(IServiceCollection services, IConfiguration co
services services
.AddHttpClient(ShogiApi.GuestClientName, client => client.BaseAddress = shogiApiUrl) .AddHttpClient(ShogiApi.GuestClientName, client => client.BaseAddress = shogiApiUrl)
.AddHttpMessageHandler<CookieCredentialsMessageHandler>(); .AddHttpMessageHandler<CookieCredentialsMessageHandler>();
services //services
.AddHttpClient(ShogiApi.AnonymouseClientName, client => client.BaseAddress = shogiApiUrl) // .AddHttpClient(ShogiApi.AnonymousClientName, client => client.BaseAddress = shogiApiUrl)
.AddHttpMessageHandler<CookieCredentialsMessageHandler>(); // .AddHttpMessageHandler<CookieCredentialsMessageHandler>();
// Authorization // Authorization
services.AddMsalAuthentication(options => services.AddMsalAuthentication(options =>
{ {
configuration.Bind("AzureAd", options.ProviderOptions.Authentication); configuration.Bind("AzureAd", options.ProviderOptions.Authentication);
options.ProviderOptions.LoginMode = "redirect";
}); });
services.AddOidcAuthentication(options => services.AddOidcAuthentication(options =>
{ {

View File

@@ -54,6 +54,7 @@ public class ShogiSocket : IDisposable
switch (action) switch (action)
{ {
case SocketAction.SessionCreated: case SocketAction.SessionCreated:
Console.WriteLine("Session created event.");
this.OnCreateGameMessage?.Invoke(this, JsonSerializer.Deserialize<SessionCreatedSocketMessage>(memory, this.serializerOptions)!); this.OnCreateGameMessage?.Invoke(this, JsonSerializer.Deserialize<SessionCreatedSocketMessage>(memory, this.serializerOptions)!);
break; break;
default: default:

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly"> <Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
@@ -14,12 +14,12 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="6.0.8" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="7.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="6.0.8" PrivateAssets="all" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="7.0.2" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="6.0.8" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="7.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Http.Extensions" Version="2.2.0" /> <PackageReference Include="Microsoft.AspNetCore.Http.Extensions" Version="2.2.0" />
<PackageReference Include="Microsoft.Authentication.WebAssembly.Msal" Version="6.0.8" /> <PackageReference Include="Microsoft.Authentication.WebAssembly.Msal" Version="7.0.2" />
<PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" /> <PackageReference Include="Microsoft.Extensions.Http" Version="7.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -10,6 +10,7 @@
@using Microsoft.JSInterop @using Microsoft.JSInterop
@using Shogi.UI.Pages.Home.Account @using Shogi.UI.Pages.Home.Account
@using Shogi.UI.Pages.Home.Api @using Shogi.UI.Pages.Home.Api
@using Shogi.UI.Pages.Home.GameBoard
@using Shogi.UI.Pages.Home.Pieces @using Shogi.UI.Pages.Home.Pieces
@using Shogi.UI.Shared.Modal @using Shogi.UI.Shared.Modal
@using Shogi.UI.Shared @using Shogi.UI.Shared

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
@@ -23,11 +23,11 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.9.0" /> <PackageReference Include="FluentAssertions" Version="6.9.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="7.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="7.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="7.0.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="7.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="7.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="7.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="7.0.0" />
<PackageReference Include="Microsoft.Identity.Client" Version="4.48.0" /> <PackageReference Include="Microsoft.Identity.Client" Version="4.49.1" />
<PackageReference Include="Microsoft.Net.Http.Headers" Version="2.2.8" /> <PackageReference Include="Microsoft.Net.Http.Headers" Version="2.2.8" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
<PackageReference Include="xunit" Version="2.4.2" /> <PackageReference Include="xunit" Version="2.4.2" />

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>

View File

@@ -1,6 +1,6 @@
{ {
"sdk": { "sdk": {
"version": "6.0.300", "version": "7.0.2",
"rollForward": "latestFeature" "rollForward": "latestFeature"
} }
} }