add-blazor-project #31

Merged
jleicher merged 5 commits from add-blazor-project into master 2020-12-29 21:04:02 +01:00
27 changed files with 404 additions and 217 deletions
Showing only changes of commit 211d3fdc03 - Show all commits

View File

@ -93,7 +93,7 @@ namespace Api.Controllers
public async Task<IActionResult> AddComment(Guid id, AddCommentViewModel command)
{
var commentId = await _bus.Send(new CommentLinkCommand(id, command.Content));
return CreatedAtAction("", "", new { id = commentId }, null);
return Created($"comments/{commentId}", null);
}
/// <summary>

View File

@ -114,7 +114,12 @@ namespace Api
app.UseSwaggerUi3();
}
app.UseCors(o => o.AllowAnyOrigin());
app.UseCors(o =>
{
o.AllowAnyOrigin();
o.AllowAnyMethod();
o.WithHeaders("content-type", "authorization");
});
app.UseRouting();

View File

@ -1,6 +1,7 @@
<Router AppAssembly="@typeof(Program).Assembly">
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
</Found>
<NotFound>
<LayoutView Layout="@typeof(MainLayout)">
@ -8,3 +9,4 @@
</LayoutView>
</NotFound>
</Router>
</CascadingAuthenticationState>

View File

@ -5,6 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="5.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="5.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="5.0.0" PrivateAssets="all" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />

View File

@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.Authorization;
namespace Client
{
public sealed class CustomAuthStateProvider : AuthenticationStateProvider
{
private static string _token;
private readonly HttpClient _http;
public CustomAuthStateProvider(HttpClient http)
{
_http = http;
}
public override Task<AuthenticationState> GetAuthenticationStateAsync()
{
return Task.FromResult(AuthenticationStateFromCurrentToken());
}
public void MarkUserAsAuthenticated(string token)
{
_token = token;
NotifyAuthenticationStateChanged(Task.FromResult(AuthenticationStateFromCurrentToken()));
}
private AuthenticationState AuthenticationStateFromCurrentToken()
{
if (string.IsNullOrWhiteSpace(_token))
{
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
}
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _token);
var principal = new ClaimsPrincipal(new ClaimsIdentity(ParseClaimsFromJwt(_token), "jwt", "unique_name", null));
return new AuthenticationState(principal);
}
private IEnumerable<Claim> ParseClaimsFromJwt(string jwt)
{
var claims = new List<Claim>();
var payload = jwt.Split('.')[1];
var jsonBytes = ParseBase64WithoutPadding(payload);
var keyValuePairs = JsonSerializer.Deserialize<Dictionary<string, object>>(jsonBytes);
keyValuePairs.TryGetValue(ClaimTypes.Role, out object roles);
if (roles != null)
{
if (roles.ToString().Trim().StartsWith("["))
{
var parsedRoles = JsonSerializer.Deserialize<string[]>(roles.ToString());
foreach (var parsedRole in parsedRoles)
{
claims.Add(new Claim(ClaimTypes.Role, parsedRole));
}
}
else
{
claims.Add(new Claim(ClaimTypes.Role, roles.ToString()));
}
keyValuePairs.Remove(ClaimTypes.Role);
}
claims.AddRange(keyValuePairs.Select(kvp => new Claim(kvp.Key, kvp.Value.ToString())));
return claims;
}
private byte[] ParseBase64WithoutPadding(string base64)
{
switch (base64.Length % 4)
{
case 2: base64 += "=="; break;
case 3: base64 += "="; break;
}
return Convert.FromBase64String(base64);
}
}
}

View File

@ -0,0 +1,36 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace Client
{
public sealed class NotificationManager
{
private readonly ILogger<NotificationManager> _logger;
private Queue<string> _messages = new Queue<string>();
public IReadOnlyList<string> Messages => _messages.ToArray();
public event Action OnChange;
public NotificationManager(ILogger<NotificationManager> logger)
{
_logger = logger;
}
public void Add(string message)
{
_messages.Enqueue(message);
Task.Run(async () =>
{
await Task.Delay(5000);
_messages.Dequeue();
OnChange?.Invoke();
});
OnChange?.Invoke();
}
}
}

View File

@ -2,8 +2,10 @@
@inject MyState State
@foreach (var name in State.Names)
{
<p>@name</p>
}
<Names />
@ -12,11 +14,18 @@
<button @onclick="OnSend">Save</button>
@code {
private string CurrentValue;
private void OnSend()
{
State.AddName(CurrentValue);
CurrentValue = "";
}
}

View File

@ -1,9 +1,9 @@
@page "/"
@inject LinksClient Links
<h1>Latest links</h1>
<Title Value="Latest links" />
@if (_loading)
@if (_links == null)
{
<p>Loading...</p>
}
@ -20,20 +20,10 @@ else
}
@code {
private LinkDto[] _links = new LinkDto[] { };
private bool _loading = false;
private LinkDto[] _links;
protected override async Task OnInitializedAsync()
{
_loading = true;
try
{
_links = (await Links.GetLinksAsync()).ToArray();
}
finally
{
_loading = false;
}
}
}

View File

@ -0,0 +1,36 @@
@page "/login"
@inject AccountsClient Accounts
@inject NotificationManager Notifications
@inject NavigationManager Navigation
@inject AuthenticationStateProvider AuthStateProvider
<Title Value="Sign in!" />
<EditForm Model="@_model" OnValidSubmit="TryLogin">
<DataAnnotationsValidator />
<ValidationSummary />
<InputText @bind-Value="_model.Username" />
<InputText type="password" @bind-Value="_model.Password" />
<button type="submit">Log in!</button>
</EditForm>
@code
{
private LoginViewModel _model = new LoginViewModel();
private async Task TryLogin()
{
try
{
var token = await Accounts.LoginAsync(_model);
((CustomAuthStateProvider)AuthStateProvider).MarkUserAsAuthenticated(token);
Navigation.NavigateTo("/");
}
catch
{
Notifications.Add("login failed!");
}
}
}

View File

@ -1,5 +1,6 @@
@page "/links/{id:guid}"
@inject LinksClient Links
@inject NotificationManager Notifications
@if (Item == null)
{
@ -7,7 +8,29 @@
}
else
{
<h1>Showing link @Item.Url with @Item.UpVotes / @Item.DownVotes</h1>
<h1>@Item.Url</h1>
<div>
👍 @Item.UpVotes
👎 @Item.DownVotes
</div>
@if (Comments == null)
{
<p>Loading comments ...</p>
} else if(Comments.Count == 0)
{
<p>No comment yet!</p>
} else
{
foreach (var comment in Comments)
{
<Comment @key="comment.Id" Item="@comment" />
}
}
<AuthorizeView>
<CommentForm OnSubmit="@SubmitComment" Username="@context.User.Identity.Name" />
</AuthorizeView>
}
@code {
@ -16,9 +39,30 @@ else
private LinkDto Item;
private ICollection<CommentDto> Comments;
protected override async Task OnInitializedAsync()
{
Item = await Links.GetLinkByIdAsync(Id);
await FetchComments();
}
protected async Task FetchComments()
{
Comments = await Links.CommentsAsync(Id);
}
private async Task SubmitComment(AddCommentViewModel command)
{
try
{
await Links.AddCommentAsync(Id, command);
await FetchComments();
Notifications.Add("comment added!");
}
catch
{
Notifications.Add("could not post a comment");
}
}
}

View File

@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Components.Authorization;
namespace Client
{
@ -27,9 +28,13 @@ namespace Client
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.Services.AddAuthorizationCore();
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri("http://localhost:8888/") });
builder.Services.AddScoped<LinksClient>();
builder.Services.AddScoped<AccountsClient>();
builder.Services.AddSingleton<MyState>();
builder.Services.AddSingleton<NotificationManager>();
builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthStateProvider>();
await builder.Build().RunAsync();
}

View File

@ -0,0 +1,7 @@
<p>@Item.Content</p>
<p>- @Item.CreatedByName</p>
@code {
[Parameter]
public CommentDto Item { get; set; }
}

View File

@ -0,0 +1,35 @@
<EditForm Model="@_model" OnValidSubmit="@OnValidSubmit">
<DataAnnotationsValidator />
<InputText @bind-Value="_model.Content" />
<ValidationMessage For=@(() => _model.Content) />
<button type="submit">Comment as @_username</button>
</EditForm>
@code
{
private AddCommentViewModel _model = new AddCommentViewModel();
[Parameter]
public string Username { get; set; }
[CascadingParameter]
private Task<AuthenticationState> authenticationStateTask { get; set; }
private string _username;
[Parameter]
public EventCallback<AddCommentViewModel> OnSubmit { get; set; }
protected async override Task OnInitializedAsync()
{
_username = (await authenticationStateTask).User.Identity.Name;
}
private async Task OnValidSubmit()
{
await OnSubmit.InvokeAsync(_model);
_model = new AddCommentViewModel();
}
}

View File

@ -0,0 +1,5 @@
@inherits LayoutComponentBase
<main>
@Body
</main>

View File

@ -0,0 +1,7 @@
<p>Title was @MyValue</p>
@code
{
[CascadingParameter]
private string MyValue { get; set; }
}

View File

@ -1,17 +1,22 @@
@inherits LayoutComponentBase
<div class="page">
<div class="sidebar">
<NavMenu />
<div class="topbar">
<NavLink href="/">Home</NavLink>
<AuthorizeView>
<Authorized>
Sign in as @context.User.Identity.Name
</Authorized>
<NotAuthorized>
You're not signed in <NavLink href="/login">Sign in</NavLink>.
</NotAuthorized>
</AuthorizeView>
</div>
<div class="main">
<div class="top-row px-4">
<a href="http://blazor.net" target="_blank" class="ml-md-auto">About</a>
</div>
<div class="content px-4">
<main>
@Body
</div>
</div>
</main>
<Toasts />
</div>

View File

@ -1,70 +0,0 @@
.page {
position: relative;
display: flex;
flex-direction: column;
}
.main {
flex: 1;
}
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.top-row {
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
align-items: center;
}
.top-row ::deep a, .top-row .btn-link {
white-space: nowrap;
margin-left: 1.5rem;
}
.top-row a:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640.98px) {
.top-row:not(.auth) {
display: none;
}
.top-row.auth {
justify-content: space-between;
}
.top-row a, .top-row .btn-link {
margin-left: 0;
}
}
@media (min-width: 641px) {
.page {
flex-direction: row;
}
.sidebar {
width: 250px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row {
position: sticky;
top: 0;
z-index: 1;
}
.main > div {
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
}

View File

@ -1,4 +1,6 @@
@inject MyState State
@implements IDisposable
@inject IJSRuntime JS;
<p>Got @State.Names.Count</p>
@ -10,6 +12,7 @@
public void Dispose()
{
JS.InvokeVoidAsync("alert", "Disposed");
State.OnChange -= StateHasChanged;
}
}

View File

@ -5,7 +5,7 @@
</button>
</div>
<div class="@NavMenuCssClass" @onclick="ToggleNavMenu">
<div @onclick="ToggleNavMenu">
<ul class="nav flex-column">
<li class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">

View File

@ -1,62 +0,0 @@
.navbar-toggler {
background-color: rgba(255, 255, 255, 0.1);
}
.top-row {
height: 3.5rem;
background-color: rgba(0,0,0,0.4);
}
.navbar-brand {
font-size: 1.1rem;
}
.oi {
width: 2rem;
font-size: 1.1rem;
vertical-align: text-top;
top: -2px;
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep a {
color: #d7d7d7;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.25);
color: white;
}
.nav-item ::deep a:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.collapse {
/* Never collapse the sidebar for wide screens */
display: block;
}
}

View File

@ -0,0 +1,14 @@
@inject IJSRuntime JS
<h1>@Value</h1>
@code
{
[Parameter]
public string Value { get; set; }
protected override void OnInitialized()
{
JS.InvokeVoidAsync("setTitle", Value);
}
}

View File

@ -0,0 +1,3 @@
h1 {
color: red;
}

View File

@ -0,0 +1,20 @@
@inject NotificationManager Notifications
@implements IDisposable
@foreach (var message in Notifications.Messages)
{
<p>⚡ @message</p>
}
@code
{
protected override void OnInitialized()
{
Notifications.OnChange += StateHasChanged;
}
public void Dispose()
{
Notifications.OnChange -= StateHasChanged;
}
}

View File

@ -5,6 +5,7 @@
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.JSInterop
@using Client
@using Client.Shared

View File

@ -1,24 +1,9 @@
@import url('open-iconic/font/css/open-iconic-bootstrap.min.css');
html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
html,
body {
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
}
a, .btn-link {
color: #0366d6;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.content {
padding-top: 1.1rem;
}
.valid.modified:not([type=checkbox]) {
.valid.modified:not([type="checkbox"]) {
outline: 1px solid #26b050;
}

View File

@ -1,9 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<title>Client</title>
<base href="/" />
<link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />
@ -20,6 +22,9 @@
<a class="dismiss">🗙</a>
</div>
<script src="_framework/blazor.webassembly.js"></script>
</body>
<script>
window.setTitle = (title) => (document.title = title);
</script>
</body>
</html>

View File

@ -180,6 +180,16 @@ project.csproj
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>
## Blazor
Ajout de l'authentification :
Ajout du paquet `Microsoft.AspNetCore.Components.Authorization` et du using dans `_Imports.razor`.
Ensuite, Passage par `AuthorizeRouteView` dans le fichier `App.razor`.
Ensuite, ajout d'un `AuthenticationStateProvider` custom pour déterminer si l'utilisateur est connecté ou non.
## Docker
On build à la racine de la solution avec `docker build -f .\Apps\Website\Dockerfile -t hn .`.