Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,6 @@ appsettings.*.local.json
# Operating system
.DS_Store
Thumbs.db

# Data Protection keys of a local run
data-protection-keys/
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ Tests run against SQLite in-process. To run the PostgreSQL tests as well, set `F

To run the host, `dotnet run --project src/FluentCMS.Host` starts the web host on `http://localhost:5000` with a SQLite database in `fluentcms.db`, and `dotnet run --project src/FluentCMS.Host -- mcp` starts the stdio mode. Configuration comes from environment variables such as `Database__ConnectionString`, `Database__Provider` (`sqlite` or `postgresql`, inferred from the connection string when omitted) and `JobQueue__WorkerCount`, and from an optional `appsettings.json` next to the binary.

On a fresh database the admin asks for the first administrator at `/setup`; every other admin page needs a signed-in user. Data Protection keeps the keys that protect sign-in cookies, anti-forgery tokens and stored secrets in `DataProtection__KeysDirectory` (default `data-protection-keys` under the content root). Put it on storage that survives restarts and upgrades and that only the host's account can read; losing it signs everyone out. Sign-in and setup posts are limited per client address by `SignInRateLimit__PermitLimit` and `SignInRateLimit__Window`, ten a minute by default.

## Database migrations

Each provider has its own `DbContext` subclass and migrations folder (ADR-0005). After changing the model, add a migration for both; no database needs to be running, the design-time factories build the model on their own.
Expand Down
5 changes: 5 additions & 0 deletions src/FluentCMS.Admin/AcceptsCredentialsAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace FluentCMS.Admin;

// Marks a page whose form takes a password; the host rate limits posts to it (SEC-07).
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class AcceptsCredentialsAttribute : Attribute;
2 changes: 1 addition & 1 deletion src/FluentCMS.Admin/AdminOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ namespace FluentCMS.Admin;

public sealed class AdminOptions
{
// The /ui-spike showcase is reachable only where the host enables it (Development until F-USR adds sign-in).
// The /ui-spike showcase is reachable only where the host enables it (Development), and like every admin page only when signed in.
public bool ShowcaseEnabled { get; set; }
}
25 changes: 25 additions & 0 deletions src/FluentCMS.Admin/AdminRoutes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace FluentCMS.Admin;

// The account routes the host needs for the cookie's redirects; each matches the @page directive of its page.
public static class AdminRoutes
{
public const string Setup = "/setup";
public const string SignIn = "/sign-in";
public const string SignOut = "/sign-out";

// Only a path on this site is followed after sign-in, so a crafted link cannot send someone elsewhere.
public static bool IsLocal(string? url)
{
if (string.IsNullOrEmpty(url) || url[0] != '/')
{
return false;
}

if (url.Length > 1 && (url[1] == '/' || url[1] == '\\'))
{
return false;
}

return !url.Any(char.IsControl);
}
}
9 changes: 9 additions & 0 deletions src/FluentCMS.Admin/Components/Layout/AccountLayout.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
@inherits LayoutComponentBase

@* The setup, sign-in and sign-out pages: one centred card, no navigation. *@
<div class="flex min-h-svh flex-col items-center justify-center gap-6 bg-muted p-6 md:p-10">
<main class="flex w-full max-w-sm flex-col gap-6">
<span class="self-center font-semibold">FluentCMS</span>
@Body
</main>
</div>
11 changes: 11 additions & 0 deletions src/FluentCMS.Admin/Components/Layout/MainLayout.razor
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
@inherits LayoutComponentBase
@inject ICurrentInitiator Initiator
@inject IStringLocalizer<MainLayout> L

<div class="flex min-h-screen flex-col">
<header class="border-b">
<nav class="mx-auto flex h-14 max-w-6xl items-center gap-6 px-4" aria-label="@L["Main"]">
<a href="" class="font-semibold">FluentCMS</a>
@if (Initiator.Current is UserInitiator user)
{
@* A plain form, so it posts to the sign-out page's handler from any page, static or interactive. *@
<form method="post" action="sign-out" class="ms-auto flex items-center gap-3">
<AntiforgeryToken />
<input type="hidden" name="_handler" value="sign-out" />
<span class="text-sm text-muted-foreground" data-testid="signed-in-user">@user.DisplayName</span>
<Button Type="submit" Variant="ghost" Size="sm">@L["Sign out"]</Button>
</form>
}
</nav>
</header>
<main class="mx-auto w-full max-w-6xl flex-1 px-4 py-6">
Expand Down
1 change: 1 addition & 0 deletions src/FluentCMS.Admin/Components/Pages/NotFound.razor
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@page "/not-found"
@attribute [AllowAnonymous]
@inject IStringLocalizer<NotFound> L

<PageTitle>@L["Not found"]</PageTitle>
Expand Down
132 changes: 132 additions & 0 deletions src/FluentCMS.Admin/Components/Pages/Setup.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
@page "/setup"
@layout AccountLayout
@attribute [AllowAnonymous]
@attribute [AcceptsCredentials]
@using FluentCMS.Core.Results
@inject IFirstRunSetup FirstRun
@inject IUserSignIn UserSignIn
@inject NavigationManager Navigation
@inject IStringLocalizer<Setup> L

<PageTitle>@L["Set up FluentCMS"]</PageTitle>

<Card>
<CardHeader>
<CardTitle><h1>@L["Create the first administrator"]</h1></CardTitle>
<CardDescription>@L["This account signs in to the admin and can add the rest of the team."]</CardDescription>
</CardHeader>
<CardContent>
<form method="post" @onsubmit="SubmitAsync" @formname="setup" class="grid gap-6">
<AntiforgeryToken />
@if (_formError is not null)
{
<p role="alert" class="text-sm text-destructive">@_formError</p>
}
<FormField Label="@L["Name"]" Description="@L["Shown in the audit log next to your actions."]" Error="@ErrorFor(nameof(SetupForm.DisplayName))">
<Input name="Form.DisplayName" value="@Form.DisplayName" autocomplete="name" required />
</FormField>
<FormField Label="@L["Email"]" Error="@ErrorFor(nameof(SetupForm.Email))">
<Input Type="email" name="Form.Email" value="@Form.Email" autocomplete="username" required />
</FormField>
<FormField Label="@L["Password"]" Error="@ErrorFor(nameof(SetupForm.Password))">
<Input Type="password" name="Form.Password" autocomplete="new-password" required />
</FormField>
<FormField Label="@L["Confirm password"]" Error="@ErrorFor(nameof(SetupForm.ConfirmPassword))">
<Input Type="password" name="Form.ConfirmPassword" autocomplete="new-password" required />
</FormField>
<Button Type="submit" Class="w-full">@L["Create administrator"]</Button>
</form>
</CardContent>
</Card>

@code {
private readonly Dictionary<string, string> _errors = [];
private string? _formError;

[SupplyParameterFromForm(FormName = "setup")]
private SetupForm Form { get; set; } = default!;

protected override async Task OnInitializedAsync()
{
Form ??= new();

if (!await FirstRun.NeedsFirstAdministratorAsync())
{
Navigation.NavigateTo("sign-in");
}
}

private async Task SubmitAsync()
{
Require(nameof(SetupForm.DisplayName), Form.DisplayName, L["Enter your name."]);
Require(nameof(SetupForm.Email), Form.Email, L["Enter your email address."]);
Require(nameof(SetupForm.Password), Form.Password, L["Enter a password."]);
if (Form.Password != Form.ConfirmPassword)
{
_errors[nameof(SetupForm.ConfirmPassword)] = L["The passwords do not match."];
}

if (_errors.Count > 0)
{
return;
}

var email = Form.Email!.Trim();
var created = await FirstRun.CreateFirstAdministratorAsync(new NewUser(email, Form.DisplayName!.Trim(), Form.Password!));
if (!created.IsSuccess)
{
foreach (var error in created.Errors)
{
Report(error);
}

return;
}

var signedIn = await UserSignIn.SignInAsync(email, Form.Password!);
Navigation.NavigateTo(signedIn.IsSuccess ? "" : "sign-in");
}

private void Require(string field, string? value, string message)
{
if (string.IsNullOrWhiteSpace(value))
{
_errors[field] = message;
}
}

// Identity reports problems by code: the code is the resource key, and Identity's own message the text until a resource exists.
private void Report(Error error)
{
var localized = L[error.Code];
var message = localized.ResourceNotFound ? error.Message : localized.Value;
var field = error.Code switch
{
"InvalidEmail" or "DuplicateEmail" or "InvalidUserName" or "DuplicateUserName" => nameof(SetupForm.Email),
_ when error.Code.StartsWith("Password", StringComparison.Ordinal) => nameof(SetupForm.Password),
_ => null,
};

if (field is null)
{
_formError = _formError is null ? message : _formError + " " + message;
}
else
{
_errors[field] = _errors.TryGetValue(field, out var existing) ? existing + " " + message : message;
}
}

private string? ErrorFor(string field) => _errors.GetValueOrDefault(field);

private sealed class SetupForm
{
public string? DisplayName { get; set; }

public string? Email { get; set; }

public string? Password { get; set; }

public string? ConfirmPassword { get; set; }
}
}
73 changes: 73 additions & 0 deletions src/FluentCMS.Admin/Components/Pages/SignIn.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
@page "/sign-in"
@layout AccountLayout
@attribute [AllowAnonymous]
@attribute [AcceptsCredentials]
@inject IFirstRunSetup FirstRun
@inject IUserSignIn UserSignIn
@inject NavigationManager Navigation
@inject IStringLocalizer<SignIn> L

<PageTitle>@L["Sign in"]</PageTitle>

<Card>
<CardHeader>
<CardTitle><h1>@L["Sign in"]</h1></CardTitle>
<CardDescription>@L["Enter your email and password to sign in to the admin."]</CardDescription>
</CardHeader>
<CardContent>
<form method="post" @onsubmit="SubmitAsync" @formname="sign-in" class="grid gap-6">
<AntiforgeryToken />
@if (_failed)
{
<p role="alert" class="text-sm text-destructive" data-testid="sign-in-error">@L["Sign-in failed. Check your email and password, or try again in a few minutes."]</p>
}
<FormField Label="@L["Email"]">
<Input Type="email" name="Form.Email" value="@Form.Email" autocomplete="username" required />
</FormField>
<FormField Label="@L["Password"]">
<Input Type="password" name="Form.Password" autocomplete="current-password" required />
</FormField>
<Button Type="submit" Class="w-full">@L["Sign in"]</Button>
</form>
</CardContent>
</Card>

@code {
private bool _failed;

[SupplyParameterFromForm(FormName = "sign-in")]
private SignInForm Form { get; set; } = default!;

[SupplyParameterFromQuery]
private string? ReturnUrl { get; set; }

protected override async Task OnInitializedAsync()
{
Form ??= new();

// Nobody can sign in before the first administrator exists, so a fresh install continues at setup.
if (await FirstRun.NeedsFirstAdministratorAsync())
{
Navigation.NavigateTo("setup");
}
}

private async Task SubmitAsync()
{
var result = await UserSignIn.SignInAsync(Form.Email?.Trim() ?? string.Empty, Form.Password ?? string.Empty);
if (!result.IsSuccess)
{
_failed = true;
return;
}

Navigation.NavigateTo(AdminRoutes.IsLocal(ReturnUrl) ? ReturnUrl! : "");
}

private sealed class SignInForm
{
public string? Email { get; set; }

public string? Password { get; set; }
}
}
29 changes: 29 additions & 0 deletions src/FluentCMS.Admin/Components/Pages/SignOut.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
@page "/sign-out"
@layout AccountLayout
@inject IUserSignIn UserSignIn
@inject NavigationManager Navigation
@inject IStringLocalizer<SignOut> L

<PageTitle>@L["Sign out"]</PageTitle>

@* Signing out only happens on a post, so a link or an image on another site cannot end the session. *@
<Card>
<CardHeader>
<CardTitle><h1>@L["Sign out"]</h1></CardTitle>
<CardDescription>@L["Sign out of the admin on this browser."]</CardDescription>
</CardHeader>
<CardContent>
<form method="post" @onsubmit="SubmitAsync" @formname="sign-out">
<AntiforgeryToken />
<Button Type="submit" Class="w-full">@L["Sign out"]</Button>
</form>
</CardContent>
</Card>

@code {
private async Task SubmitAsync()
{
await UserSignIn.SignOutAsync();
Navigation.NavigateTo("sign-in");
}
}
9 changes: 9 additions & 0 deletions src/FluentCMS.Admin/Components/Ui/Card.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<div data-slot="card" class="@ClassNames.Cn("flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm", Class)" @attributes="AdditionalAttributes">
@ChildContent
</div>

@code {
[Parameter] public string? Class { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; }
[Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }
}
8 changes: 8 additions & 0 deletions src/FluentCMS.Admin/Components/Ui/CardContent.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<div data-slot="card-content" class="@ClassNames.Cn("px-6", Class)">
@ChildContent
</div>

@code {
[Parameter] public string? Class { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; }
}
8 changes: 8 additions & 0 deletions src/FluentCMS.Admin/Components/Ui/CardDescription.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<div data-slot="card-description" class="@ClassNames.Cn("text-sm text-muted-foreground", Class)">
@ChildContent
</div>

@code {
[Parameter] public string? Class { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; }
}
8 changes: 8 additions & 0 deletions src/FluentCMS.Admin/Components/Ui/CardHeader.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<div data-slot="card-header" class="@ClassNames.Cn("@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", Class)">
@ChildContent
</div>

@code {
[Parameter] public string? Class { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; }
}
9 changes: 9 additions & 0 deletions src/FluentCMS.Admin/Components/Ui/CardTitle.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
@* A div as in shadcn; a page puts its heading inside, so the heading level stays the page's choice. *@
<div data-slot="card-title" class="@ClassNames.Cn("leading-none font-semibold", Class)">
@ChildContent
</div>

@code {
[Parameter] public string? Class { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; }
}
17 changes: 17 additions & 0 deletions src/FluentCMS.Admin/Components/Ui/ClassNames.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,20 @@ public sealed class CommandContext
public string ListId => Id + "-list";
public string InputId => Id + "-input";
}

public sealed class FormFieldContext
{
public string Id { get; } = "field-" + Guid.NewGuid().ToString("N")[..8];
public string DescriptionId => Id + "-description";
public string ErrorId => Id + "-error";
public bool HasDescription { get; set; }
public bool HasError { get; set; }

public string? DescribedBy => (HasDescription, HasError) switch
{
(true, true) => DescriptionId + " " + ErrorId,
(true, false) => DescriptionId,
(false, true) => ErrorId,
_ => null,
};
}
Loading
Loading