Skip to content

Commit 4d64239

Browse files
Blazor Initial Structure
* add initial Blazor structure with Fluent UI Blazor * configure custom authentication scheme * add API contracts project * add API client * add register/login pages
1 parent 2bf7b36 commit 4d64239

File tree

82 files changed

+2247
-0
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

82 files changed

+2247
-0
lines changed

Frontend/.editorconfig

Lines changed: 401 additions & 0 deletions
Large diffs are not rendered by default.

Frontend/Directory.Build.props

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<Project>
2+
<PropertyGroup>
3+
<TargetFramework>net8.0</TargetFramework>
4+
<ImplicitUsings>enable</ImplicitUsings>
5+
<Nullable>enable</Nullable>
6+
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
7+
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
8+
</PropertyGroup>
9+
</Project>

Frontend/Directory.Packages.props

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<Project>
2+
<PropertyGroup>
3+
<CentralPackageVersionOverrideEnabled>false</CentralPackageVersionOverrideEnabled>
4+
</PropertyGroup>
5+
<ItemGroup>
6+
<PackageVersion Include="BitzArt.Blazor.Cookies.Client" Version="1.0.4" />
7+
<PackageVersion Include="BitzArt.Blazor.Cookies.Server" Version="1.0.4" />
8+
<PackageVersion Include="Microsoft.AspNetCore.Authentication" Version="2.2.0" />
9+
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.4" />
10+
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="8.0.4" />
11+
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="8.0.4" />
12+
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" />
13+
<PackageVersion Include="Microsoft.Extensions.Http" Version="8.0.0" />
14+
<PackageVersion Include="Microsoft.FluentUI.AspNetCore.Components" Version="4.7.1" />
15+
<PackageVersion Include="Microsoft.FluentUI.AspNetCore.Components.Icons" Version="4.7.0" />
16+
<PackageVersion Include="Skrasek.RailwayResult" Version="1.1.0" />
17+
<PackageVersion Include="Skrasek.RailwayResult.FunctionalExtensions" Version="1.1.0" />
18+
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="7.5.1" />
19+
</ItemGroup>
20+
</Project>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using RailwayResult;
2+
3+
using TeamUp.Contracts.Users;
4+
5+
namespace TeamUp.ApiLayer;
6+
7+
public sealed partial class ApiClient
8+
{
9+
private const string HTTP_HEADER_CONFIRM_PASSWORD = "HTTP_HEADER_CONFIRM_PASSWORD";
10+
11+
public Task<Result<string>> LoginAsync(LoginRequest request, CancellationToken ct) =>
12+
SendAsync<LoginRequest, string>(HttpMethod.Post, "/api/v1/users/login", request, ct);
13+
14+
public Task<Result<UserId>> RegisterAsync(RegisterUserRequest request, CancellationToken ct) =>
15+
SendAsync<RegisterUserRequest, UserId>(HttpMethod.Post, "/api/v1/users/register", request, ct);
16+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
using System.Net;
2+
using System.Net.Http.Json;
3+
using System.Text;
4+
using System.Text.Json;
5+
6+
using RailwayResult;
7+
8+
using TeamUp.ApiLayer.Internal;
9+
using TeamUp.Contracts;
10+
11+
namespace TeamUp.ApiLayer;
12+
13+
public sealed partial class ApiClient
14+
{
15+
private readonly HttpClient _client;
16+
17+
public ApiClient(HttpClient client)
18+
{
19+
_client = client;
20+
}
21+
22+
private Task<Result<TResponse>> SendAsync<TRequest, TResponse>(HttpMethod method, string uri, TRequest payload, CancellationToken ct) =>
23+
SendAsync<TRequest, TResponse>(method, uri, payload, null, ct);
24+
25+
private async Task<Result<TResponse>> SendAsync<TRequest, TResponse>(HttpMethod method, string uri, TRequest payload, Action<HttpRequestMessage>? configure, CancellationToken ct)
26+
{
27+
var json = JsonSerializer.Serialize(payload);
28+
var request = new HttpRequestMessage
29+
{
30+
Method = method,
31+
RequestUri = new Uri(uri, UriKind.Relative),
32+
Content = new StringContent(json, Encoding.UTF8, "application/json"),
33+
};
34+
35+
if (configure is not null)
36+
{
37+
configure(request);
38+
}
39+
40+
var response = await _client.SendAsync(request, ct);
41+
42+
if (!response.IsSuccessStatusCode)
43+
{
44+
if (response.StatusCode == HttpStatusCode.Unauthorized)
45+
{
46+
//logout
47+
}
48+
49+
var contentType = response.Content.Headers.ContentType?.MediaType;
50+
if (contentType != "application/problem+json")
51+
{
52+
return new ApiError("Api.UnexpectedError", $"Unexpected response with content type '{contentType}'.", response.StatusCode);
53+
}
54+
55+
var problemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>(ct);
56+
if (problemDetails is null)
57+
{
58+
return new ApiError("Api.SerializationError", "Failed to deserialize error response.", response.StatusCode);
59+
}
60+
61+
var title = problemDetails.Title ?? "Undefined Title";
62+
var detail = problemDetails.Detail ?? "Undefined Detail";
63+
64+
if (problemDetails.Errors.Count > 0)
65+
{
66+
return new ApiValidationError("Api.ValidationError", "One or more validation errors occurred.", problemDetails.Errors);
67+
}
68+
69+
return new ApiError("Api.Error", detail, response.StatusCode);
70+
}
71+
72+
return await response.Content.ReadFromJsonAsync<TResponse>(ct);
73+
}
74+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace TeamUp.ApiLayer.Internal;
4+
5+
internal class ProblemDetails
6+
{
7+
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
8+
[JsonPropertyOrder(-5)]
9+
[JsonPropertyName("type")]
10+
public string? Type { get; set; }
11+
12+
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
13+
[JsonPropertyOrder(-4)]
14+
[JsonPropertyName("title")]
15+
public string? Title { get; set; }
16+
17+
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
18+
[JsonPropertyOrder(-3)]
19+
[JsonPropertyName("status")]
20+
public int? Status { get; set; }
21+
22+
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
23+
[JsonPropertyOrder(-2)]
24+
[JsonPropertyName("detail")]
25+
public string? Detail { get; set; }
26+
27+
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
28+
[JsonPropertyOrder(-1)]
29+
[JsonPropertyName("instance")]
30+
public string? Instance { get; set; }
31+
32+
[JsonExtensionData]
33+
public IDictionary<string, object?> Extensions { get; set; } = new Dictionary<string, object?>(StringComparer.Ordinal);
34+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace TeamUp.ApiLayer.Internal;
4+
5+
internal sealed class ValidationProblemDetails : ProblemDetails
6+
{
7+
public ValidationProblemDetails() : this(new Dictionary<string, string[]>(StringComparer.Ordinal))
8+
{
9+
}
10+
11+
public ValidationProblemDetails(IDictionary<string, string[]> errors) : this(new Dictionary<string, string[]>(errors ?? throw new ArgumentNullException(nameof(errors)), StringComparer.Ordinal))
12+
{
13+
}
14+
15+
private ValidationProblemDetails(Dictionary<string, string[]> errors)
16+
{
17+
Title = "One or more validation errors occurred.";
18+
Errors = errors;
19+
}
20+
21+
[JsonPropertyName("errors")]
22+
public IDictionary<string, string[]> Errors { get; set; } = new Dictionary<string, string[]>(StringComparer.Ordinal);
23+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
3+
namespace TeamUp.ApiLayer;
4+
5+
public static class ServiceCollectionExtensions
6+
{
7+
public static void AddApiClient(this IServiceCollection services, string api)
8+
{
9+
services
10+
.AddHttpClient<ApiClient>("ApiHttpClient")
11+
.ConfigureHttpClient(client =>
12+
{
13+
client.BaseAddress = new Uri(api);
14+
});
15+
}
16+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<ItemGroup>
4+
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
5+
<PackageReference Include="Microsoft.Extensions.Http" />
6+
</ItemGroup>
7+
8+
<ItemGroup>
9+
<ProjectReference Include="..\TeamUp.Contracts\TeamUp.Contracts.csproj" />
10+
</ItemGroup>
11+
12+
</Project>
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
@page "/login"
2+
3+
@rendermode InteractiveAuto
4+
5+
@layout EmptyLayout
6+
7+
@inherits CancellableComponent
8+
9+
@using System.ComponentModel.DataAnnotations
10+
@using System.Text.Json
11+
@using BitzArt.Blazor.Cookies
12+
@using TeamUp.ApiLayer
13+
@using TeamUp.Client.Components
14+
@using TeamUp.Client.Services
15+
@using TeamUp.Contracts
16+
@using TeamUp.Contracts.Users
17+
18+
@inject NavigationManager NavigationManager
19+
@inject ApiClient ApiClient
20+
@inject AuthenticationStateProvider AuthenticationStateProvider
21+
@inject ICookieService CookieService
22+
@inject IToastService ToastService
23+
24+
<PageTitle>Login</PageTitle>
25+
26+
<div class="d-flex flex-dir-column h-screen">
27+
<FluentSpacer />
28+
<FluentCard Style="max-width: 550px; margin-bottom: 200px" Class="h-auto margin-auto padding-large">
29+
<h1 style="margin-bottom: 16px">Login</h1>
30+
<FluentDivider />
31+
<EditForm Model="@Input" OnValidSubmit="LoginAsync" FormName="LoginForm" Style="margin-top: 16px">
32+
<CustomValidation @ref="ValidationComponent" />
33+
<FluentStack Orientation="Orientation.Vertical">
34+
<FluentTextField @bind-value="Input.Email" Immediate="true" AutoComplete="username" Required="true" Placeholder="name@example.com" Label="Email" Class="w-100" />
35+
<FluentValidationMessage For="() => Input.Email" Class="text-danger" />
36+
37+
<FluentTextField @bind-value="Input.Password" Immediate="true" AutoComplete="current-password" Required="true" Placeholder="password" Label="Password" type="password" Class="w-100" />
38+
<FluentValidationMessage For="() => Input.Password" Class="text-danger" />
39+
40+
<FluentStack Orientation="Orientation.Horizontal" Style="margin-top: 8px">
41+
<FluentButton Type="ButtonType.Submit" Appearance="Appearance.Accent">Log in</FluentButton>
42+
<FluentAnchor Href="register" Appearance="Appearance.Lightweight">Register</FluentAnchor>
43+
</FluentStack>
44+
</FluentStack>
45+
</EditForm>
46+
</FluentCard>
47+
<FluentSpacer />
48+
</div>
49+
50+
@code
51+
{
52+
[SupplyParameterFromQuery]
53+
private string? ReturnUrl { get; set; }
54+
55+
[SupplyParameterFromForm]
56+
private LoginRequest Input { get; set; } = new()
57+
{
58+
Email = "",
59+
Password = ""
60+
};
61+
62+
private CustomValidation ValidationComponent { get; set; } = null!;
63+
64+
65+
protected override async Task OnInitializedAsync()
66+
{
67+
var state = await AuthenticationStateProvider.GetAuthenticationStateAsync();
68+
if (state.User.Identity?.IsAuthenticated == true)
69+
{
70+
Redirect();
71+
}
72+
}
73+
74+
public async Task LoginAsync()
75+
{
76+
var result = await ApiClient.LoginAsync(Input, CTS.Token);
77+
if (result.IsFailure)
78+
{
79+
if (result.Error is ApiValidationError error)
80+
{
81+
ValidationComponent.DisplayErrors(error.Errors);
82+
}
83+
84+
ToastService.ShowError(result.Error.Message);
85+
Input.Password = "";
86+
return;
87+
}
88+
89+
await CookieService.SetAsync("JWT", result.Value, null, CTS.Token);
90+
if (AuthenticationStateProvider is PersistentAuthenticationStateProvider persistAuthProvider)
91+
{
92+
persistAuthProvider.Login(result.Value);
93+
Redirect();
94+
return;
95+
}
96+
97+
Redirect(true);
98+
}
99+
100+
private void Redirect(bool forceLoad = false) => NavigationManager.NavigateTo(ReturnUrl ?? "/", forceLoad);
101+
}

0 commit comments

Comments
 (0)