Improved ArticleView permission handling
This commit is contained in:
parent
6ab9855680
commit
468f8491de
72
Wave/Components/ArticleComponent.razor
Normal file
72
Wave/Components/ArticleComponent.razor
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
@using Wave.Data
|
||||||
|
@using Humanizer
|
||||||
|
|
||||||
|
@inject IStringLocalizer<ArticleComponent> Localizer
|
||||||
|
|
||||||
|
<h1 class="text-3xl lg:text-5xl font-light">
|
||||||
|
@Article.Title
|
||||||
|
</h1>
|
||||||
|
<p class="mb-3">
|
||||||
|
<small class="text-sm text-neutral-content">
|
||||||
|
<time datetime="@Article.PublishDate.ToString("u")"
|
||||||
|
title="@Article.PublishDate.ToString("g")">
|
||||||
|
@Article.PublishDate.Humanize()
|
||||||
|
</time>
|
||||||
|
@if (Article.LastModified is not null) {
|
||||||
|
<time datetime="@Article.LastModified.Value.ToString("u")"
|
||||||
|
title="@Article.LastModified.Value.ToString("g")">
|
||||||
|
 (@Localizer["ModifiedOn"] @Article.LastModified.Humanize())
|
||||||
|
</time>
|
||||||
|
}
|
||||||
|
@if (Article.Status <= ArticleStatus.Published) {
|
||||||
|
<span class="badge badge-sm badge-outline badge-warning">
|
||||||
|
@Article.Status.Humanize()
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</small>
|
||||||
|
</p>
|
||||||
|
<AuthorizeView Policy="ArticleEditPermissions">
|
||||||
|
<Authorized>
|
||||||
|
<a class="btn btn-info mb-3" href="article/@Article.Id/edit">Edit</a>
|
||||||
|
</Authorized>
|
||||||
|
</AuthorizeView>
|
||||||
|
|
||||||
|
<hr class="my-3" />
|
||||||
|
|
||||||
|
<article class="prose prose-neutral max-w-none mb-6">
|
||||||
|
@Content
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<hr class="my-3" />
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(Article.Author.AboutTheAuthor)) {
|
||||||
|
<section class="mb-2">
|
||||||
|
<h2 class="text-2xl lg:text-4xl mb-3">About The Author</h2>
|
||||||
|
<div class="card sm:card-side card-compact bg-neutral text-neutral-content rounded shadow">
|
||||||
|
<figure class="sm:max-w-32">
|
||||||
|
<img src="/api/user/pfp/@Article.Author.Id" alt="" width="800">
|
||||||
|
</figure>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title">@Article.Author.Name</h3>
|
||||||
|
<p>
|
||||||
|
@Article.Author.AboutTheAuthor
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
@if (string.IsNullOrWhiteSpace(Article.Author.AboutTheAuthor)) {
|
||||||
|
<ProfilePill Profile="Article.Author" RoleTag="@Localizer["Author"]"/>
|
||||||
|
}
|
||||||
|
@if (Article.Reviewer is not null && Article.Reviewer.Id != Article.Author.Id) {
|
||||||
|
<ProfilePill Profile="Article.Reviewer" RoleTag="@Localizer["Reviewer"]"/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public required Article Article { get; set; }
|
||||||
|
private MarkupString Content => new(Article.BodyHtml);
|
||||||
|
}
|
|
@ -1,69 +1,35 @@
|
||||||
@page "/article/{id:guid}"
|
@page "/article/{id:guid}"
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using Wave.Data
|
@using Wave.Data
|
||||||
@using Humanizer
|
@using System.Security.Claims
|
||||||
@using System.Globalization
|
@using Microsoft.AspNetCore.Identity
|
||||||
|
@using System.Diagnostics.CodeAnalysis
|
||||||
|
|
||||||
@inject IDbContextFactory<ApplicationDbContext> ContextFactory;
|
@inject IDbContextFactory<ApplicationDbContext> ContextFactory;
|
||||||
|
@inject RoleManager<IdentityRole> RoleManager
|
||||||
@inject IStringLocalizer<ArticleView> Localizer
|
@inject IStringLocalizer<ArticleView> Localizer
|
||||||
|
|
||||||
<PageTitle>@(TitlePrefix + Article.Title)</PageTitle>
|
<PageTitle>@(TitlePrefix + Article.Title)</PageTitle>
|
||||||
|
|
||||||
<h1 class="text-3xl lg:text-5xl font-light">@Article.Title</h1>
|
<ErrorBoundary>
|
||||||
<p class="mb-3">
|
<ChildContent>
|
||||||
<small class="text-sm text-neutral-content">
|
<AuthorizeView Policy="ArticleEditOrReviewPermissions">
|
||||||
<time datetime="@Article.PublishDate.ToString("u")"
|
<Authorized>
|
||||||
title="@Article.PublishDate.ToString("g")">
|
<ArticleComponent Article="@GetArticleProtected(context.User)" />
|
||||||
@Article.PublishDate.Humanize()
|
</Authorized>
|
||||||
</time>
|
<NotAuthorized>
|
||||||
@if (Article.LastModified is not null) {
|
<ArticleComponent Article="@GetArticlePublic()" />
|
||||||
<time datetime="@Article.LastModified.Value.ToString("u")"
|
</NotAuthorized>
|
||||||
title="@Article.LastModified.Value.ToString("g")">
|
</AuthorizeView>
|
||||||
 (@Localizer["ModifiedOn"] @Article.LastModified.Humanize())
|
</ChildContent>
|
||||||
</time>
|
<ErrorContent>
|
||||||
|
<h1 class="text-3xl lg:text-5xl font-light mb-6">@Localizer["NotFound_Title"]</h1>
|
||||||
|
<p class="my-3">@Localizer["NotFound_Description"]</p>
|
||||||
|
@if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")?.ToLower() == "development") {
|
||||||
|
<p>@context.Message</p>
|
||||||
}
|
}
|
||||||
</small>
|
</ErrorContent>
|
||||||
</p>
|
</ErrorBoundary>
|
||||||
<AuthorizeView Policy="ArticleEditPermissions">
|
|
||||||
<Authorized>
|
|
||||||
<a class="btn btn-info mb-3" href="article/@Article.Id/edit">Edit</a>
|
|
||||||
</Authorized>
|
|
||||||
</AuthorizeView>
|
|
||||||
|
|
||||||
<hr class="my-3" />
|
|
||||||
|
|
||||||
<article class="prose prose-neutral max-w-none mb-6">
|
|
||||||
@Content
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<hr class="my-3" />
|
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(Article.Author.AboutTheAuthor)) {
|
|
||||||
<section class="mb-2">
|
|
||||||
<h2 class="text-2xl lg:text-4xl mb-3">About The Author</h2>
|
|
||||||
<div class="card sm:card-side card-compact bg-neutral text-neutral-content rounded shadow">
|
|
||||||
<figure class="sm:max-w-32">
|
|
||||||
<img src="/api/user/pfp/@Article.Author.Id" alt="" width="800">
|
|
||||||
</figure>
|
|
||||||
<div class="card-body">
|
|
||||||
<h3 class="card-title">@Article.Author.Name</h3>
|
|
||||||
<p>
|
|
||||||
@Article.Author.AboutTheAuthor
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="flex gap-2">
|
|
||||||
@if (string.IsNullOrWhiteSpace(Article.Author.AboutTheAuthor)) {
|
|
||||||
<ProfilePill Profile="Article.Author" RoleTag="@Localizer["Author"]"/>
|
|
||||||
}
|
|
||||||
@if (Article.Reviewer is not null && Article.Reviewer.Id != Article.Author.Id) {
|
|
||||||
<ProfilePill Profile="Article.Reviewer" RoleTag="@Localizer["Reviewer"]"/>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[CascadingParameter(Name = "TitlePrefix")]
|
[CascadingParameter(Name = "TitlePrefix")]
|
||||||
|
@ -71,9 +37,39 @@
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
|
private Article Article { get; set; } = default!;
|
||||||
|
|
||||||
private Article Article { get; set; } = null!;
|
private Article GetArticlePublic() {
|
||||||
private MarkupString Content => new(Article.BodyHtml);
|
if (Article.Status >= ArticleStatus.Published && Article.PublishDate <= DateTimeOffset.UtcNow) {
|
||||||
|
return Article;
|
||||||
|
}
|
||||||
|
throw new ApplicationException("Article is not public.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[SuppressMessage("ReSharper", "ConvertIfStatementToSwitchStatement")]
|
||||||
|
private Article GetArticleProtected(ClaimsPrincipal principal) {
|
||||||
|
// Admins always get access
|
||||||
|
if (principal.IsInRole("Admin")) {
|
||||||
|
return Article;
|
||||||
|
}
|
||||||
|
|
||||||
|
// You cannot access your own drafts
|
||||||
|
if (Article.Status is ArticleStatus.Draft) {
|
||||||
|
if (Article.Author.Id == principal.FindFirst("Id")!.Value) {
|
||||||
|
return Article;
|
||||||
|
}
|
||||||
|
throw new ApplicationException("Cannot access draft article without being author or admin.");
|
||||||
|
}
|
||||||
|
// InReview Articles can only be accessed by reviewers
|
||||||
|
if (Article.Status is ArticleStatus.InReview) {
|
||||||
|
if (principal.IsInRole("Reviewer")) {
|
||||||
|
return Article;
|
||||||
|
}
|
||||||
|
throw new ApplicationException("Cannot access in-review article without being a reviewer or admin.");
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ApplicationException("User does not have access to this article.");
|
||||||
|
}
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync() {
|
protected override async Task OnInitializedAsync() {
|
||||||
// We need blocking calls here, bc otherwise Blazor will execute Render in parallel,
|
// We need blocking calls here, bc otherwise Blazor will execute Render in parallel,
|
||||||
|
@ -86,7 +82,7 @@
|
||||||
Article = context.Set<Article>()
|
Article = context.Set<Article>()
|
||||||
.Include(a => a.Author)
|
.Include(a => a.Author)
|
||||||
.Include(a => a.Reviewer)
|
.Include(a => a.Reviewer)
|
||||||
.Where(a => a.Status >= ArticleStatus.Published && a.PublishDate <= now)
|
// .Where(a => a.Status >= ArticleStatus.Published && a.PublishDate <= now)
|
||||||
.First(a => a.Id == Id);
|
.First(a => a.Id == Id);
|
||||||
|
|
||||||
if (Article is null) throw new ApplicationException("Article not found.");
|
if (Article is null) throw new ApplicationException("Article not found.");
|
||||||
|
|
|
@ -36,7 +36,9 @@
|
||||||
.AddPolicy("ArticleEditPermissions", p => p.RequireRole("Author", "Admin"))
|
.AddPolicy("ArticleEditPermissions", p => p.RequireRole("Author", "Admin"))
|
||||||
.AddPolicy("ArticleReviewPermissions", p => p.RequireRole("Reviewer", "Admin"))
|
.AddPolicy("ArticleReviewPermissions", p => p.RequireRole("Reviewer", "Admin"))
|
||||||
.AddPolicy("ArticleDeletePermissions", p => p.RequireRole("Moderator", "Admin"))
|
.AddPolicy("ArticleDeletePermissions", p => p.RequireRole("Moderator", "Admin"))
|
||||||
.AddPolicy("RoleAssignPermissions", p => p.RequireRole("Admin"));
|
.AddPolicy("RoleAssignPermissions", p => p.RequireRole("Admin"))
|
||||||
|
|
||||||
|
.AddPolicy("ArticleEditOrReviewPermissions", p => p.RequireRole("Author", "Reviewer", "Admin"));
|
||||||
builder.Services.AddAuthentication(options => {
|
builder.Services.AddAuthentication(options => {
|
||||||
options.DefaultScheme = IdentityConstants.ApplicationScheme;
|
options.DefaultScheme = IdentityConstants.ApplicationScheme;
|
||||||
options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
|
options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
|
||||||
|
|
11
Wave/Utilities/ClaimsPrincipalUtilities.cs
Normal file
11
Wave/Utilities/ClaimsPrincipalUtilities.cs
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace Wave.Utilities;
|
||||||
|
|
||||||
|
public static class ClaimsPrincipalUtilities {
|
||||||
|
public static bool IsInRole(this ClaimsPrincipal principal, string roleName) {
|
||||||
|
return principal.Claims.Any(
|
||||||
|
c => c.Type == ClaimTypes.Role &&
|
||||||
|
string.Equals(c.Value, roleName,StringComparison.CurrentCultureIgnoreCase));
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue