From 56a56175c7edd50c21ad5e1a0e6f717e1ff43aab Mon Sep 17 00:00:00 2001
From: Steven Giesel <stgiesel35@gmail.com>
Date: Tue, 29 Oct 2024 22:01:28 +0100
Subject: [PATCH 1/2] feat: Allow Member only posts

---
 src/LinkDotNet.Blog.Domain/BlogPost.cs        |   5 +
 .../OpenIdConnect/AuthExtensions.cs           |   6 +
 .../Features/AboutMe/AboutMePage.razor        |   5 +-
 .../Components/CreateNewBlogPost.razor        | 155 +++++++++---------
 .../Components/CreateNewModel.cs              |   8 +
 .../Admin/BlogPostEditor/CreateBlogPost.razor |   2 +-
 .../Features/Admin/Sitemap/SitemapPage.razor  |  14 +-
 .../Features/Components/ShortBlogPost.razor   |   4 +-
 .../Home/Components/AccessControl.razor       |  58 +++----
 .../Features/Home/Index.razor                 |   2 +-
 .../Components/BlogPostAdminActions.razor     |   2 +-
 .../ShowBlogPost/ShowBlogPostPage.razor       |  73 ++++++++-
 src/LinkDotNet.Blog.Web/Program.cs            |   2 +-
 .../RavenDb/BlogPostRepositoryTests.cs        |   9 +-
 .../Sql/BlogPostRepositoryTests.cs            |   9 +-
 .../SmokeTests.cs                             |   1 +
 .../Web/Features/Home/IndexTests.cs           |   1 +
 .../ShowBlogPost/ShowBlogPostPageTests.cs     |  36 +++-
 .../Shared/Admin/BlogPostAdminActionsTests.cs |   2 +-
 .../BlogPostBuilder.cs                        |   8 +
 .../Domain/BlogPostTests.cs                   |  13 +-
 .../Home/Components/AccessControlTests.cs     |  16 +-
 22 files changed, 292 insertions(+), 139 deletions(-)

diff --git a/src/LinkDotNet.Blog.Domain/BlogPost.cs b/src/LinkDotNet.Blog.Domain/BlogPost.cs
index 725efb27..0233874e 100644
--- a/src/LinkDotNet.Blog.Domain/BlogPost.cs
+++ b/src/LinkDotNet.Blog.Domain/BlogPost.cs
@@ -36,6 +36,8 @@ public sealed partial class BlogPost : Entity
 
     public int ReadingTimeInMinutes { get; private set; }
 
+    public bool IsMembersOnly { get; private set; }
+
     public string Slug => GenerateSlug();
 
     private string GenerateSlug()
@@ -89,6 +91,7 @@ public static BlogPost Create(
         string content,
         string previewImageUrl,
         bool isPublished,
+        bool isMembersOnly,
         DateTime? updatedDate = null,
         DateTime? scheduledPublishDate = null,
         IEnumerable<string>? tags = null,
@@ -113,6 +116,7 @@ public static BlogPost Create(
             IsPublished = isPublished,
             Tags = tags?.Select(t => t.Trim()).ToImmutableArray() ?? [],
             ReadingTimeInMinutes = ReadingTimeCalculator.CalculateReadingTime(content),
+            IsMembersOnly = isMembersOnly,
         };
 
         return blogPost;
@@ -141,6 +145,7 @@ public void Update(BlogPost from)
         PreviewImageUrl = from.PreviewImageUrl;
         PreviewImageUrlFallback = from.PreviewImageUrlFallback;
         IsPublished = from.IsPublished;
+        IsMembersOnly = from.IsMembersOnly;
         Tags = from.Tags;
         ReadingTimeInMinutes = from.ReadingTimeInMinutes;
     }
diff --git a/src/LinkDotNet.Blog.Web/Authentication/OpenIdConnect/AuthExtensions.cs b/src/LinkDotNet.Blog.Web/Authentication/OpenIdConnect/AuthExtensions.cs
index 551e8e6a..e05ed96b 100644
--- a/src/LinkDotNet.Blog.Web/Authentication/OpenIdConnect/AuthExtensions.cs
+++ b/src/LinkDotNet.Blog.Web/Authentication/OpenIdConnect/AuthExtensions.cs
@@ -53,6 +53,12 @@ public static void UseAuthentication(this IServiceCollection services)
             };
         });
 
+        services.AddAuthorization(options =>
+        {
+            options.AddPolicy("Admin", policy => policy.RequireRole("Admin"));
+            options.AddPolicy("Member", policy => policy.RequireRole("Member"));
+        });
+
         services.AddHttpContextAccessor();
         services.AddScoped<ILoginManager, AuthLoginManager>();
     }
diff --git a/src/LinkDotNet.Blog.Web/Features/AboutMe/AboutMePage.razor b/src/LinkDotNet.Blog.Web/Features/AboutMe/AboutMePage.razor
index 25bc392c..1d945f5a 100644
--- a/src/LinkDotNet.Blog.Web/Features/AboutMe/AboutMePage.razor
+++ b/src/LinkDotNet.Blog.Web/Features/AboutMe/AboutMePage.razor
@@ -32,7 +32,8 @@
 
     protected override async Task OnInitializedAsync()
     {
-        var userIdentity = (await AuthenticationStateProvider.GetAuthenticationStateAsync()).User.Identity;
-	    isAuthenticated = userIdentity?.IsAuthenticated ?? false;
+	    var principal = (await AuthenticationStateProvider.GetAuthenticationStateAsync()).User;
+	    var userIdentity = principal.Identity;
+	    isAuthenticated = (userIdentity?.IsAuthenticated ?? false) && principal.IsInRole("Admin");
     }
 }
diff --git a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPost.razor b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPost.razor
index 7e8c1c4f..fd8bd02c 100644
--- a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPost.razor
+++ b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPost.razor
@@ -32,85 +32,84 @@
                               @bind-Value="@model.Content"></MarkdownTextArea>
             <ValidationMessage For="() => model.Content"></ValidationMessage>
 
-            <div class="btn-group position-absolute bottom-0 end-0 m-5 extra-buttons">
-                <button class="btn btn-primary btn-outlined btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
-                    More
-                </button>
-                <ul class="dropdown-menu">
-                    @if (shortCodes.Count > 0)
-                    {
-                        <li>
-                            <button type="button" @onclick="OpenShortCodeDialog" class="dropdown-item">
-                                <span>Get shortcode</span>
-                            </button>
-                        </li>
-                    }
-                    <li><button type="button" class="dropdown-item" @onclick="FeatureDialog.Open">Experimental Features</button></li>
+			<div class="btn-group position-absolute bottom-0 end-0 m-5 extra-buttons">
+				<button class="btn btn-primary btn-outlined btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
+					More
+				</button>
+				<ul class="dropdown-menu">
+					@if (shortCodes.Count > 0)
+					{
+						<li>
+							<button type="button" @onclick="OpenShortCodeDialog" class="dropdown-item">
+								<span>Get shortcode</span>
+							</button>
+						</li>
+					}
+					<li><button type="button" class="dropdown-item" @onclick="FeatureDialog.Open">Experimental Features</button></li>
                     <li><button id="convert" type="button" class="dropdown-item" @onclick="ConvertContent">@ConvertLabel <i class="lab"></i></button></li>
-                </ul>
-            </div>
-        </div>
-        <div class="form-floating mb-3">
-            <InputText type="url" class="form-control" id="preview" placeholder="Preview-Url" @bind-Value="model.PreviewImageUrl" />
-            <label for="preview">Preview-Url</label>
-            <small for="preview" class="form-text text-body-secondary">The primary image which will be used.</small>
-            <ValidationMessage For="() => model.PreviewImageUrl"></ValidationMessage>
-        </div>
-        <div class="form-floating mb-3">
-            <InputText type="url" class="form-control" id="fallback-preview" placeholder="Fallback Preview-Url" @bind-Value="model.PreviewImageUrlFallback" />
-            <label for="fallback-preview">Fallback Preview-Url</label>
-            <small for="fallback-preview" class="form-text text-body-secondary">
-                Optional: Used as a fallback if the preview image can't be used by the browser.
-                <br>For example using a jpg or png as fallback for avif which is not supported in Safari or Edge.
-            </small>
-            <ValidationMessage For="() => model.PreviewImageUrlFallback"></ValidationMessage>
-        </div>
-        <div class="form-floating mb-3">
-            <InputDate Type="InputDateType.DateTimeLocal" class="form-control" id="scheduled"
-                       placeholder="Scheduled Publish Date" @bind-Value="model.ScheduledPublishDate"
-                       @bind-Value:after="@(() => model.IsPublished &= !IsScheduled)" />
-            <label for="scheduled">Scheduled Publish Date</label>
-            <small for="scheduled" class="form-text text-body-secondary">
-                If set the blog post will be published at the given date.
-                A blog post with a schedule date can't be set to published.
-            </small>
-            <ValidationMessage For="() => model.ScheduledPublishDate"></ValidationMessage>
-        </div>
-        <div class="form-check form-switch mb-3">
-            <InputCheckbox class="form-check-input" id="published" @bind-Value="model.IsPublished" />
-            <label class="form-check-label" for="published">Publish</label><br />
-            <small for="published" class="form-text text-body-secondary">If this blog post is only draft or it will be scheduled, uncheck the box.</small>
-            <ValidationMessage For="() => model.IsPublished"></ValidationMessage>
-        </div>
-        <div class="form-floating mb-3">
-            <InputText type="text" class="form-control" id="tags" placeholder="Tags" @bind-Value="model.Tags" />
-            <label for="tags">Tags (Comma separated)</label>
-        </div>
-        @if (BlogPost is not null && !IsScheduled)
-        {
-            <div class="form-check form-switch mb-3">
-                <InputCheckbox class="form-check-input" id="updatedate" @bind-Value="model.ShouldUpdateDate" />
-                <label class="form-check-label" for="updatedate">Update Publish Date</label><br />
-                <small for="updatedate" class="form-text text-body-secondary">
-                    If set the publish date is set to now,
-                    otherwise its original date.
-                </small>
-            </div>
-        }
-        <div class="mb-3">
-            <button class="btn btn-primary position-relative" type="submit" disabled="@(!canSubmit)">Submit</button>
-            <div class="alert alert-info text-muted form-text mt-3 mb-3">
-                The first page of the blog is cached. Therefore, the blog post is not immediately visible.
-                Head over to <a href="/settings">settings</a> to invalidate the cache or enable the checkmark.
-                <br>
-                The option should be enabled if you want to publish the blog post immediately and it should be visible on the first page.
-            </div>
-            <div class="form-check form-switch mb-3">
-                <InputCheckbox class="form-check-input" id="invalidate-cache" @bind-Value="model.ShouldInvalidateCache" />
-                <label class="form-check-label" for="invalidate-cache">Make it visible immediately</label><br />
-            </div>
-        </div>
-    </EditForm>
+				</ul>
+			</div>
+		</div>
+		<div class="form-floating mb-3">
+			<InputText type="url" class="form-control" id="preview" placeholder="Preview-Url" @bind-Value="model.PreviewImageUrl"/>
+			<label for="preview">Preview-Url</label>
+			<small for="preview" class="form-text text-body-secondary">The primary image which will be used.</small>
+			<ValidationMessage For="() => model.PreviewImageUrl"></ValidationMessage>
+		</div>
+		<div class="form-floating mb-3">
+			<InputText type="url" class="form-control" id="fallback-preview" placeholder="Fallback Preview-Url" @bind-Value="model.PreviewImageUrlFallback"/>
+			<label for="fallback-preview">Fallback Preview-Url</label>
+			<small for="fallback-preview" class="form-text text-body-secondary">Optional: Used as a fallback if the preview image can't be used by the browser.
+				<br>For example using a jpg or png as fallback for avif which is not supported in Safari or Edge.</small>
+			<ValidationMessage For="() => model.PreviewImageUrlFallback"></ValidationMessage>
+		</div>
+		<div class="form-floating mb-3">
+			<InputDate Type="InputDateType.DateTimeLocal" class="form-control" id="scheduled"
+			           placeholder="Scheduled Publish Date" @bind-Value="model.ScheduledPublishDate"
+			           @bind-Value:after="@(() => model.IsPublished &= !IsScheduled)"/>
+			<label for="scheduled">Scheduled Publish Date</label>
+			<small for="scheduled" class="form-text text-body-secondary">If set the blog post will be published at the given date.
+				A blog post with a schedule date can't be set to published.</small>
+			<ValidationMessage For="() => model.ScheduledPublishDate"></ValidationMessage>
+		</div>
+		<div class="form-check form-switch mb-3">
+			<InputCheckbox class="form-check-input" id="published" @bind-Value="model.IsPublished"/>
+			<label class="form-check-label" for="published">Publish</label><br/>
+			<small for="published" class="form-text text-body-secondary">If this blog post is only draft or it will be scheduled, uncheck the box.</small>
+			<ValidationMessage For="() => model.IsPublished"></ValidationMessage>
+		</div>
+		<div class="form-floating mb-3">
+			<InputText type="text" class="form-control" id="tags" placeholder="Tags" @bind-Value="model.Tags"/>
+			<label for="tags">Tags</label>
+		</div>
+		<div class="form-check form-switch mb-3">
+			<InputCheckbox class="form-check-input" id="members-only"  @bind-Value="model.IsMembersOnly" />
+			<label class="form-check-label" for="members-only">Members only?</label><br/>
+			<small for="updatedate" class="form-text text-body-secondary">The blog post can only be read by members.</small>
+		</div>
+		@if (BlogPost is not null && !IsScheduled)
+		{
+		<div class="form-check form-switch mb-3">
+			<InputCheckbox class="form-check-input" id="updatedate" @bind-Value="model.ShouldUpdateDate" />
+			<label class="form-check-label" for="updatedate">Update Publish Date</label><br/>
+			<small for="updatedate" class="form-text text-body-secondary">If set the publish date is set to now,
+				otherwise its original date.</small>
+		</div>
+		}
+		<div class="mb-3">
+			<button class="btn btn-primary position-relative" type="submit" disabled="@(!canSubmit)">Submit</button>
+			<div class="alert alert-info text-muted form-text mt-3 mb-3">
+				The first page of the blog is cached. Therefore, the blog post is not immediately visible.
+				Head over to <a href="/settings">settings</a> to invalidate the cache or enable the checkmark.
+				<br>
+				The option should be enabled if you want to publish the blog post immediately and it should be visible on the first page.
+			</div>
+			<div class="form-check form-switch mb-3">
+				<InputCheckbox class="form-check-input" id="invalidate-cache" @bind-Value="model.ShouldInvalidateCache"/>
+				<label class="form-check-label" for="invalidate-cache">Make it visible immediately</label><br/>
+			</div>
+		</div>
+	</EditForm>
 </div>
 
 <FeatureInfoDialog @ref="FeatureDialog"></FeatureInfoDialog>
diff --git a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewModel.cs b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewModel.cs
index 6c7c5153..6e92ca9c 100644
--- a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewModel.cs
+++ b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewModel.cs
@@ -18,6 +18,7 @@ public sealed class CreateNewModel
     private string tags = string.Empty;
     private string previewImageUrlFallback = string.Empty;
     private DateTime? scheduledPublishDate;
+    private bool isMembersOnly;
 
     [Required]
     [MaxLength(256)]
@@ -64,6 +65,12 @@ public bool ShouldUpdateDate
         set => SetProperty(out shouldUpdateDate, value);
     }
 
+    public bool IsMembersOnly
+    {
+        get => isMembersOnly;
+        set => SetProperty(out isMembersOnly, value);
+    }
+
     [FutureDateValidation]
     public DateTime? ScheduledPublishDate
     {
@@ -128,6 +135,7 @@ public BlogPost ToBlogPost()
             Content,
             PreviewImageUrl,
             IsPublished,
+            IsMembersOnly,
             updatedDate,
             scheduledPublishDate,
             tagList,
diff --git a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/CreateBlogPost.razor b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/CreateBlogPost.razor
index 26e190b4..6728c76a 100644
--- a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/CreateBlogPost.razor
+++ b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/CreateBlogPost.razor
@@ -1,5 +1,5 @@
 @page "/create"
-@attribute [Authorize]
+@attribute [Authorize(Roles = "Admin")]
 @using LinkDotNet.Blog.Domain
 @using LinkDotNet.Blog.Infrastructure.Persistence
 @using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Components
diff --git a/src/LinkDotNet.Blog.Web/Features/Admin/Sitemap/SitemapPage.razor b/src/LinkDotNet.Blog.Web/Features/Admin/Sitemap/SitemapPage.razor
index 2304b0b7..9f2bcd52 100644
--- a/src/LinkDotNet.Blog.Web/Features/Admin/Sitemap/SitemapPage.razor
+++ b/src/LinkDotNet.Blog.Web/Features/Admin/Sitemap/SitemapPage.razor
@@ -1,7 +1,7 @@
 @page "/Sitemap"
 @using LinkDotNet.Blog.Web.Features.Admin.Sitemap.Services
 @inject ISitemapService SitemapService
-@attribute [Authorize]
+@attribute [Authorize(Roles = "Admin")]
 
 <PageTitle>Sitemap</PageTitle>
 
@@ -14,12 +14,12 @@
 			If you get a 404 there is currently no sitemap.xml</p>
 		<button class="btn btn-primary" @onclick="CreateSitemap" disabled="@isGenerating">Create Sitemap</button>
 
-		@if (isGenerating)
-		{
-			<Loading></Loading>
-		}
-		@if (sitemapUrlSet is not null)
-		{
+			@if (isGenerating)
+			{
+				<Loading></Loading>
+			}
+			@if (sitemapUrlSet is not null)
+			{
 			<table class="table table-striped table-hover h-50">
 				<thead>
 				<tr>
diff --git a/src/LinkDotNet.Blog.Web/Features/Components/ShortBlogPost.razor b/src/LinkDotNet.Blog.Web/Features/Components/ShortBlogPost.razor
index ed5e67a6..63669467 100644
--- a/src/LinkDotNet.Blog.Web/Features/Components/ShortBlogPost.razor
+++ b/src/LinkDotNet.Blog.Web/Features/Components/ShortBlogPost.razor
@@ -1,7 +1,7 @@
 @using LinkDotNet.Blog.Domain
 
 <article>
-	<div class="blog-card @AltCssClass">
+	<div class="blog-card @AltCssClass @(BlogPost.IsMembersOnly ? "border border-warning" : "")">
 		<div class="meta">
 			<div class="photo">
 				<PreviewImage PreviewImageUrl="@BlogPost.PreviewImageUrl"
@@ -37,7 +37,7 @@
 			<h2></h2>
 			<p>@MarkdownConverter.ToMarkupString(BlogPost.ShortDescription)</p>
 			<p class="read-more">
-                <a href="/blogPost/@BlogPost.Id/@BlogPost.Slug" aria-label="@BlogPost.Title">Read the whole article</a>
+				<a href="/blogPost/@BlogPost.Id/@BlogPost.Slug" aria-label="@BlogPost.Title">Read the whole article</a>
 			</p>
 		</div>
 	</div>
diff --git a/src/LinkDotNet.Blog.Web/Features/Home/Components/AccessControl.razor b/src/LinkDotNet.Blog.Web/Features/Home/Components/AccessControl.razor
index 87760dd2..f6870fdf 100644
--- a/src/LinkDotNet.Blog.Web/Features/Home/Components/AccessControl.razor
+++ b/src/LinkDotNet.Blog.Web/Features/Home/Components/AccessControl.razor
@@ -1,31 +1,33 @@
-<AuthorizeView>
-    <Authorized>
-        <li class="nav-item dropdown">
-            <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown"
-               aria-expanded="false">
-                <i class="user-tie"></i> Admin
-            </a>
-            <ul class="dropdown-menu ps-0" aria-labelledby="navbarDropdown">
-                <li><h6 class="dropdown-header">Blog posts</h6></li>
-                <li><a class="dropdown-item" href="create">Create new</a></li>
-                <li><a class="dropdown-item" href="draft">Show drafts</a></li>
-                <li><a class="dropdown-item" href="settings">Show settings</a></li>
-                <li><hr class="dropdown-divider"></li>
-                <li><h6 class="dropdown-header">Analytics</h6></li>
-                <li><a class="dropdown-item" href="dashboard">Dashboard</a></li>
-                <li><hr class="dropdown-divider"></li>
-                <li><h6 class="dropdown-header">Others</h6></li>
-                <li><a class="dropdown-item" href="short-codes">Shortcodes</a></li>
-                <li><a class="dropdown-item" href="Sitemap">Sitemap</a></li>
-                <li><hr class="dropdown-divider"></li>
-	            <li><a class="dropdown-item" target="_blank" href="https://github.com/linkdotnet/Blog/releases" rel="noreferrer">Releases</a></li>
-            </ul>
-        </li>
-        <li class="nav-item"><a class="nav-link" href="logout?redirectUri=@CurrentUri"><i class="lock"></i> Log out</a></li>
-    </Authorized>
-    <NotAuthorized>
-        <li class="nav-item"><a class="nav-link" href="login?redirectUri=@CurrentUri" rel="nofollow"><i class="unlocked"></i> Log in</a></li>
-    </NotAuthorized>
+<AuthorizeView Roles="Admin,Member">
+	<Authorized>
+		<AuthorizeView Roles="Admin" Context="AdminContext">
+			<li class="nav-item dropdown" id="admin-actions">
+				<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown"
+				   aria-expanded="false">
+					<i class="user-tie"></i> Admin
+				</a>
+				<ul class="dropdown-menu ps-0" aria-labelledby="navbarDropdown">
+					<li><h6 class="dropdown-header">Blog posts</h6></li>
+					<li><a class="dropdown-item" href="create">Create new</a></li>
+					<li><a class="dropdown-item" href="draft">Show drafts</a></li>
+					<li><a class="dropdown-item" href="settings">Show settings</a></li>
+					<li><hr class="dropdown-divider"></li>
+					<li><h6 class="dropdown-header">Analytics</h6></li>
+					<li><a class="dropdown-item" href="dashboard">Dashboard</a></li>
+					<li><hr class="dropdown-divider"></li>
+					<li><h6 class="dropdown-header">Others</h6></li>
+					<li><a class="dropdown-item" href="short-codes">Shortcodes</a></li>
+					<li><a class="dropdown-item" href="Sitemap">Sitemap</a></li>
+					<li><hr class="dropdown-divider"></li>
+					<li><a class="dropdown-item" target="_blank" href="https://github.com/linkdotnet/Blog/releases" rel="noreferrer">Releases</a></li>
+				</ul>
+			</li>
+		</AuthorizeView>
+		<li class="nav-item"><a class="nav-link" href="logout?redirectUri=@CurrentUri"><i class="lock"></i> Log out</a></li>
+	</Authorized>
+	<NotAuthorized>
+		<li class="nav-item"><a class="nav-link" href="login?redirectUri=@CurrentUri" rel="nofollow"><i class="unlocked"></i> Log in</a></li>
+	</NotAuthorized>
 </AuthorizeView>
 
 @code {
diff --git a/src/LinkDotNet.Blog.Web/Features/Home/Index.razor b/src/LinkDotNet.Blog.Web/Features/Home/Index.razor
index 8997b330..c2a98a6a 100644
--- a/src/LinkDotNet.Blog.Web/Features/Home/Index.razor
+++ b/src/LinkDotNet.Blog.Web/Features/Home/Index.razor
@@ -21,7 +21,7 @@
         AbsolutePreviewImageUrl="@ImageUrl"
         Description="@(Markdown.ToPlainText(Introduction.Value.Description))"></OgData>
 <section>
-    <IntroductionCard></IntroductionCard>
+	<IntroductionCard></IntroductionCard>
 </section>
 
 <section>
diff --git a/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/Components/BlogPostAdminActions.razor b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/Components/BlogPostAdminActions.razor
index 5b166f21..003f58fc 100644
--- a/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/Components/BlogPostAdminActions.razor
+++ b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/Components/BlogPostAdminActions.razor
@@ -6,7 +6,7 @@
 @inject IRepository<BlogPost> BlogPostRepository
 @inject IInstantJobRegistry InstantJobRegistry
 
-<AuthorizeView>
+<AuthorizeView Roles="Admin">
 	<div class="d-flex justify-content-start gap-2">
 		<a id="edit-blogpost" type="button" class="btn btn-primary d-flex align-items-center gap-2" href="update/@BlogPostId" aria-label="edit">
 			<i class="pencil"></i>
diff --git a/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/ShowBlogPostPage.razor b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/ShowBlogPostPage.razor
index cfddd3eb..149319ac 100644
--- a/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/ShowBlogPostPage.razor
+++ b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/ShowBlogPostPage.razor
@@ -14,6 +14,7 @@
 @inject IOptions<ApplicationConfiguration> AppConfiguration
 @inject IOptions<ProfileInformation> ProfileInformation
 @inject IOptions<SupportMeConfiguration> SupportConfiguration
+@inject AuthenticationStateProvider AuthenticationStateProvider
 
 @if (isLoading)
 {
@@ -40,6 +41,8 @@ else if (BlogPost is not null)
 
 	<div class="d-flex justify-content-center pt-2 blog-outer-box">
 		<div class="blog-container">
+			@if (hasPermission)
+			{
 			<div class="blog-inner-content">
 				<header class="text-center">
 					<h1 class="fw-bold">@BlogPost.Title</h1></header>
@@ -76,6 +79,13 @@ else if (BlogPost is not null)
 					@(EnrichWithShortCodes(BlogPost.Content))
 				</div>
 			</div>
+			}
+			else
+			{
+				<div class="alert alert-warning text-center" role="alert">
+					<h1 class="fs-3">This content is only available for members.</h1>
+				</div>
+			}
 			<div class="d-flex justify-content-between py-2 border-top border-bottom align-items-center">
 				<Like BlogPost="@BlogPost" OnBlogPostLiked="@UpdateLikes"></Like>
 				<ShareBlogPost></ShareBlogPost>
@@ -86,9 +96,58 @@ else if (BlogPost is not null)
             }
             @if (AppConfiguration.Value.ShowSimilarPosts)
 			{
-				<SimilarBlogPostSection BlogPost="@BlogPost" />
+				<div class="blog-inner-content">
+					<header class="text-center">
+						<h1 class="fw-bold">@BlogPost.Title</h1></header>
+					<div class="text-dark-emphasis d-flex flex-wrap gap-2">
+						<div class="me-2">
+							<span class="date"></span>
+							<span class="ms-1">@BlogPost.UpdatedDate.ToShortDateString()</span>
+						</div>
+						@if (BlogPost.Tags is not null && BlogPost.Tags.Any())
+						{
+							<div class="d-flex align-items-center">
+								<span class="blogpost-tag me-2"></span>
+								<div class="d-flex flex-wrap gap-2">
+									@foreach (var tag in BlogPost.Tags)
+									{
+										<a class="goto-tag badge bg-primary rounded-pill text-decoration-none" href="/searchByTag/@(Uri.EscapeDataString(tag))">@tag</a>
+									}
+								</div>
+							</div>
+						}
+					</div>
+
+					<div class="pt-2">
+						<BlogPostAdminActions BlogPostId="@BlogPostId"></BlogPostAdminActions>
+					</div>
+
+					<div class="pt-2">
+						<TableOfContents Content="@BlogPost.Content" CurrentUri="@NavigationManager.Uri"></TableOfContents>
+					</div>
+
+					<div class="blogpost-content">
+						@(EnrichWithShortCodes(BlogPost.Content))
+					</div>
+				</div>
+				<div class="d-flex justify-content-between py-2 border-top border-bottom align-items-center">
+					<Like BlogPost="@BlogPost" OnBlogPostLiked="@UpdateLikes"></Like>
+					<ShareBlogPost></ShareBlogPost>
+				</div>
+				<DonationSection></DonationSection>
+				@if (AppConfiguration.Value.ShowSimilarPosts)
+				{
+					<SimilarBlogPostSection BlogPost="@BlogPost"/>
+				}
+
+				<CommentSection></CommentSection>
+			}
+			else
+			{
+				<div class="alert alert-warning text-center" role="alert">
+					<h1 class="fs-3">This content is only available for members.</h1>
+				</div>
 			}
-			<CommentSection></CommentSection>
 		</div>
 	</div>
 
@@ -110,6 +169,7 @@ else if (BlogPost is not null)
 	private string OgDataImage => BlogPost!.PreviewImageUrlFallback ?? BlogPost.PreviewImageUrl;
 	private string BlogPostCanoncialUrl => $"blogPost/{BlogPost?.Id}";
 	private IReadOnlyCollection<ShortCode> shortCodes = [];
+	private bool hasPermission;
 
 	private BlogPost? BlogPost { get; set; }
 
@@ -122,6 +182,15 @@ else if (BlogPost is not null)
     {
 	    isLoading = true;
 	    BlogPost = await BlogPostRepository.GetByIdAsync(BlogPostId);
+	    if (BlogPost?.IsMembersOnly ?? false)
+	    {
+		    var state = await AuthenticationStateProvider.GetAuthenticationStateAsync();
+		    hasPermission = state.User.IsInRole("Admin") || state.User.IsInRole("Member");
+	    }
+	    else
+	    {
+		    hasPermission = true;
+	    }
 	    isLoading = false;
     }
 
diff --git a/src/LinkDotNet.Blog.Web/Program.cs b/src/LinkDotNet.Blog.Web/Program.cs
index 2c08dc54..ecdf2da5 100644
--- a/src/LinkDotNet.Blog.Web/Program.cs
+++ b/src/LinkDotNet.Blog.Web/Program.cs
@@ -37,7 +37,7 @@ private static void RegisterServices(WebApplicationBuilder builder)
             .AddResponseCompression()
             .AddHealthCheckSetup();
 
-        if (builder.Environment.IsDevelopment())
+        if (!builder.Environment.IsDevelopment())
         {
             builder.Services.UseDummyAuthentication();
         }
diff --git a/tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/RavenDb/BlogPostRepositoryTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/RavenDb/BlogPostRepositoryTests.cs
index 7e0ddfe4..eaa7a686 100644
--- a/tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/RavenDb/BlogPostRepositoryTests.cs
+++ b/tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/RavenDb/BlogPostRepositoryTests.cs
@@ -27,7 +27,7 @@ public BlogPostRepositoryTests()
     [Fact]
     public async Task ShouldLoadBlogPost()
     {
-        var blogPost = BlogPost.Create("Title", "Subtitle", "Content", "url", true, tags: new[] { "Tag 1", "Tag 2" });
+        var blogPost = BlogPost.Create("Title", "Subtitle", "Content", "url", true, true, tags: new[] { "Tag 1", "Tag 2" });
         await SaveBlogPostAsync(blogPost);
 
         var blogPostFromRepo = await sut.GetByIdAsync(blogPost.Id);
@@ -38,6 +38,7 @@ public async Task ShouldLoadBlogPost()
         blogPostFromRepo.Content.ShouldBe("Content");
         blogPostFromRepo.PreviewImageUrl.ShouldBe("url");
         blogPostFromRepo.IsPublished.ShouldBeTrue();
+        blogPostFromRepo.IsMembersOnly.ShouldBeTrue();
         blogPostFromRepo.Tags.Count.ShouldBe(2);
         var tagContent = blogPostFromRepo.Tags;
         tagContent.ShouldContain("Tag 1");
@@ -87,7 +88,7 @@ public async Task ShouldSort()
     [Fact]
     public async Task ShouldSaveBlogPost()
     {
-        var blogPost = BlogPost.Create("Title", "Subtitle", "Content", "url", true, tags: new[] { "Tag 1", "Tag 2" });
+        var blogPost = BlogPost.Create("Title", "Subtitle", "Content", "url", true, true, tags: new[] { "Tag 1", "Tag 2" });
 
         await sut.StoreAsync(blogPost);
 
@@ -97,6 +98,7 @@ public async Task ShouldSaveBlogPost()
         blogPostFromContext.ShortDescription.ShouldBe("Subtitle");
         blogPostFromContext.Content.ShouldBe("Content");
         blogPostFromContext.IsPublished.ShouldBeTrue();
+        blogPostFromContext.IsMembersOnly.ShouldBeTrue();
         blogPostFromContext.PreviewImageUrl.ShouldBe("url");
         blogPostFromContext.Tags.Count.ShouldBe(2);
         var tagContent = blogPostFromContext.Tags;
@@ -107,7 +109,7 @@ public async Task ShouldSaveBlogPost()
     [Fact]
     public async Task ShouldGetAllBlogPosts()
     {
-        var blogPost = BlogPost.Create("Title", "Subtitle", "Content", "url", true, tags: new[] { "Tag 1", "Tag 2" });
+        var blogPost = BlogPost.Create("Title", "Subtitle", "Content", "url", true, true, tags: new[] { "Tag 1", "Tag 2" });
         await SaveBlogPostAsync(blogPost);
 
         var blogPostsFromRepo = await sut.GetAllAsync();
@@ -120,6 +122,7 @@ public async Task ShouldGetAllBlogPosts()
         blogPostFromRepo.Content.ShouldBe("Content");
         blogPostFromRepo.PreviewImageUrl.ShouldBe("url");
         blogPostFromRepo.IsPublished.ShouldBeTrue();
+        blogPostFromRepo.IsMembersOnly.ShouldBeTrue();
         blogPostFromRepo.Tags.Count.ShouldBe(2);
         var tagContent = blogPostFromRepo.Tags;
         tagContent.ShouldContain("Tag 1");
diff --git a/tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/Sql/BlogPostRepositoryTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/Sql/BlogPostRepositoryTests.cs
index 9fe8fd06..a3166b0e 100644
--- a/tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/Sql/BlogPostRepositoryTests.cs
+++ b/tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/Sql/BlogPostRepositoryTests.cs
@@ -14,7 +14,7 @@ public sealed class BlogPostRepositoryTests : SqlDatabaseTestBase<BlogPost>
     [Fact]
     public async Task ShouldLoadBlogPost()
     {
-        var blogPost = BlogPost.Create("Title", "Subtitle", "Content", "url", true, tags: new[] { "Tag 1", "Tag 2" });
+        var blogPost = BlogPost.Create("Title", "Subtitle", "Content", "url", true, true, tags: new[] { "Tag 1", "Tag 2" });
         await DbContext.BlogPosts.AddAsync(blogPost, TestContext.Current.CancellationToken);
         await DbContext.SaveChangesAsync(TestContext.Current.CancellationToken);
 
@@ -26,6 +26,7 @@ public async Task ShouldLoadBlogPost()
         blogPostFromRepo.Content.ShouldBe("Content");
         blogPostFromRepo.PreviewImageUrl.ShouldBe("url");
         blogPostFromRepo.IsPublished.ShouldBeTrue();
+        blogPostFromRepo.IsMembersOnly.ShouldBeTrue();
         blogPostFromRepo.Tags.Count.ShouldBe(2);
         var tagContent = blogPostFromRepo.Tags;
         tagContent.ShouldContain("Tag 1");
@@ -35,7 +36,7 @@ public async Task ShouldLoadBlogPost()
     [Fact]
     public async Task ShouldSaveBlogPost()
     {
-        var blogPost = BlogPost.Create("Title", "Subtitle", "Content", "url", true, tags: new[] { "Tag 1", "Tag 2" });
+        var blogPost = BlogPost.Create("Title", "Subtitle", "Content", "url", true, true, tags: new[] { "Tag 1", "Tag 2" });
 
         await Repository.StoreAsync(blogPost);
 
@@ -48,6 +49,7 @@ public async Task ShouldSaveBlogPost()
         blogPostFromContext.ShortDescription.ShouldBe("Subtitle");
         blogPostFromContext.Content.ShouldBe("Content");
         blogPostFromContext.IsPublished.ShouldBeTrue();
+        blogPostFromContext.IsMembersOnly.ShouldBeTrue();
         blogPostFromContext.PreviewImageUrl.ShouldBe("url");
         blogPostFromContext.Tags.Count.ShouldBe(2);
         var tagContent = blogPostFromContext.Tags;
@@ -58,7 +60,7 @@ public async Task ShouldSaveBlogPost()
     [Fact]
     public async Task ShouldGetAllBlogPosts()
     {
-        var blogPost = BlogPost.Create("Title", "Subtitle", "Content", "url", true, tags: new[] { "Tag 1", "Tag 2" });
+        var blogPost = BlogPost.Create("Title", "Subtitle", "Content", "url", true, true, tags: new[] { "Tag 1", "Tag 2" });
         await DbContext.BlogPosts.AddAsync(blogPost, TestContext.Current.CancellationToken);
         await DbContext.SaveChangesAsync(TestContext.Current.CancellationToken);
 
@@ -72,6 +74,7 @@ public async Task ShouldGetAllBlogPosts()
         blogPostFromRepo.Content.ShouldBe("Content");
         blogPostFromRepo.PreviewImageUrl.ShouldBe("url");
         blogPostFromRepo.IsPublished.ShouldBeTrue();
+        blogPostFromRepo.IsMembersOnly.ShouldBeTrue();
         blogPostFromRepo.Tags.Count.ShouldBe(2);
         var tagContent = blogPostFromRepo.Tags;
         tagContent.ShouldContain("Tag 1");
diff --git a/tests/LinkDotNet.Blog.IntegrationTests/SmokeTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/SmokeTests.cs
index 3cd6a487..67a286ac 100644
--- a/tests/LinkDotNet.Blog.IntegrationTests/SmokeTests.cs
+++ b/tests/LinkDotNet.Blog.IntegrationTests/SmokeTests.cs
@@ -28,6 +28,7 @@ public SmokeTests(WebApplicationFactory<Program> factory)
             builder.UseSetting("BlogName", "Tests Title");
             builder.UseSetting("PersistenceProvider", PersistenceProvider.Sqlite.Key);
             builder.UseSetting("ConnectionString", "DataSource=file::memory:?cache=shared");
+            builder.UseSetting("Authentication:ClientId", "ClientId");
         });
         
         scope = this.factory.Services.CreateScope();
diff --git a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Home/IndexTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Home/IndexTests.cs
index b52f3ff3..ff51620e 100644
--- a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Home/IndexTests.cs
+++ b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Home/IndexTests.cs
@@ -72,6 +72,7 @@ public async Task ShouldLoadOnlyItemsInPage()
     {
         await CreatePublishedBlogPosts(11);
         using var ctx = new BunitContext();
+        
         ctx.JSInterop.Mode = JSRuntimeMode.Loose;
         RegisterComponents(ctx);
 
diff --git a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/ShowBlogPost/ShowBlogPostPageTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/ShowBlogPost/ShowBlogPostPageTests.cs
index 22923471..0e7729e9 100644
--- a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/ShowBlogPost/ShowBlogPostPageTests.cs
+++ b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/ShowBlogPost/ShowBlogPostPageTests.cs
@@ -1,4 +1,6 @@
-using System.Threading.Tasks;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using AngleSharp.Dom;
 using Blazored.Toast.Services;
 using LinkDotNet.Blog.Domain;
 using LinkDotNet.Blog.Infrastructure;
@@ -137,6 +139,38 @@ public async Task ShortCodesShouldBeReplacedByTheirContent()
         cut.Find(".blogpost-content > p").TextContent.ShouldBe("This is a Content shortcode");
     }
 
+    [Fact]
+    public async Task MembersOnlyShouldOnlyBeVisibleForMembers()
+    {
+        using var ctx = new BunitContext();
+        ctx.JSInterop.Mode = JSRuntimeMode.Loose;
+        ctx.AddAuthorization().SetAuthorized("s").SetRoles("Member");
+        RegisterComponents(ctx);
+        var blogPost = new BlogPostBuilder().WithContent("Member Content").IsPublished().WithIsMembersOnly().Build();
+        await Repository.StoreAsync(blogPost);
+        
+        var cut = ctx.Render<ShowBlogPostPage>(
+            p => p.Add(b => b.BlogPostId, blogPost.Id));
+        
+        cut.Find(".blogpost-content").TextContent.ShouldContain("Member Content");
+    }
+    
+    [Fact]
+    public async Task MembersOnlyShouldNotOnlyBeVisibleForNonMembers()
+    {
+        using var ctx = new BunitContext();
+        ctx.JSInterop.Mode = JSRuntimeMode.Loose;
+        ctx.AddAuthorization();
+        RegisterComponents(ctx);
+        var blogPost = new BlogPostBuilder().WithContent("Member Content").IsPublished().WithIsMembersOnly().Build();
+        await Repository.StoreAsync(blogPost);
+        
+        var cut = ctx.Render<ShowBlogPostPage>(
+            p => p.Add(b => b.BlogPostId, blogPost.Id));
+        
+        cut.FindAll(".blogpost-content").Count.ShouldBe(0);
+    }
+
     private void RegisterComponents(BunitContext ctx, ILocalStorageService? localStorageService = null)
     {
         ctx.Services.AddScoped(_ => Repository);
diff --git a/tests/LinkDotNet.Blog.IntegrationTests/Web/Shared/Admin/BlogPostAdminActionsTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/Web/Shared/Admin/BlogPostAdminActionsTests.cs
index c55e827f..29257a72 100644
--- a/tests/LinkDotNet.Blog.IntegrationTests/Web/Shared/Admin/BlogPostAdminActionsTests.cs
+++ b/tests/LinkDotNet.Blog.IntegrationTests/Web/Shared/Admin/BlogPostAdminActionsTests.cs
@@ -16,7 +16,7 @@ public BlogPostAdminActionsTests()
         Services.AddSingleton(Substitute.For<IRepository<BlogPost>>());
         Services.AddSingleton(Substitute.For<IToastService>());
         Services.AddSingleton(Substitute.For<IInstantJobRegistry>());
-        AddAuthorization().SetAuthorized("s");
+        AddAuthorization().SetAuthorized("s").SetRoles("Admin");
     }
     
     [Fact]
diff --git a/tests/LinkDotNet.Blog.TestUtilities/BlogPostBuilder.cs b/tests/LinkDotNet.Blog.TestUtilities/BlogPostBuilder.cs
index 82c4e544..99abb154 100644
--- a/tests/LinkDotNet.Blog.TestUtilities/BlogPostBuilder.cs
+++ b/tests/LinkDotNet.Blog.TestUtilities/BlogPostBuilder.cs
@@ -15,6 +15,7 @@ public class BlogPostBuilder
     private int likes;
     private DateTime? updateDate;
     private DateTime? scheduledPublishDate;
+    private bool isMembersOnly;
 
     public BlogPostBuilder WithTitle(string title)
     {
@@ -75,6 +76,12 @@ public BlogPostBuilder WithScheduledPublishDate(DateTime scheduledPublishDate)
         this.scheduledPublishDate = scheduledPublishDate;
         return this;
     }
+    
+    public BlogPostBuilder WithIsMembersOnly(bool isMembersOnly = true)
+    {
+        this.isMembersOnly = isMembersOnly;
+        return this;
+    }
 
     public BlogPost Build()
     {
@@ -84,6 +91,7 @@ public BlogPost Build()
             content,
             previewImageUrl,
             isPublished,
+            isMembersOnly,
             updateDate,
             scheduledPublishDate,
             tags,
diff --git a/tests/LinkDotNet.Blog.UnitTests/Domain/BlogPostTests.cs b/tests/LinkDotNet.Blog.UnitTests/Domain/BlogPostTests.cs
index 0edc6e73..1f7a4fd9 100644
--- a/tests/LinkDotNet.Blog.UnitTests/Domain/BlogPostTests.cs
+++ b/tests/LinkDotNet.Blog.UnitTests/Domain/BlogPostTests.cs
@@ -12,7 +12,7 @@ public void ShouldUpdateBlogPost()
     {
         var blogPostToUpdate = new BlogPostBuilder().Build();
         blogPostToUpdate.Id = "random-id";
-        var blogPost = BlogPost.Create("Title", "Desc", "Other Content", "Url", true, previewImageUrlFallback: "Url2");
+        var blogPost = BlogPost.Create("Title", "Desc", "Other Content", "Url", true, true, previewImageUrlFallback: "Url2");
         blogPost.Id = "something else";
 
         blogPostToUpdate.Update(blogPost);
@@ -23,6 +23,7 @@ public void ShouldUpdateBlogPost()
         blogPostToUpdate.PreviewImageUrl.ShouldBe("Url");
         blogPostToUpdate.PreviewImageUrlFallback.ShouldBe("Url2");
         blogPostToUpdate.IsPublished.ShouldBeTrue();
+        blogPostToUpdate.IsMembersOnly.ShouldBeTrue();
         blogPostToUpdate.Tags.ShouldBeEmpty();
         blogPostToUpdate.Slug.ShouldNotBeNull();
         blogPostToUpdate.ReadingTimeInMinutes.ShouldBe(1);
@@ -65,7 +66,7 @@ public void ShouldUpdateTagsWhenExisting()
     [Fact]
     public void ShouldTrimWhitespacesFromTags()
     {
-        var blogPost = BlogPost.Create("Title", "Sub", "Content", "Preview", false, tags: new[] { " Tag 1", " Tag 2 ", });
+        var blogPost = BlogPost.Create("Title", "Sub", "Content", "Preview", false, false, tags: new[] { " Tag 1", " Tag 2 ", });
 
         blogPost.Tags.ShouldContain("Tag 1");
         blogPost.Tags.ShouldContain("Tag 2");
@@ -76,7 +77,7 @@ public void ShouldSetDateWhenGiven()
     {
         var somewhen = new DateTime(1991, 5, 17);
 
-        var blog = BlogPost.Create("1", "2", "3", "4", false, somewhen);
+        var blog = BlogPost.Create("1", "2", "3", "4", false, false, somewhen);
 
         blog.UpdatedDate.ShouldBe(somewhen);
     }
@@ -108,7 +109,7 @@ public void ShouldPublishBlogPost()
     [Fact]
     public void ShouldThrowErrorWhenCreatingBlogPostThatIsPublishedAndHasScheduledPublishDate()
     {
-        Action action = () => BlogPost.Create("1", "2", "3", "4", true, scheduledPublishDate: new DateTime(2023, 3, 24));
+        Action action = () => BlogPost.Create("1", "2", "3", "4", true, true, scheduledPublishDate: new DateTime(2023, 3, 24));
 
         action.ShouldThrow<InvalidOperationException>();
     }
@@ -129,7 +130,7 @@ public void GivenScheduledPublishDate_WhenCreating_ThenUpdateDateIsScheduledPubl
     {
         var date = new DateTime(2023, 3, 24);
 
-        var bp = BlogPost.Create("1", "2", "3", "4", false, scheduledPublishDate: date);
+        var bp = BlogPost.Create("1", "2", "3", "4", false, false, scheduledPublishDate: date);
 
         bp.UpdatedDate.ShouldBe(date);
     }
@@ -139,7 +140,7 @@ public void GivenScheduledPublishDate_WhenCreating_ThenIsScheduledPublishDateIsT
     {
         var date = new DateTime(2023, 3, 24);
 
-        var bp = BlogPost.Create("1", "2", "3", "4", false, scheduledPublishDate: date);
+        var bp = BlogPost.Create("1", "2", "3", "4", false, false, scheduledPublishDate: date);
 
         bp.IsScheduled.ShouldBeTrue();
     }
diff --git a/tests/LinkDotNet.Blog.UnitTests/Web/Features/Home/Components/AccessControlTests.cs b/tests/LinkDotNet.Blog.UnitTests/Web/Features/Home/Components/AccessControlTests.cs
index 05637ee3..cbb332b9 100644
--- a/tests/LinkDotNet.Blog.UnitTests/Web/Features/Home/Components/AccessControlTests.cs
+++ b/tests/LinkDotNet.Blog.UnitTests/Web/Features/Home/Components/AccessControlTests.cs
@@ -19,7 +19,7 @@ public void ShouldShowLoginAndHideAdminWhenNotLoggedIn()
     [Fact]
     public void ShouldShowLogoutAndAdminWhenLoggedIn()
     {
-        AddAuthorization().SetAuthorized("steven");
+        AddAuthorization().SetAuthorized("steven").SetRoles("Admin");
 
         var cut = Render<AccessControl>();
 
@@ -43,11 +43,23 @@ public void LoginShouldHaveCurrentUriAsRedirectUri()
     public void LogoutShouldHaveCurrentUriAsRedirectUri()
     {
         const string currentUri = "http://localhost/test";
-        AddAuthorization().SetAuthorized("steven");
+        AddAuthorization().SetAuthorized("steven").SetRoles("Admin");
 
         var cut = Render<AccessControl>(
             p => p.Add(s => s.CurrentUri, currentUri));
 
         ((IHtmlAnchorElement)cut.Find("a:contains('Log out')")).Href.ShouldContain(currentUri);
     }
+
+    [Fact]
+    public void MembersDontHaveAdminUi()
+    {
+        const string currentUri = "http://localhost/test";
+        AddAuthorization().SetAuthorized("steven").SetRoles("Member");
+        
+        var cut = Render<AccessControl>(
+            p => p.Add(s => s.CurrentUri, currentUri));
+        
+        cut.FindAll("#admin-actions").ShouldBeEmpty();
+    }
 }
\ No newline at end of file

From 083c701ea111ab7ccd0047453b796cdb5db282e7 Mon Sep 17 00:00:00 2001
From: Steven Giesel <stgiesel35@gmail.com>
Date: Tue, 3 Dec 2024 18:39:13 +0100
Subject: [PATCH 2/2] Added Members Only EF Migration

---
 ...203173853_BlogPost_MembersOnly.Designer.cs | 247 ++++++++++++++++++
 .../20241203173853_BlogPost_MembersOnly.cs    |  29 ++
 .../Migrations/BlogDbContextModelSnapshot.cs  |   3 +
 3 files changed, 279 insertions(+)
 create mode 100644 src/LinkDotNet.Blog.Infrastructure/Migrations/20241203173853_BlogPost_MembersOnly.Designer.cs
 create mode 100644 src/LinkDotNet.Blog.Infrastructure/Migrations/20241203173853_BlogPost_MembersOnly.cs

diff --git a/src/LinkDotNet.Blog.Infrastructure/Migrations/20241203173853_BlogPost_MembersOnly.Designer.cs b/src/LinkDotNet.Blog.Infrastructure/Migrations/20241203173853_BlogPost_MembersOnly.Designer.cs
new file mode 100644
index 00000000..8b0b8903
--- /dev/null
+++ b/src/LinkDotNet.Blog.Infrastructure/Migrations/20241203173853_BlogPost_MembersOnly.Designer.cs
@@ -0,0 +1,247 @@
+// <auto-generated />
+using System;
+using LinkDotNet.Blog.Infrastructure.Persistence.Sql;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace LinkDotNet.Blog.Infrastructure.Migrations
+{
+    [DbContext(typeof(BlogDbContext))]
+    [Migration("20241203173853_BlogPost_MembersOnly")]
+    partial class BlogPost_MembersOnly
+    {
+        /// <inheritdoc />
+        protected override void BuildTargetModel(ModelBuilder modelBuilder)
+        {
+#pragma warning disable 612, 618
+            modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
+
+            modelBuilder.Entity("LinkDotNet.Blog.Domain.BlogPost", b =>
+                {
+                    b.Property<string>("Id")
+                        .ValueGeneratedOnAdd()
+                        .IsUnicode(false)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Content")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool>("IsMembersOnly")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("IsPublished")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Likes")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("PreviewImageUrl")
+                        .IsRequired()
+                        .HasMaxLength(1024)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("PreviewImageUrlFallback")
+                        .HasMaxLength(1024)
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("ReadingTimeInMinutes")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<DateTime?>("ScheduledPublishDate")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("ShortDescription")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Tags")
+                        .IsRequired()
+                        .HasMaxLength(2048)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Title")
+                        .IsRequired()
+                        .HasMaxLength(256)
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime>("UpdatedDate")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("IsPublished", "UpdatedDate")
+                        .IsDescending(false, true)
+                        .HasDatabaseName("IX_BlogPosts_IsPublished_UpdatedDate");
+
+                    b.ToTable("BlogPosts");
+                });
+
+            modelBuilder.Entity("LinkDotNet.Blog.Domain.BlogPostRecord", b =>
+                {
+                    b.Property<string>("Id")
+                        .ValueGeneratedOnAdd()
+                        .IsUnicode(false)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("BlogPostId")
+                        .IsRequired()
+                        .HasMaxLength(256)
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("Clicks")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<DateOnly>("DateClicked")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.ToTable("BlogPostRecords");
+                });
+
+            modelBuilder.Entity("LinkDotNet.Blog.Domain.ProfileInformationEntry", b =>
+                {
+                    b.Property<string>("Id")
+                        .ValueGeneratedOnAdd()
+                        .IsUnicode(false)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Content")
+                        .IsRequired()
+                        .HasMaxLength(512)
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("SortOrder")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("Id");
+
+                    b.ToTable("ProfileInformationEntries");
+                });
+
+            modelBuilder.Entity("LinkDotNet.Blog.Domain.ShortCode", b =>
+                {
+                    b.Property<string>("Id")
+                        .ValueGeneratedOnAdd()
+                        .IsUnicode(false)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("MarkdownContent")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Name")
+                        .IsRequired()
+                        .HasMaxLength(512)
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.ToTable("ShortCodes");
+                });
+
+            modelBuilder.Entity("LinkDotNet.Blog.Domain.SimilarBlogPost", b =>
+                {
+                    b.Property<string>("Id")
+                        .ValueGeneratedOnAdd()
+                        .IsUnicode(false)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("SimilarBlogPostIds")
+                        .IsRequired()
+                        .HasMaxLength(1350)
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.ToTable("SimilarBlogPosts");
+                });
+
+            modelBuilder.Entity("LinkDotNet.Blog.Domain.Skill", b =>
+                {
+                    b.Property<string>("Id")
+                        .ValueGeneratedOnAdd()
+                        .IsUnicode(false)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Capability")
+                        .IsRequired()
+                        .HasMaxLength(128)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("IconUrl")
+                        .HasMaxLength(1024)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Name")
+                        .IsRequired()
+                        .HasMaxLength(128)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("ProficiencyLevel")
+                        .IsRequired()
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.ToTable("Skills");
+                });
+
+            modelBuilder.Entity("LinkDotNet.Blog.Domain.Talk", b =>
+                {
+                    b.Property<string>("Id")
+                        .ValueGeneratedOnAdd()
+                        .IsUnicode(false)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Description")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Place")
+                        .IsRequired()
+                        .HasMaxLength(256)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("PresentationTitle")
+                        .IsRequired()
+                        .HasMaxLength(256)
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime>("PublishedDate")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.ToTable("Talks");
+                });
+
+            modelBuilder.Entity("LinkDotNet.Blog.Domain.UserRecord", b =>
+                {
+                    b.Property<string>("Id")
+                        .ValueGeneratedOnAdd()
+                        .IsUnicode(false)
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateOnly>("DateClicked")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("UrlClicked")
+                        .IsRequired()
+                        .HasMaxLength(256)
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.ToTable("UserRecords");
+                });
+#pragma warning restore 612, 618
+        }
+    }
+}
diff --git a/src/LinkDotNet.Blog.Infrastructure/Migrations/20241203173853_BlogPost_MembersOnly.cs b/src/LinkDotNet.Blog.Infrastructure/Migrations/20241203173853_BlogPost_MembersOnly.cs
new file mode 100644
index 00000000..b956349a
--- /dev/null
+++ b/src/LinkDotNet.Blog.Infrastructure/Migrations/20241203173853_BlogPost_MembersOnly.cs
@@ -0,0 +1,29 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace LinkDotNet.Blog.Infrastructure.Migrations
+{
+    /// <inheritdoc />
+    public partial class BlogPost_MembersOnly : Migration
+    {
+        /// <inheritdoc />
+        protected override void Up(MigrationBuilder migrationBuilder)
+        {
+            migrationBuilder.AddColumn<bool>(
+                name: "IsMembersOnly",
+                table: "BlogPosts",
+                type: "INTEGER",
+                nullable: false,
+                defaultValue: false);
+        }
+
+        /// <inheritdoc />
+        protected override void Down(MigrationBuilder migrationBuilder)
+        {
+            migrationBuilder.DropColumn(
+                name: "IsMembersOnly",
+                table: "BlogPosts");
+        }
+    }
+}
diff --git a/src/LinkDotNet.Blog.Infrastructure/Migrations/BlogDbContextModelSnapshot.cs b/src/LinkDotNet.Blog.Infrastructure/Migrations/BlogDbContextModelSnapshot.cs
index d5b07c7f..94bd60b4 100644
--- a/src/LinkDotNet.Blog.Infrastructure/Migrations/BlogDbContextModelSnapshot.cs
+++ b/src/LinkDotNet.Blog.Infrastructure/Migrations/BlogDbContextModelSnapshot.cs
@@ -28,6 +28,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
                         .IsRequired()
                         .HasColumnType("TEXT");
 
+                    b.Property<bool>("IsMembersOnly")
+                        .HasColumnType("INTEGER");
+
                     b.Property<bool>("IsPublished")
                         .HasColumnType("INTEGER");