diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05e5d09..670628b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,6 +124,17 @@ jobs: grep -q '"/counter"' Components/UI/AppSidebar.razor || (echo "AppSidebar links were not built from the pages"; exit 1) dotnet build -c Debug + cd "$TMPDIR/app" + dotnet new blazor -o IdentityApp --auth Individual --no-restore + cd IdentityApp + shellui init --tailwind standalone --yes --dashboard 02 + grep -q '' Components/UI/AppSidebar.razor || (echo "the sidebar footer has no account menu"; exit 1) + grep -q 'StartsWithSegments("/Account")' Components/App.razor || (echo "Identity pages are not kept static"; exit 1) + shellui add auth-01 + grep -q '@layout IdentityApp.Components.Layout.AuthLayout01' Components/Account/Pages/_Imports.razor || (echo "auth-01 was not set as the sign-in layout"; exit 1) + ! grep -q 'class="form-control"' Components/Account/Pages/Login.razor || (echo "Login.razor was not restyled"; exit 1) + dotnet build -c Debug + # Catches missing dependencies that per-template tests can't see. ALL=$(python3 - "$GITHUB_WORKSPACE" <<'PY' import os, re, sys diff --git a/README.md b/README.md index 30b6fbd..22961b7 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ ShellUI is pre-1.0, so APIs and generated output can still change between minor ## Capabilities - The CLI commands are `init`, `add`, `list`, `remove`, and `update`, plus `theme init`, `theme apply`, and `theme update`. -- The component registry has **239 entries**: **90 direct install targets** and **149 hidden dependency entries**. `list` shows direct targets; `add` resolves hidden dependencies. +- The component registry has **243 entries**: **93 direct install targets** and **150 hidden dependency entries**. `list` shows direct targets; `add` resolves hidden dependencies. - `0.3.0` added `typed-select`, `command-palette`, `data-picker`, `multi-select`, `tag-input`, `donut-chart`, `radar-chart`, and `radial-chart`. - `ShellUI.Components` supports a release-generated precompiled CSS bundle and a generated safelist for existing Tailwind builds. - The CLI can install source with Tailwind's standalone executable or an npm-based build. The current Tailwind baseline is `4.3.2`. diff --git a/ShellUI.Tests/AccountPagesTests.cs b/ShellUI.Tests/AccountPagesTests.cs new file mode 100644 index 0000000..20f001e --- /dev/null +++ b/ShellUI.Tests/AccountPagesTests.cs @@ -0,0 +1,111 @@ +using System; +using System.IO; +using System.Runtime.CompilerServices; +using ShellUI.CLI.Services; +using Xunit; + +namespace ShellUI.Tests; + +public class AccountPagesTests +{ + [Theory] + [InlineData("net10", "Pages/Login.razor")] + [InlineData("net10", "Pages/Manage/Email.razor")] + [InlineData("net10", "Shared/ManageLayout.razor")] + [InlineData("net10", "Shared/ManageNavMenu.razor")] + [InlineData("net8", "Pages/Login.razor")] + [InlineData("net8", "Shared/ManageLayout.razor")] + public void Restyle_RemovesBootstrapFromStockPages(string framework, string file) + { + var content = Fixture(framework, file); + Assert.True(AccountPages.IsStock(file, content, DashboardSetup.StockProjectName)); + + var restyled = AccountPages.Restyle(content); + + Assert.False(AccountPages.UsesBootstrap(restyled)); + Assert.DoesNotContain("class=\"row\"", restyled); + Assert.DoesNotContain("

", restyled); + Assert.DoesNotContain("
", restyled); + Assert.False(AccountPages.IsStock(file, restyled, DashboardSetup.StockProjectName)); + } + + [Theory] + [InlineData("MyApp")] + [InlineData("my_app")] + public void IsStock_IgnoresTheProjectNamespace(string rootNamespace) + { + var content = Fixture("net10", "Pages/Login.razor").Replace(DashboardSetup.StockProjectName, rootNamespace); + + Assert.True(AccountPages.IsStock("Pages/Login.razor", content, rootNamespace)); + Assert.False(AccountPages.IsStock("Pages/Login.razor", content + "\n

mine

", rootNamespace)); + } + + [Fact] + public void Restyle_KeepsRazorExpressionsInsideTags() + { + var restyled = AccountPages.Restyle(Fixture("net10", "Pages/Login.razor")); + + Assert.Contains(" { [\"ReturnUrl\"] = ReturnUrl }))\">", restyled); + Assert.Contains("@bind-Value=\"Input.Email\"", restyled); + Assert.Contains("OnSubmit=\"LoginUser\"", restyled); + } + + [Fact] + public void Restyle_StatusMessageUsesThemeColors() + { + var restyled = AccountPages.Restyle(Fixture("net10", "Shared/StatusMessage.razor")); + + Assert.DoesNotContain("alert-@", restyled); + Assert.Contains("\"text-destructive\"", restyled); + Assert.Contains("@statusMessageClass\"", restyled); + } + + [Theory] + [InlineData("btn btn-primary", "bg-primary", "h-10 px-4 py-2")] + [InlineData("w-100 btn btn-lg btn-primary", "w-full", "h-11 px-8")] + [InlineData("btn btn-danger", "bg-destructive", "h-10 px-4 py-2")] + public void MapClasses_BuildsShellUIButtons(string bootstrap, string variant, string size) + { + var mapped = AccountPages.MapClasses(bootstrap); + + Assert.Contains(variant, mapped); + Assert.Contains(size, mapped); + Assert.DoesNotContain("btn", mapped.Split(' ')); + Assert.DoesNotContain("w-100", mapped.Split(' ')); + } + + [Fact] + public void Apply_RestylesStockAccountPagesAndReportsEditedOnes() + { + var root = Path.Combine(Path.GetTempPath(), "shellui-account-" + Guid.NewGuid().ToString("N")); + try + { + var login = Fixture("net10", "Pages/Login.razor").Replace(DashboardSetup.StockProjectName, "MyApp"); + Write(root, "Components/Account/Pages/Login.razor", login); + Write(root, "Components/Account/Shared/ManageLayout.razor", Fixture("net10", "Shared/ManageLayout.razor").Replace(DashboardSetup.StockProjectName, "MyApp") + "\n

mine

"); + Write(root, "Components/Pages/Counter.razor", ""); + + var result = AccountPages.Apply(root, "MyApp"); + + Assert.Equal(new[] { "Components/Account/Pages/Login.razor" }, result.Restyled); + Assert.False(AccountPages.UsesBootstrap(File.ReadAllText(Path.Combine(root, "Components/Account/Pages/Login.razor")))); + Assert.Contains(result.Notes, n => n.Contains("Components/Account/Shared/ManageLayout.razor") && n.Contains("modified")); + Assert.Single(result.Notes); + Assert.Contains("btn btn-primary", File.ReadAllText(Path.Combine(root, "Components/Pages/Counter.razor"))); + } + finally + { + Directory.Delete(root, true); + } + } + + private static void Write(string root, string relative, string content) + { + var path = Path.Combine(root, relative); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + } + + private static string Fixture(string framework, string file, [CallerFilePath] string thisFile = "") => + File.ReadAllText(Path.Combine(Path.GetDirectoryName(thisFile)!, "Fixtures", "StockBlazor", framework, "Account", file + ".txt")); +} diff --git a/ShellUI.Tests/AuthSetupTests.cs b/ShellUI.Tests/AuthSetupTests.cs new file mode 100644 index 0000000..fc2cc4d --- /dev/null +++ b/ShellUI.Tests/AuthSetupTests.cs @@ -0,0 +1,61 @@ +using ShellUI.CLI.Services; +using ShellUI.Templates; +using Xunit; + +namespace ShellUI.Tests; + +public class AuthSetupTests +{ + private const string Layout = "MyApp.Components.Layout.AuthLayout01"; + + [Theory] + [InlineData("\n")] + [InlineData("\r\n")] + public void SetLayout_AddsTheLayoutWhereNoneIsSet(string newline) + { + var imports = $"@using MyApp.Components.Account.Shared{newline}@attribute [ExcludeFromInteractiveRouting]{newline}"; + + var updated = AuthSetup.SetLayout(imports, Layout); + + Assert.Equal($"@using MyApp.Components.Account.Shared{newline}@attribute [ExcludeFromInteractiveRouting]{newline}@layout {Layout}{newline}", updated); + Assert.Equal(updated, AuthSetup.SetLayout(updated!, Layout)); + } + + [Theory] + [InlineData("@layout AccountLayout")] + [InlineData("@layout MyApp.Components.Layout.AuthLayout02")] + public void SetLayout_ReplacesTheNet8AccountLayoutOrAnotherAuthBlock(string directive) + { + var updated = AuthSetup.SetLayout($"@using MyApp.Components.Account.Shared\r\n{directive}\r\n", Layout); + + Assert.Equal($"@using MyApp.Components.Account.Shared\r\n@layout {Layout}\r\n", updated); + } + + [Fact] + public void SetLayout_LeavesACustomLayoutAlone() + { + Assert.Null(AuthSetup.SetLayout("@layout MyApp.Components.Layout.SignInLayout\n", Layout)); + } + + [Theory] + [InlineData("Components/Layout", "MyApp.Components.Layout.AuthLayout01")] + [InlineData("Layouts", "MyApp.Layouts.AuthLayout01")] + public void LayoutTypeName_FollowsTheLayoutPath(string layoutPath, string expected) + { + Assert.Equal(expected, AuthSetup.LayoutTypeName("MyApp", layoutPath, "AuthLayout01")); + } + + [Fact] + public void EveryAuthBlock_IsAnInstallableLayout() + { + foreach (var (name, layout) in AuthSetup.Layouts) + { + var metadata = ComponentRegistry.GetMetadata(name); + Assert.NotNull(metadata); + Assert.True(metadata!.IsAvailable); + Assert.True(metadata.IsLayoutBlock); + Assert.Equal($"{layout}.razor", metadata.FilePath); + Assert.Contains("@Body", ComponentRegistry.GetComponentContent(name)); + } + } +} diff --git a/ShellUI.Tests/DashboardSetupTests.cs b/ShellUI.Tests/DashboardSetupTests.cs index 0b6f486..93d5d99 100644 --- a/ShellUI.Tests/DashboardSetupTests.cs +++ b/ShellUI.Tests/DashboardSetupTests.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; using ShellUI.CLI.Services; using ShellUI.Core.Models; using ShellUI.Templates; @@ -196,6 +197,39 @@ public void AppSidebarTemplate_LinksAreRewrittenFromPages() Assert.Null(DashboardSetup.RewriteSidebarLinks(rewritten!, links)); } + [Fact] + public void AppSidebarTemplate_FooterBecomesTheAccountMenu() + { + var sidebar = ComponentRegistry.GetComponentContent("app-sidebar")!; + var templateFooter = Regex.Match(sidebar, ".*?", RegexOptions.Singleline).Value; + + var rewritten = DashboardSetup.RewriteSidebarFooter(sidebar, templateFooter); + + Assert.NotNull(rewritten); + Assert.Contains("", rewritten); + Assert.DoesNotContain("user@example.com", rewritten); + Assert.Null(DashboardSetup.RewriteSidebarFooter(sidebar.Replace("user@example.com", "me@company.com"), templateFooter)); + } + + [Fact] + public void IsIdentityApp_NeedsAccountPagesAndIdentityRegistration() + { + var root = Path.Combine(Path.GetTempPath(), "shellui-identity-" + Guid.NewGuid().ToString("N")); + try + { + Write(root, "Program.cs", "builder.Services.AddRazorComponents();"); + Write(root, "Components/Account/Pages/Login.razor", "@page \"/Account/Login\""); + Assert.False(DashboardSetup.IsIdentityApp(root)); + + Write(root, "Program.cs", "builder.Services.AddIdentityCore(options => { });"); + Assert.True(DashboardSetup.IsIdentityApp(root)); + } + finally + { + Directory.Delete(root, true); + } + } + private static void Write(string root, string relative, string content) { var path = Path.Combine(root, relative); diff --git a/ShellUI.Tests/Fixtures/StockBlazor/net10/Account/Pages/Login.razor.txt b/ShellUI.Tests/Fixtures/StockBlazor/net10/Account/Pages/Login.razor.txt new file mode 100644 index 0000000..b190238 --- /dev/null +++ b/ShellUI.Tests/Fixtures/StockBlazor/net10/Account/Pages/Login.razor.txt @@ -0,0 +1,164 @@ +@page "/Account/Login" + +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Authentication +@using Microsoft.AspNetCore.Identity +@using ShellStockProbe.Data + +@inject UserManager UserManager +@inject SignInManager SignInManager +@inject ILogger Logger +@inject NavigationManager NavigationManager +@inject IdentityRedirectManager RedirectManager + +Log in + +

Log in

+
+ +@code { + private string? errorMessage; + private EditContext editContext = default!; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [SupplyParameterFromForm] + private InputModel Input { get; set; } = default!; + + [SupplyParameterFromQuery] + private string? ReturnUrl { get; set; } + + protected override async Task OnInitializedAsync() + { + Input ??= new(); + + editContext = new EditContext(Input); + + if (HttpMethods.IsGet(HttpContext.Request.Method)) + { + // Clear the existing external cookie to ensure a clean login process + await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme); + } + } + + public async Task LoginUser() + { + if (!string.IsNullOrEmpty(Input.Passkey?.Error)) + { + errorMessage = $"Error: {Input.Passkey.Error}"; + return; + } + + SignInResult result; + if (!string.IsNullOrEmpty(Input.Passkey?.CredentialJson)) + { + // When performing passkey sign-in, don't perform form validation. + result = await SignInManager.PasskeySignInAsync(Input.Passkey.CredentialJson); + } + else + { + // If doing a password sign-in, validate the form. + if (!editContext.Validate()) + { + return; + } + + // This doesn't count login failures towards account lockout + // To enable password failures to trigger account lockout, set lockoutOnFailure: true + result = await SignInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: false); + } + + if (result.Succeeded) + { + Logger.LogInformation("User logged in."); + RedirectManager.RedirectTo(ReturnUrl); + } + else if (result.RequiresTwoFactor) + { + RedirectManager.RedirectTo( + "Account/LoginWith2fa", + new() { ["returnUrl"] = ReturnUrl, ["rememberMe"] = Input.RememberMe }); + } + else if (result.IsLockedOut) + { + Logger.LogWarning("User account locked out."); + RedirectManager.RedirectTo("Account/Lockout"); + } + else + { + errorMessage = "Error: Invalid login attempt."; + } + } + + private sealed class InputModel + { + [Required] + [EmailAddress] + public string Email { get; set; } = ""; + + [Required] + [DataType(DataType.Password)] + public string Password { get; set; } = ""; + + [Display(Name = "Remember me?")] + public bool RememberMe { get; set; } + + public PasskeyInputModel? Passkey { get; set; } + } +} diff --git a/ShellUI.Tests/Fixtures/StockBlazor/net10/Account/Pages/Manage/Email.razor.txt b/ShellUI.Tests/Fixtures/StockBlazor/net10/Account/Pages/Manage/Email.razor.txt new file mode 100644 index 0000000..fce6379 --- /dev/null +++ b/ShellUI.Tests/Fixtures/StockBlazor/net10/Account/Pages/Manage/Email.razor.txt @@ -0,0 +1,143 @@ +@page "/Account/Manage/Email" + +@using System.ComponentModel.DataAnnotations +@using System.Text +@using System.Text.Encodings.Web +@using Microsoft.AspNetCore.Identity +@using Microsoft.AspNetCore.WebUtilities +@using ShellStockProbe.Data + +@inject UserManager UserManager +@inject IEmailSender EmailSender +@inject NavigationManager NavigationManager +@inject IdentityRedirectManager RedirectManager + +Manage email + +

Manage email

+ + +
+
+
+ + + + + + @if (isEmailConfirmed) + { +
+ +
+ ✓ +
+ +
+ } + else + { +
+ + + +
+ } +
+ + + +
+ +
+
+
+ +@code { + private string? message; + private ApplicationUser? user; + private string? email; + private bool isEmailConfirmed; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [SupplyParameterFromForm(FormName = "change-email")] + private InputModel Input { get; set; } = default!; + + protected override async Task OnInitializedAsync() + { + Input ??= new(); + + user = await UserManager.GetUserAsync(HttpContext.User); + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + + email = await UserManager.GetEmailAsync(user); + isEmailConfirmed = await UserManager.IsEmailConfirmedAsync(user); + + Input.NewEmail ??= email; + } + + private async Task OnValidSubmitAsync() + { + if (Input.NewEmail is null || Input.NewEmail == email) + { + message = "Your email is unchanged."; + return; + } + + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + + var userId = await UserManager.GetUserIdAsync(user); + var code = await UserManager.GenerateChangeEmailTokenAsync(user, Input.NewEmail); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + var callbackUrl = NavigationManager.GetUriWithQueryParameters( + NavigationManager.ToAbsoluteUri("Account/ConfirmEmailChange").AbsoluteUri, + new Dictionary { ["userId"] = userId, ["email"] = Input.NewEmail, ["code"] = code }); + + await EmailSender.SendConfirmationLinkAsync(user, Input.NewEmail, HtmlEncoder.Default.Encode(callbackUrl)); + + message = "Confirmation link to change email sent. Please check your email."; + } + + private async Task OnSendEmailVerificationAsync() + { + if (email is null) + { + return; + } + + if (user is null) + { + RedirectManager.RedirectToInvalidUser(UserManager, HttpContext); + return; + } + + var userId = await UserManager.GetUserIdAsync(user); + var code = await UserManager.GenerateEmailConfirmationTokenAsync(user); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + var callbackUrl = NavigationManager.GetUriWithQueryParameters( + NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri, + new Dictionary { ["userId"] = userId, ["code"] = code }); + + await EmailSender.SendConfirmationLinkAsync(user, email, HtmlEncoder.Default.Encode(callbackUrl)); + + message = "Verification email sent. Please check your email."; + } + + private sealed class InputModel + { + [Required] + [EmailAddress] + [Display(Name = "New email")] + public string? NewEmail { get; set; } + } +} diff --git a/ShellUI.Tests/Fixtures/StockBlazor/net10/Account/Shared/ManageLayout.razor.txt b/ShellUI.Tests/Fixtures/StockBlazor/net10/Account/Shared/ManageLayout.razor.txt new file mode 100644 index 0000000..68652a0 --- /dev/null +++ b/ShellUI.Tests/Fixtures/StockBlazor/net10/Account/Shared/ManageLayout.razor.txt @@ -0,0 +1,17 @@ +@inherits LayoutComponentBase +@layout ShellStockProbe.Components.Layout.MainLayout + +

Manage your account

+ +
+

Change your account settings

+
+
+
+ +
+
+ @Body +
+
+
diff --git a/ShellUI.Tests/Fixtures/StockBlazor/net10/Account/Shared/ManageNavMenu.razor.txt b/ShellUI.Tests/Fixtures/StockBlazor/net10/Account/Shared/ManageNavMenu.razor.txt new file mode 100644 index 0000000..9f4e7e0 --- /dev/null +++ b/ShellUI.Tests/Fixtures/StockBlazor/net10/Account/Shared/ManageNavMenu.razor.txt @@ -0,0 +1,40 @@ +@using Microsoft.AspNetCore.Identity +@using ShellStockProbe.Data + +@inject SignInManager SignInManager + + + +@code { + private bool hasExternalLogins; + + protected override async Task OnInitializedAsync() + { + hasExternalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).Any(); + } +} diff --git a/ShellUI.Tests/Fixtures/StockBlazor/net10/Account/Shared/StatusMessage.razor.txt b/ShellUI.Tests/Fixtures/StockBlazor/net10/Account/Shared/StatusMessage.razor.txt new file mode 100644 index 0000000..12cd544 --- /dev/null +++ b/ShellUI.Tests/Fixtures/StockBlazor/net10/Account/Shared/StatusMessage.razor.txt @@ -0,0 +1,29 @@ +@if (!string.IsNullOrEmpty(DisplayMessage)) +{ + var statusMessageClass = DisplayMessage.StartsWith("Error") ? "danger" : "success"; + +} + +@code { + private string? messageFromCookie; + + [Parameter] + public string? Message { get; set; } + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + private string? DisplayMessage => Message ?? messageFromCookie; + + protected override void OnInitialized() + { + messageFromCookie = HttpContext.Request.Cookies[IdentityRedirectManager.StatusCookieName]; + + if (messageFromCookie is not null) + { + HttpContext.Response.Cookies.Delete(IdentityRedirectManager.StatusCookieName); + } + } +} diff --git a/ShellUI.Tests/Fixtures/StockBlazor/net8/Account/Pages/Login.razor.txt b/ShellUI.Tests/Fixtures/StockBlazor/net8/Account/Pages/Login.razor.txt new file mode 100644 index 0000000..79ff010 --- /dev/null +++ b/ShellUI.Tests/Fixtures/StockBlazor/net8/Account/Pages/Login.razor.txt @@ -0,0 +1,128 @@ +@page "/Account/Login" + +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Authentication +@using Microsoft.AspNetCore.Identity +@using ShellStockProbe.Data + +@inject SignInManager SignInManager +@inject ILogger Logger +@inject NavigationManager NavigationManager +@inject IdentityRedirectManager RedirectManager + +Log in + +

Log in

+
+
+
+ + + +

Use a local account to log in.

+
+ +
+ + + +
+
+ + + +
+
+ +
+
+ +
+ +
+
+
+
+
+

Use another service to log in.

+
+ +
+
+
+ +@code { + private string? errorMessage; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [SupplyParameterFromForm] + private InputModel Input { get; set; } = new(); + + [SupplyParameterFromQuery] + private string? ReturnUrl { get; set; } + + protected override async Task OnInitializedAsync() + { + if (HttpMethods.IsGet(HttpContext.Request.Method)) + { + // Clear the existing external cookie to ensure a clean login process + await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme); + } + } + + public async Task LoginUser() + { + // This doesn't count login failures towards account lockout + // To enable password failures to trigger account lockout, set lockoutOnFailure: true + var result = await SignInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: false); + if (result.Succeeded) + { + Logger.LogInformation("User logged in."); + RedirectManager.RedirectTo(ReturnUrl); + } + else if (result.RequiresTwoFactor) + { + RedirectManager.RedirectTo( + "Account/LoginWith2fa", + new() { ["returnUrl"] = ReturnUrl, ["rememberMe"] = Input.RememberMe }); + } + else if (result.IsLockedOut) + { + Logger.LogWarning("User account locked out."); + RedirectManager.RedirectTo("Account/Lockout"); + } + else + { + errorMessage = "Error: Invalid login attempt."; + } + } + + private sealed class InputModel + { + [Required] + [EmailAddress] + public string Email { get; set; } = ""; + + [Required] + [DataType(DataType.Password)] + public string Password { get; set; } = ""; + + [Display(Name = "Remember me?")] + public bool RememberMe { get; set; } + } +} diff --git a/ShellUI.Tests/Fixtures/StockBlazor/net8/Account/Pages/Manage/Email.razor.txt b/ShellUI.Tests/Fixtures/StockBlazor/net8/Account/Pages/Manage/Email.razor.txt new file mode 100644 index 0000000..5abfc9b --- /dev/null +++ b/ShellUI.Tests/Fixtures/StockBlazor/net8/Account/Pages/Manage/Email.razor.txt @@ -0,0 +1,123 @@ +@page "/Account/Manage/Email" + +@using System.ComponentModel.DataAnnotations +@using System.Text +@using System.Text.Encodings.Web +@using Microsoft.AspNetCore.Identity +@using Microsoft.AspNetCore.WebUtilities +@using ShellStockProbe.Data + +@inject UserManager UserManager +@inject IEmailSender EmailSender +@inject IdentityUserAccessor UserAccessor +@inject NavigationManager NavigationManager + +Manage email + +

Manage email

+ + +
+
+
+ + + + + + @if (isEmailConfirmed) + { +
+ +
+ ✓ +
+ +
+ } + else + { +
+ + + +
+ } +
+ + + +
+ +
+
+
+ +@code { + private string? message; + private ApplicationUser user = default!; + private string? email; + private bool isEmailConfirmed; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [SupplyParameterFromForm(FormName = "change-email")] + private InputModel Input { get; set; } = new(); + + protected override async Task OnInitializedAsync() + { + user = await UserAccessor.GetRequiredUserAsync(HttpContext); + email = await UserManager.GetEmailAsync(user); + isEmailConfirmed = await UserManager.IsEmailConfirmedAsync(user); + + Input.NewEmail ??= email; + } + + private async Task OnValidSubmitAsync() + { + if (Input.NewEmail is null || Input.NewEmail == email) + { + message = "Your email is unchanged."; + return; + } + + var userId = await UserManager.GetUserIdAsync(user); + var code = await UserManager.GenerateChangeEmailTokenAsync(user, Input.NewEmail); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + var callbackUrl = NavigationManager.GetUriWithQueryParameters( + NavigationManager.ToAbsoluteUri("Account/ConfirmEmailChange").AbsoluteUri, + new Dictionary { ["userId"] = userId, ["email"] = Input.NewEmail, ["code"] = code }); + + await EmailSender.SendConfirmationLinkAsync(user, Input.NewEmail, HtmlEncoder.Default.Encode(callbackUrl)); + + message = "Confirmation link to change email sent. Please check your email."; + } + + private async Task OnSendEmailVerificationAsync() + { + if (email is null) + { + return; + } + + var userId = await UserManager.GetUserIdAsync(user); + var code = await UserManager.GenerateEmailConfirmationTokenAsync(user); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + var callbackUrl = NavigationManager.GetUriWithQueryParameters( + NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri, + new Dictionary { ["userId"] = userId, ["code"] = code }); + + await EmailSender.SendConfirmationLinkAsync(user, email, HtmlEncoder.Default.Encode(callbackUrl)); + + message = "Verification email sent. Please check your email."; + } + + private sealed class InputModel + { + [Required] + [EmailAddress] + [Display(Name = "New email")] + public string? NewEmail { get; set; } + } +} diff --git a/ShellUI.Tests/Fixtures/StockBlazor/net8/Account/Shared/ManageLayout.razor.txt b/ShellUI.Tests/Fixtures/StockBlazor/net8/Account/Shared/ManageLayout.razor.txt new file mode 100644 index 0000000..949bc92 --- /dev/null +++ b/ShellUI.Tests/Fixtures/StockBlazor/net8/Account/Shared/ManageLayout.razor.txt @@ -0,0 +1,17 @@ +@inherits LayoutComponentBase +@layout AccountLayout + +

Manage your account

+ +
+

Change your account settings

+
+
+
+ +
+
+ @Body +
+
+
diff --git a/ShellUI.Tests/Fixtures/StockBlazor/net8/Account/Shared/ManageNavMenu.razor.txt b/ShellUI.Tests/Fixtures/StockBlazor/net8/Account/Shared/ManageNavMenu.razor.txt new file mode 100644 index 0000000..5b3c61c --- /dev/null +++ b/ShellUI.Tests/Fixtures/StockBlazor/net8/Account/Shared/ManageNavMenu.razor.txt @@ -0,0 +1,37 @@ +@using Microsoft.AspNetCore.Identity +@using ShellStockProbe.Data + +@inject SignInManager SignInManager + + + +@code { + private bool hasExternalLogins; + + protected override async Task OnInitializedAsync() + { + hasExternalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).Any(); + } +} diff --git a/ShellUI.Tests/Fixtures/StockBlazor/net8/Account/Shared/StatusMessage.razor.txt b/ShellUI.Tests/Fixtures/StockBlazor/net8/Account/Shared/StatusMessage.razor.txt new file mode 100644 index 0000000..12cd544 --- /dev/null +++ b/ShellUI.Tests/Fixtures/StockBlazor/net8/Account/Shared/StatusMessage.razor.txt @@ -0,0 +1,29 @@ +@if (!string.IsNullOrEmpty(DisplayMessage)) +{ + var statusMessageClass = DisplayMessage.StartsWith("Error") ? "danger" : "success"; + +} + +@code { + private string? messageFromCookie; + + [Parameter] + public string? Message { get; set; } + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + private string? DisplayMessage => Message ?? messageFromCookie; + + protected override void OnInitialized() + { + messageFromCookie = HttpContext.Request.Cookies[IdentityRedirectManager.StatusCookieName]; + + if (messageFromCookie is not null) + { + HttpContext.Response.Cookies.Delete(IdentityRedirectManager.StatusCookieName); + } + } +} diff --git a/ShellUI.Tests/InitBootstrapTests.cs b/ShellUI.Tests/InitBootstrapTests.cs index da26c3f..7bf7f0e 100644 --- a/ShellUI.Tests/InitBootstrapTests.cs +++ b/ShellUI.Tests/InitBootstrapTests.cs @@ -102,6 +102,17 @@ public void RewriteAppRazor_IsIdempotent() Assert.Equal(once, twice); } + [Fact] + public void RewriteAppRazor_KeepsIdentityPagesStatic() + { + var once = InitService.RewriteAppRazor(FreshAppRazor, identity: true); + + Assert.Contains(@"", once); + Assert.Contains(@"", once); + Assert.Contains(@"StartsWithSegments(""/Account"") ? null : InteractiveServer", once); + Assert.Equal(once, InitService.RewriteAppRazor(once, identity: true)); + } + [Fact] public void RewriteAppRazor_PreservesExistingRenderMode() { diff --git a/ShellUI.Tests/StockPagesTests.cs b/ShellUI.Tests/StockPagesTests.cs index 292e361..b0927a6 100644 --- a/ShellUI.Tests/StockPagesTests.cs +++ b/ShellUI.Tests/StockPagesTests.cs @@ -71,9 +71,8 @@ public void Apply_RestylesStockPagesAndReportsEditedOnes() Assert.Equal(new[] { "Components/Pages/Counter.razor" }, result.Restyled); Assert.False(StockPages.UsesBootstrap(File.ReadAllText(Path.Combine(root, "Components/Pages/Counter.razor")))); - Assert.Equal(2, result.Notes.Count); + Assert.Single(result.Notes); Assert.Contains(result.Notes, n => n.Contains("Components/Pages/Weather.razor") && n.Contains("modified")); - Assert.Contains(result.Notes, n => n.StartsWith("1 Identity page")); Assert.Contains("

mine

", File.ReadAllText(Path.Combine(root, "Components/Pages/Weather.razor"))); } finally diff --git a/ShellUI.Tests/TemplateCompileTests.cs b/ShellUI.Tests/TemplateCompileTests.cs index a6c0459..0c09a16 100644 --- a/ShellUI.Tests/TemplateCompileTests.cs +++ b/ShellUI.Tests/TemplateCompileTests.cs @@ -127,13 +127,13 @@ public void EveryHiddenEntry_IsReachableFromAnInstallableTarget() foreach (var dep in ComponentRegistry.Components[name].Dependencies ?? new List()) stack.Push(dep); } - // sidebar-js: legacy, kept for projects that still load shellui-sidebar.js. - var orphans = ComponentRegistry.Components.Keys.Where(k => !reachable.Contains(k) && k != "sidebar-js").ToList(); + // Installed outside the dependency graph: sidebar-js (legacy) and sidebar-account (with a dashboard in Identity apps). + var installedOtherwise = new[] { "sidebar-js", "sidebar-account" }; + var orphans = ComponentRegistry.Components.Keys.Where(k => !reachable.Contains(k) && !installedOtherwise.Contains(k)).ToList(); Assert.True(orphans.Count == 0, "Hidden entries no installable target depends on:\n " + string.Join("\n ", orphans)); } - // `shellui add ` alone must compile: every project namespace a file imports has to be declared - // by a file the same install writes. The all-components CI sweep can't see this, since other files fill the gap. + // `shellui add ` alone must compile; the all-components CI sweep can't catch a namespace only another target declares. [Fact] public void EveryDirectTarget_ImportsOnlyNamespacesItInstalls() { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 28609f4..66b0800 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -11,7 +11,7 @@ This document describes the ShellUI source tree. | Tailwind CSS | `4.3.2` | | Solution | `ShellUI.slnx` | | Solution projects | 6 | -| Registry | 239 entries: 90 direct targets and 149 hidden dependency entries | +| Registry | 243 entries: 93 direct targets and 150 hidden dependency entries | ShellUI is a CLI-first Blazor component library. The CLI copies source into a consumer project, while `ShellUI.Components` is an independent Razor class library (RCL) for consumers who prefer NuGet. @@ -89,13 +89,13 @@ The implemented command tree is: ## Registry Model and Counts -`ComponentRegistry` contains 239 metadata entries: +`ComponentRegistry` contains 243 metadata entries: | Registry classification | Count | Meaning | |---|---:|---| -| Direct targets | 90 | Entries with `IsAvailable = true`; these are the normal targets shown by `shellui list`. | -| Hidden entries | 149 | Entries with `IsAvailable = false`; these are generally installed through a parent target. | -| **Total** | **239** | All registered templates, sub-components, variants, models, services, and assets. | +| Direct targets | 93 | Entries with `IsAvailable = true`; these are the normal targets shown by `shellui list`. | +| Hidden entries | 150 | Entries with `IsAvailable = false`; these are generally installed through a parent target. | +| **Total** | **243** | All registered templates, sub-components, variants, models, services, and assets. | A dependency entry is still real source and is written to the consumer project when the dependency walk reaches it. The two counts describe registry visibility, not two different component libraries. diff --git a/docs/CLI_SYNTAX.md b/docs/CLI_SYNTAX.md index fd858d8..ab60258 100644 --- a/docs/CLI_SYNTAX.md +++ b/docs/CLI_SYNTAX.md @@ -1,6 +1,6 @@ # ShellUI CLI Syntax -This reference covers the CLI on `main` (0.4, in development): it runs on .NET 10, sets up Blazor projects on .NET 8, 9 and 10, uses Tailwind CSS `4.3.2`, and has 90 direct component targets. Some options, such as `--dashboard`, are not in the released 0.3.0; see the [v0.3.0 reference](https://github.com/shellui-dev/shellui/blob/v0.3.0/docs/CLI_SYNTAX.md) for that version. +This reference covers the CLI on `main` (0.4, in development): it runs on .NET 10, sets up Blazor projects on .NET 8, 9 and 10, uses Tailwind CSS `4.3.2`, and has 93 direct component targets. Some options, such as `--dashboard`, are not in the released 0.3.0; see the [v0.3.0 reference](https://github.com/shellui-dev/shellui/blob/v0.3.0/docs/CLI_SYNTAX.md) for that version. ## Command prefix @@ -61,7 +61,9 @@ Initialization creates or updates: `Components/Layout/` is created when a layout target is installed. -`init` removes the template's local Bootstrap copy (`wwwroot/lib/bootstrap` or, on .NET 8, `wwwroot/bootstrap`) and its `` in `App.razor`; Bootstrap loaded from a CDN is left alone. The sample pages (`Home`, `Counter`, `Weather`, `Error`, `NotFound`, `Auth`) are restyled with Tailwind classes when they are unchanged from `dotnet new blazor`. Pages you have edited are kept, and `init` lists any that still use Bootstrap classes. Identity pages under `Account/` are counted but not restyled. +`init` removes the template's local Bootstrap copy (`wwwroot/lib/bootstrap` or, on .NET 8, `wwwroot/bootstrap`) and its `` in `App.razor`; Bootstrap loaded from a CDN is left alone. The sample pages (`Home`, `Counter`, `Weather`, `Error`, `NotFound`, `Auth`) are restyled with Tailwind classes when they are unchanged from `dotnet new blazor`. Pages you have edited are kept, and `init` lists any that still use Bootstrap classes. + +In apps created with `--auth Individual`, `init` renders the `/Account` pages statically while the rest of the app stays interactive, because those pages set sign-in cookies and need a real HTTP request. It leaves their Bootstrap markup alone and points to the [auth layouts](#auth-layouts) instead. With `--dashboard`, `init` then runs `shellui add dashboard-02` (or `dashboard-01`), including the layout wiring described under [Dashboard layouts](#dashboard-layouts). @@ -93,10 +95,32 @@ shellui add button --force - Pages that pin `@layout MainLayout`, such as the .NET 10 `NotFound` page, move to the dashboard layout. - The Blazor error bar (`blazor-error-ui`) moves from `MainLayout` into `App.razor`. - `AppSidebar` links are built from the app's `@page` routes. Parameterized routes, `/Account/*`, `/Error` and `/not-found` are skipped. Links you have edited are left alone. +- In apps that use ASP.NET Core Identity, the sidebar footer shows Log in and Register, or the signed-in user (linking to the account page) and Log out. This adds the hidden `sidebar-account` component and replaces the placeholder user in the footer; a footer you have edited is left alone. - `ReconnectModal` and pages are not changed otherwise. Running the command again is safe. It prints a summary of what changed. +### Auth layouts + +For apps that use ASP.NET Core Identity (`dotnet new blazor --auth Individual`), three blocks style the sign-in, sign-up and account recovery pages: + +| Block | Design | +|---|---| +| `auth-01` | Centered card on a muted background | +| `auth-02` | Split screen: the form beside a brand panel | +| `auth-03` | Minimal centered form | + +```bash +shellui add auth-01 +``` + +Adding one: + +- Sets `@layout` in `Components/Account/Pages/_Imports.razor`, so those pages render without the dashboard sidebar. The account management pages under `Account/Manage` keep the app's layout. Adding another auth block switches to it. +- Restyles the Identity pages that are unchanged from `dotnet new blazor` for .NET 8, 9 or 10 with ShellUI's button, input and alert styles. Pages you have edited are kept and listed. + +The layouts show a placeholder logo and "My App"; edit the installed `AuthLayout0x.razor` to brand them. + ## `list` Lists the direct targets and their installation status. @@ -107,7 +131,7 @@ shellui list --installed shellui list --available ``` -There are 90 direct targets. Registry entries used only as dependencies are not counted as direct targets. Choose either `--installed` or `--available` to filter the output. +There are 93 direct targets. Registry entries used only as dependencies are not counted as direct targets. Choose either `--installed` or `--available` to filter the output. ## `remove` diff --git a/docs/COMPARISON.md b/docs/COMPARISON.md index 235ca99..e110d4c 100644 --- a/docs/COMPARISON.md +++ b/docs/COMPARISON.md @@ -6,7 +6,7 @@ This is a qualitative comparison, not a benchmark report. Library features, pack - **Target framework:** .NET 10 - **Tailwind CSS:** `4.3.2` -- **CLI inventory:** 90 direct targets, 239 registry entries, and 149 hidden entries +- **CLI inventory:** 93 direct targets, 243 registry entries, and 150 hidden entries - **Distribution:** the CLI copies source into an application; the repository also contains the packable `ShellUI.Components` package - **Packaging boundary:** only `ShellUI.CLI` and `ShellUI.Components` are packable. `ShellUI.Core` and `ShellUI.Templates` are internal/non-packable projects. @@ -18,7 +18,7 @@ The direct-target count is the number users invoke with `shellui add`. It is not |---|---|---|---|---|---|---| | Distribution | CLI source-copy workflow; `ShellUI.Components` is packable | NuGet package | NuGet package | NuGet package | NuGet package | NuGet package | | CSS model | Tailwind CSS `4.3.2` | Material-oriented component styling | Project-specific component styling | Provider-based CSS options | Ant Design styling | Project-specific styling | -| Direct component count | 90 CLI targets; 239 registry entries; 149 hidden | Not audited here | Not audited here | Not audited here | Not audited here | Not audited here | +| Direct component count | 93 CLI targets; 243 registry entries; 150 hidden | Not audited here | Not audited here | Not audited here | Not audited here | Not audited here | | Source customization | Copied component source can be edited | Package/API customization | Package/API customization | Package/API customization | Package/API customization | Package/API customization | | CLI workflow | Yes | No CLI in this comparison | No CLI in this comparison | No CLI in this comparison | No CLI in this comparison | No CLI in this comparison | | Accessibility status | Semantic markup is used; no current WCAG conformance claim | Not audited here | Not audited here | Not audited here | Not audited here | Not audited here | @@ -39,7 +39,7 @@ ShellUI templates use Tailwind utility classes and CSS variables. ShellUI pins T ### Registry-driven dependencies -`ComponentRegistry` is authoritative for the 90 direct targets, 149 hidden support entries, source dependencies, and NuGet dependencies. A component's visible sub-components or support files do not imply that it is a standalone package. +`ComponentRegistry` is authoritative for the 93 direct targets, 150 hidden support entries, source dependencies, and NuGet dependencies. A component's visible sub-components or support files do not imply that it is a standalone package. ### Compositional APIs diff --git a/docs/COMPONENT_DEPENDENCIES.md b/docs/COMPONENT_DEPENDENCIES.md index 7a6af2f..70262b8 100644 --- a/docs/COMPONENT_DEPENDENCIES.md +++ b/docs/COMPONENT_DEPENDENCIES.md @@ -4,10 +4,10 @@ ## Registry snapshot -- **239** total registry entries -- **90** direct CLI targets (`IsAvailable = true`) -- **149** hidden entries (`IsAvailable = false`) -- `shellui list` displays the 90 direct targets; hidden sub-components, variants, models, services, and support assets are omitted from the public list. +- **243** total registry entries +- **93** direct CLI targets (`IsAvailable = true`) +- **150** hidden entries (`IsAvailable = false`) +- `shellui list` displays the 93 direct targets; hidden sub-components, variants, models, services, and support assets are omitted from the public list. `Dependencies` contains registry-declared source/template dependencies. `NuGetDependencies` is separate and is used by the installer to add package references. A relationship visible in rendered markup is not automatically a registry dependency, so do not infer a dependency graph from component names or visual composition. diff --git a/docs/COMPONENT_ROADMAP.md b/docs/COMPONENT_ROADMAP.md index 5d3bd92..30af5bd 100644 --- a/docs/COMPONENT_ROADMAP.md +++ b/docs/COMPONENT_ROADMAP.md @@ -6,20 +6,20 @@ ## Current inventory -`ComponentRegistry` is the source of truth for component availability, names, and dependencies. The current registry contains **239 entries**: +`ComponentRegistry` is the source of truth for component availability, names, and dependencies. The current registry contains **243 entries**: -- **90 direct CLI targets** shown by `shellui list` -- **149 hidden entries** for sub-components, variants, models, services, and support assets +- **93 direct CLI targets** shown by `shellui list` +- **150 hidden entries** for sub-components, variants, models, services, and support assets - **Packable projects:** `ShellUI.CLI` and `ShellUI.Components` Hidden entries are not counted as direct targets. They can still be installed recursively when a parent target declares them. -### Implemented direct targets (90) +### Implemented direct targets (93) The following categories describe the direct targets in the current registry, not a promise about future scope. - [x] **Form (26):** Button, ButtonGroup, ChatInput, Checkbox, Combobox, DataPicker, DatePicker, DateRangePicker, FileUpload, Form, Input, InputGroup, InputOTP, Label, MultiSelect, NumberInput, RadioGroup, Select, Slider, Switch, TagInput, Textarea, TimePicker, Toggle, ToggleGroup, TypedSelect -- [x] **Layout (13):** Accordion, AspectRatio, Breadcrumb, Card, Collapsible, DashboardLayout01, DashboardLayout02, LinkCard, Navbar, Resizable, ScrollArea, Separator, Sidebar +- [x] **Layout (16):** Accordion, AspectRatio, AuthLayout01, AuthLayout02, AuthLayout03, Breadcrumb, Card, Collapsible, DashboardLayout01, DashboardLayout02, LinkCard, Navbar, Resizable, ScrollArea, Separator, Sidebar - [x] **Feedback (9):** Alert, Callout, EmptyState, Loading, Progress, Skeleton, Sonner, Toast, Tooltip - [x] **Overlay (9):** AlertDialog, Command, CommandPalette, Dialog, Drawer, Dropdown, HoverCard, Popover, Sheet - [x] **Navigation (8):** ContextMenu, Menubar, NavigationMenu, Pagination, PrevNextNav, Stepper, Tabs, TreeView diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 91de6d9..5142b85 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -93,7 +93,7 @@ For a component change: 1. Update the live component and its generated template together. 2. Update registry metadata, dependencies, `FilePath`, variants, and NuGet dependencies when the public contract changes. -3. Preserve the distinction between direct targets and hidden dependency entries. The current registry has 239 entries, including 90 direct targets and 149 hidden entries. +3. Preserve the distinction between direct targets and hidden dependency entries. The current registry has 243 entries, including 93 direct targets and 150 hidden entries. 4. Use the Tailwind `4.3.2` variable and utility conventions already used by the project. 5. Add or update tests that exercise the changed registry, template, or CLI behavior. 6. Review keyboard and assistive-technology behavior without claiming a blanket accessibility conformance level that the repository does not test. diff --git a/docs/FAQ.md b/docs/FAQ.md index 5b95631..3fba5df 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -6,7 +6,7 @@ Answers for the current ShellUI source and the currently published packages. ### Which version should I use? -Use the latest stable release, `0.3.0`. It uses Tailwind CSS `4.3.2`. The 0.4 work on `main`, with 90 direct component targets, is not published yet: +Use the latest stable release, `0.3.0`. It uses Tailwind CSS `4.3.2`. The 0.4 work on `main`, with 93 direct component targets, is not published yet: ```bash dotnet tool install -g ShellUI.CLI @@ -49,7 +49,7 @@ shellui add button,input,card shellui add button,card dialog ``` -Use the exact names printed by `shellui list`. Dependency-only entries are installed automatically and are not counted among the 90 direct targets. +Use the exact names printed by `shellui list`. Dependency-only entries are installed automatically and are not counted among the 93 direct targets. ### Which names should I use? @@ -87,7 +87,7 @@ shellui list --installed shellui list --available ``` -There are 90 direct targets. Use the command output as the authoritative name list. +There are 93 direct targets. Use the command output as the authoritative name list. ### What happens when I update a component? diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index 326da2c..98a8fd9 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -42,10 +42,10 @@ theme update ### Registry and components -`ComponentRegistry` has 239 entries: +`ComponentRegistry` has 243 entries: -- 90 direct targets with `IsAvailable = true`. -- 149 hidden entries with `IsAvailable = false`, generally installed as dependencies or assets. +- 93 direct targets with `IsAvailable = true`. +- 150 hidden entries with `IsAvailable = false`, generally installed as dependencies or assets. The direct-target count is the number shown by the normal public list. The hidden count is not a second public library; it represents the sub-components, variants, models, services, and assets needed to make the direct targets work. diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 3563344..7bd9ccd 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -68,7 +68,7 @@ Dependencies are copied automatically. Use `--force` to overwrite an existing co shellui add button --force ``` -Use the exact names shown by `shellui list`. There are 90 direct targets; dependency-only registry entries are not direct targets. +Use the exact names shown by `shellui list`. There are 93 direct targets; dependency-only registry entries are not direct targets. ## Use a component diff --git a/src/ShellUI.CLI/README.md b/src/ShellUI.CLI/README.md index ed35d9d..e33bcd0 100644 --- a/src/ShellUI.CLI/README.md +++ b/src/ShellUI.CLI/README.md @@ -37,7 +37,7 @@ dotnet watch |---|---| | `init` | Set up ShellUI and Tailwind in the current project | | `add ` | Install components and their dependencies | -| `list` | List the 90 components, with `--installed` or `--available` filters | +| `list` | List the 93 components, with `--installed` or `--available` filters | | `remove ` | Delete installed component files | | `update [names...]` | Rewrite installed components from the current templates (`--all` for every one) | | `theme init ` | Run `init`, then apply a [tweakcn](https://tweakcn.com) theme | diff --git a/src/ShellUI.CLI/Services/AccountPages.cs b/src/ShellUI.CLI/Services/AccountPages.cs new file mode 100644 index 0000000..ca75802 --- /dev/null +++ b/src/ShellUI.CLI/Services/AccountPages.cs @@ -0,0 +1,400 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; + +namespace ShellUI.CLI.Services; + +// init removes Bootstrap; unmodified Identity pages get ShellUI's Button, Input and Alert classes and edited ones are reported. +public static class AccountPages +{ + // `dotnet new blazor --auth Individual` for net8.0/net9.0/net10.0 and every interactivity option, hashed after Normalize. + private static readonly Dictionary> StockHashes = new(StringComparer.OrdinalIgnoreCase) + { + ["Pages/AccessDenied.razor"] = new(StringComparer.Ordinal) { "b6109bf4a8706bb962cf64c45f67571aca2cbc9a45d2ffebb63d7cf4bc74b626" }, + ["Pages/ConfirmEmail.razor"] = new(StringComparer.Ordinal) + { + "3c84325a7c0d679532b52298a7df1a93b74020f24804ef1378d40a5753778d45", + "4468ca180062585d6356dd71de5f44e3bfd463ef843ddac5f8ac223cc48e2b9d" + }, + ["Pages/ConfirmEmailChange.razor"] = new(StringComparer.Ordinal) + { + "5263b5c2d262ce8555ac3fa6b474772a0e31e0d980972a6f61debe2963889874", + "d689f04ee7510c53193df567163765ad0aeb80c911880aa7d5ff3487a05b3918" + }, + ["Pages/ExternalLogin.razor"] = new(StringComparer.Ordinal) + { + "2170aea76f696f655e94fe614c33200c78dd3a955b11b81f12b67fbbbcea1a3f", + "62e4ec0107d81e5d1829beb7d7bb2df0324e795bb4a8857d05720a8dd0852306", + "6fc645d7deb82670172d2dc1f3ab1e9c6f45f64bf570f3e9f95aafe851da2fd5" + }, + ["Pages/ForgotPassword.razor"] = new(StringComparer.Ordinal) + { + "5053ecbb6b9d836388491fdda1d5d7a1534a31224eaa32e83b6b0f15d2954e8d", + "6bf4a0ab8ffd045a645ba45d813aa8d3a78c80c909959b10c4d99917a22538da", + "e63a3097ff744175ec91c7059ecb540a355484b4c708fdb1546dd6aac74b2f36" + }, + ["Pages/ForgotPasswordConfirmation.razor"] = new(StringComparer.Ordinal) + { + "74724076354ddd81dc1cb715d3a29cd45a014d71cfa589d94f53140a1a9669d0", + "f3ffe30484369b81cddb5fb8a35100a89c0712902bb57b998e6b405d52ec36c1" + }, + ["Pages/InvalidPasswordReset.razor"] = new(StringComparer.Ordinal) + { + "19b56fd47b9b95acbf09b259793ac8c702ca11c201742d200f77d5ba7c012a96", + "6375738630c240a80afe3e1b3e799a2811f4321cff1301c979bd7fe1399a62f1" + }, + ["Pages/InvalidUser.razor"] = new(StringComparer.Ordinal) { "285b6a382eab7f48d6340d0f9a65a7285a8ea5329a699709cce7dcd5fdad520e" }, + ["Pages/Lockout.razor"] = new(StringComparer.Ordinal) + { + "469e986117725dc7f49edb72ce14b317ad4b6cb95583142f5141c84846c7eb32", + "c2d910ba6d150392146fa98f69e1d379aab98564bb890d29c4359a4723f2b39b" + }, + ["Pages/Login.razor"] = new(StringComparer.Ordinal) + { + "0e5eb7e1ef66dd84026fad8fc254bf994117dd2934388a6b228c74fe6eb68b6c", + "3e9068fc3d32ebfeb9b5d5873b6c5083c00c5babcc61e010309ba703bd884e0d", + "e11c78b3ddb87480e7bc6b14b9c8e006ab313a028da865b4cfc5cb258ced33c6" + }, + ["Pages/LoginWith2fa.razor"] = new(StringComparer.Ordinal) + { + "3d7907a51eb1e610dd1047a4b67774da2ac9509b8b9f4a554e57fa7c13c0e867", + "bd8337ce2f31c255df3c8a0e64e08855e563bd283e930f6f2c0b53530268f667", + "e2728080c241e022781d6e24fc1c9b1bc408f23dad0cd5c67f5cec715e9c2f35" + }, + ["Pages/LoginWithRecoveryCode.razor"] = new(StringComparer.Ordinal) + { + "9a7b775fb0fbeae7757c07ebe7fa88bd69071cf182c9bcc8d9853832a4ee654f", + "b4724a2118e31135dce481b7f9280dea2ee03df5efee96517f78016db6050012", + "d3987452d4ed72842d8ab9da24b811923a943aa8a84308a9b7fc6980826472de" + }, + ["Pages/Manage/ChangePassword.razor"] = new(StringComparer.Ordinal) + { + "65b42b8849bbad1435a91e6745f88601cd62bb7361c0b35abf1beabcb36c6f3c", + "74ef2785beb8153240612b94c35d56a5b6d920bfc33c3fd4bcdc16d7c270773f", + "ddf28a69da05c15b0b58c06ed60ac749760ddace24180dcf20b38e43271b4f4f" + }, + ["Pages/Manage/DeletePersonalData.razor"] = new(StringComparer.Ordinal) + { + "297fd574621f11597dd71a5b8598dbe5c3a19c3cb665a3505b2698979ed4ae5c", + "2afd3abf4a94a09f2d2a8aca213bf999a1d55c79cdba6e343e4e84d75a39b005", + "99ceff259f9c6315cead560a29cacee54a9c73cc350f059d79c1c96485ddb17f" + }, + ["Pages/Manage/Disable2fa.razor"] = new(StringComparer.Ordinal) + { + "4f940f7b8871956e32b59822e7a78fdbb107f542cdf18d24ca007ac470ad77f1", + "6d14e673a473ce33b274287acf1acd03f1ca52cca18efb8f73108e2d630a42f2" + }, + ["Pages/Manage/Email.razor"] = new(StringComparer.Ordinal) + { + "81918687ef7ba8925321532cfc02ae78b2303809b1b6a0b94c592365e424d646", + "a816c45c996e2a2db21775da93858a3db261c9416442bf78e376f53b426d5424", + "cd4a3e01a35d0b1f59088ecdef8d7496bd147b0c7e72fe080f0207148f0f9b77" + }, + ["Pages/Manage/EnableAuthenticator.razor"] = new(StringComparer.Ordinal) + { + "1d5f0148c5bc85ff2b17f7acbdfffd3a87f50e6b059ab9fb9830f081ac77ee5b", + "69a1a925d591b871045274f5face85c1e5c1bb8ca769bda05074dd5b2ec90ded", + "dde6c58980dbf1ed582b042391e94a9ac35271feb479545f03be510ba454312b" + }, + ["Pages/Manage/ExternalLogins.razor"] = new(StringComparer.Ordinal) + { + "03a4c82975f6d0ae8e32cc43d749c3da6d5cbc5ef0c44ae851e8dc662d270a54", + "640a28f70f27c4f382e2d7dff1e972b4e376ea2b7f6a38e89ed6f7c25edc7404" + }, + ["Pages/Manage/GenerateRecoveryCodes.razor"] = new(StringComparer.Ordinal) + { + "7297a499efa1e89a4c82521ebbebe75d522cf8ddae7b018387ca494f57af2bec", + "b252c7dc03946cb0a8fa620a3f9e433937d98f1bce39118f5137bdfcdac8037e" + }, + ["Pages/Manage/Index.razor"] = new(StringComparer.Ordinal) + { + "3b44bb0cda2ebf94d475241647e1c5b1a06ee3d8e9e758128af9551fde8766b7", + "4dfb4be606a212b18620ca5f499069d63ea0b138b43915a37c2c9298b0cf489e", + "7029a2d3a766e569d30050ea7a1fa35c622f8f9082090963e080944de1e63800" + }, + ["Pages/Manage/Passkeys.razor"] = new(StringComparer.Ordinal) { "2887ff8db7f40fbd207763942b0ac0c4286d7a6094d12ccc37e25f5941d94f4f" }, + ["Pages/Manage/PersonalData.razor"] = new(StringComparer.Ordinal) + { + "049f1eee3d298f4a75a006c10869500d34be195d5bd5e2b57aba79849c795c9d", + "2af37ab52bc87caaf5d6d38a5670a77534eb23a8158a1878f93c33b2ac4c64ec" + }, + ["Pages/Manage/RenamePasskey.razor"] = new(StringComparer.Ordinal) { "d7260ab13e73f04bedae5affc695d5eec2f82c8c210a913a5d72c5623e5040a6" }, + ["Pages/Manage/ResetAuthenticator.razor"] = new(StringComparer.Ordinal) + { + "267cef205ebf7675dcc334e1a9e67659465eb67ae7b3d7c6cf3dc393262929d9", + "f2fcba0e007184f416ba726b02d804557f1a6da2aeaec7d0b0e90bd3d65d5dd8" + }, + ["Pages/Manage/SetPassword.razor"] = new(StringComparer.Ordinal) + { + "0bcbe17a16e1951aa73f68173495a8c80c577cfe08646a983ca81f51f89329cf", + "b900d9e0937e07de101be5ac3b01dd3be36e7200eb95c248aef30d55b67ec71f", + "ea58057070f27fa73cb02e17376148e2a6d2e34e1961ffc9b539f84efd482626" + }, + ["Pages/Manage/TwoFactorAuthentication.razor"] = new(StringComparer.Ordinal) + { + "198321c9815c183c395418a13677682eff6b53b100cfa96fdfac78946f45c6d7", + "be60a9dc1f5280c3ed49b4db06af55c77f939a2d0556cbbfdf51ea6a7e08c52a" + }, + ["Pages/Manage/_Imports.razor"] = new(StringComparer.Ordinal) { "3e222370c041c607a47d00cf66676a54b647446b8bbd24284170dcc698ffb365" }, + ["Pages/Register.razor"] = new(StringComparer.Ordinal) + { + "5a6cc61bb9ef9303744e93aaf5710516995744c8ae816f5097ac2f4e3ecae045", + "740eee29369bb3134aeaf0ac46d868099279920b63fc97a098697edb37a9d828", + "c468cc4906e60d31322a4fef6ac843d9749193913102c97fd1ed83e1e3f7cc28" + }, + ["Pages/RegisterConfirmation.razor"] = new(StringComparer.Ordinal) + { + "4af952f873c71c5b19e5b8e101730271214ef86ebbbb56432e8f1399594092d8", + "8c95cf01919d4ea348f87c826982db6a2417834c3fd6a3801b9798a83ea4e3e1", + "fdcd4fb41f5611500dbf951534d950c3ac3cff4443082fec68e9da66d648617c" + }, + ["Pages/ResendEmailConfirmation.razor"] = new(StringComparer.Ordinal) + { + "12b33198dc0e38618cd889e9f475891bb7738d969350da42828006bfc3f81697", + "32a07c6d44c0c64fd7d07fca7f79af25c46e25e2cc0eacbb8cb0477f4367733a", + "40955af96e0ba7064fcec07493fd1116fbebab30c8472bfae31cfcd2187a9b34" + }, + ["Pages/ResetPassword.razor"] = new(StringComparer.Ordinal) + { + "477d1e8a2c08a010bf8570fd4cdb65464257301eb63b3c2a26a45f0ba8115266", + "ae84c4c4a8b8f7efc53636ae86201131beb3984aec20538b239a7f801dc3bd62", + "c741fc9428b6d527e32c27300014292c5163939ea9946a048f6da122d70ac110" + }, + ["Pages/ResetPasswordConfirmation.razor"] = new(StringComparer.Ordinal) + { + "844c48259e2b6d532b3c77ed6e409af7f859b676d69536b11bd8d734ca404403", + "91002bdc2454b2e6f9b563b66cc849e7c00622912ad250d16695694c7aa037fe" + }, + ["Pages/_Imports.razor"] = new(StringComparer.Ordinal) + { + "2d0533eb9dff4d504a97c3ceeffc0466e2d916034f41b0f07a196dd0b956f191", + "a38d03e90c73981b8a977fa0de16078868577b65ba1afb7291f8fa20f3bb24ba" + }, + ["Shared/AccountLayout.razor"] = new(StringComparer.Ordinal) + { + "7b63158d82ddfaae90650438ec37a027a47c892e9bc85bc91d43b5e84ed3eff2", + "ffa1e3e7a65e814feed52bd61228b29df431981a531f12cb2767408f19eab708" + }, + ["Shared/ExternalLoginPicker.razor"] = new(StringComparer.Ordinal) { "a4a6361e8b508209de14bf7b04fd67773697e09e83d4f93b732c187ff057ba93" }, + ["Shared/ManageLayout.razor"] = new(StringComparer.Ordinal) + { + "07a5f54f17187dc07e9ebaf19dc57c5ae7dd63cfd650f14375f0425d56a95c20", + "57e4978bd9196add1f3ffe6d85ba582bb4a6b54cd6987c5ff64609bb9405ccd8", + "b87a94172f72a2fa3f5fcead12aad3e9dae48984febbc453da1106548d38a3b7" + }, + ["Shared/ManageNavMenu.razor"] = new(StringComparer.Ordinal) + { + "4c2ade9171e6e75b9e17ce5db4130a188a1c7c720cc67e90b3fef10b536bf281", + "bded26c673d0c8bfbd4904452291d2f493bfb5413e7e50201beda16ca3a681b1" + }, + ["Shared/PasskeySubmit.razor"] = new(StringComparer.Ordinal) { "9dae91807e527b9887350045a9c37fe3b632c5c73f6158fb5f28efdbc6fbe94d" }, + ["Shared/RedirectToLogin.razor"] = new(StringComparer.Ordinal) { "e8ceb9bd9cf1466f0da3292e068fcc0249fde051c753a5ae4af1277b32a8b26a" }, + ["Shared/ShowRecoveryCodes.razor"] = new(StringComparer.Ordinal) { "d93d5b74ad731c56ee72ad285df1727d833a0ffb819a216ae1403c65a7561b87" }, + ["Shared/StatusMessage.razor"] = new(StringComparer.Ordinal) { "e271d48b5024056c4412441f89a71951360a103b84328bb3b35805bd0983410a" } + }; + + private const string Button = "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"; + private const string Alert = "relative mb-4 w-full rounded-lg border border-border p-4 text-sm"; + + private static readonly Dictionary ClassMap = new(StringComparer.Ordinal) + { + ["alert"] = Alert, + ["alert-danger"] = "text-destructive", + ["alert-info"] = "text-blue-700 dark:text-blue-400", + ["alert-success"] = "text-green-700 dark:text-green-400", + ["alert-warning"] = "text-yellow-700 dark:text-yellow-400", + ["checkbox"] = "", + ["col-lg-3"] = "col-span-12 @3xl:col-span-3 @3xl:not-last:pr-8", + ["col-lg-4"] = "col-span-12 @3xl:col-span-4 @3xl:not-last:pr-8", + ["col-lg-6"] = "col-span-12 @3xl:col-span-6 @3xl:not-last:pr-8", + ["col-lg-9"] = "col-span-12 @3xl:col-span-9 @3xl:not-last:pr-8", + ["col-lg-offset-2"] = "", + ["col-md-3"] = "col-span-12 @2xl:col-span-3 @2xl:not-last:pr-8", + ["col-md-4"] = "col-span-12 @2xl:col-span-4 @2xl:not-last:pr-8", + ["col-md-6"] = "col-span-12 @2xl:col-span-6 @2xl:not-last:pr-8", + ["col-md-9"] = "col-span-12 @2xl:col-span-9 @2xl:not-last:pr-8", + ["col-md-12"] = "col-span-12", + ["col-md-offset-2"] = "", + ["col-xl-6"] = "col-span-12 @4xl:col-span-6 @4xl:not-last:pr-8", + ["control-label"] = "text-sm font-medium", + ["d-flex"] = "flex", + ["darker-border-checkbox"] = "", + ["flex-column"] = "flex-col", + ["font-weight-bold"] = "font-bold", + ["form-check-input"] = "size-4 shrink-0 rounded-sm border border-primary accent-primary", + ["form-control"] = "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", + // Bootstrap floats the label over the input that precedes it; here it moves above the input. + ["form-floating"] = "grid gap-2 [&>label]:order-first [&>label]:col-span-full [&>label]:text-sm [&>label]:font-medium [&>label]:leading-none", + ["form-horizontal"] = "space-y-4", + ["form-label"] = "text-sm font-medium leading-none", + ["glyphicon"] = "", + ["glyphicon-warning-sign"] = "", + ["h-100"] = "h-full", + ["input-group"] = "grid-cols-[1fr_auto] gap-x-2", + ["input-group-append"] = "flex", + ["input-group-text"] = "inline-flex items-center rounded-md border border-input px-3", + ["list"] = "mb-4 list-decimal space-y-3 pl-6", + ["nav"] = "flex gap-1", + ["nav-item"] = "", + ["nav-link"] = "flex w-full items-center rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground [&.active]:bg-accent [&.active]:text-accent-foreground", + ["nav-pills"] = "", + ["recovery-code"] = "font-mono text-sm", + // Container queries stack the columns in a narrow auth card; gutters are padding because column gaps overflow phones. + ["row"] = "@container grid grid-cols-12 gap-y-6", + ["table"] = "w-full text-sm", + ["text-danger"] = "text-destructive", + ["text-info"] = "text-blue-700 dark:text-blue-400", + ["text-secondary"] = "text-muted-foreground", + ["text-success"] = "text-green-600 dark:text-green-500", + ["w-100"] = "w-full" + }; + + // Tailwind's preflight removes the heading, paragraph and link styles Bootstrap restored. + private static readonly Dictionary TagDefaults = new(StringComparer.Ordinal) + { + ["h1"] = "mb-4 text-2xl font-semibold tracking-tight", + ["h2"] = "mb-2 text-lg font-semibold", + ["h3"] = "mb-2 text-lg font-semibold", + ["h4"] = "mb-2 font-semibold", + ["hr"] = "my-4 border-border", + ["p"] = "mb-3", + ["kbd"] = "rounded bg-muted px-1.5 py-0.5 font-mono text-sm", + ["td"] = "py-2 pr-4 align-middle", + ["a"] = "font-medium underline underline-offset-4" + }; + + private static readonly Regex StaticClass = new(@"(?<=\sclass="")[^""@]*(?="")", RegexOptions.Compiled); + private static readonly Regex TagStart = new(@"<(h[1-4]|hr|p|kbd|td|a)(?=[\s/>])", RegexOptions.Compiled); + private static readonly Regex ClassAttribute = new(@"\sclass=""", RegexOptions.Compiled); + + public record Result(List Restyled, List Notes); + + internal static string Normalize(string content, string rootNamespace) + { + var text = content.TrimStart('').Replace("\r\n", "\n").TrimEnd(); + // The dashboard setup retargets `@layout MainLayout` to its own layout; that alone doesn't make a page edited. + text = DashboardLayoutDirective.Replace(text, "$1MainLayout"); + if (string.IsNullOrEmpty(rootNamespace) || rootNamespace == DashboardSetup.StockProjectName) return text; + return Regex.Replace(text, $@"(?(); + if (tokens.Contains("btn")) + { + output.Add(Button); + output.Add(tokens.Contains("btn-lg") ? "h-11 px-8" : "h-10 px-4 py-2"); + if (tokens.Contains("btn-primary")) output.Add("bg-primary text-primary-foreground hover:bg-primary/90"); + if (tokens.Contains("btn-danger")) output.Add("bg-destructive text-destructive-foreground hover:bg-destructive/90"); + if (tokens.Contains("btn-link")) output.Add("text-primary underline-offset-4 hover:underline"); + } + foreach (var token in tokens) + { + if (token is "btn" or "btn-lg" or "btn-primary" or "btn-danger" or "btn-link") continue; + output.Add(ClassMap.TryGetValue(token, out var mapped) ? mapped : token); + } + return string.Join(" ", output.SelectMany(o => o.Split(' ', StringSplitOptions.RemoveEmptyEntries)).Distinct()); + } + + internal static string Restyle(string content) + { + // Razor reads `@` in markup as code; `@@` writes a literal `@`, and Tailwind still finds the class. + var text = StaticClass.Replace(content, m => MapClasses(m.Value).Replace("@", "@@")); + text = text + .Replace("? \"danger\" : \"success\"", "? \"text-destructive\" : \"text-green-700 dark:text-green-400\"") + .Replace("class=\"alert alert-@statusMessageClass\"", $"class=\"{Alert} @statusMessageClass\""); + return AddTagDefaults(text); + } + + private static string AddTagDefaults(string text) + { + var builder = new StringBuilder(text.Length + 1024); + var index = 0; + foreach (Match match in TagStart.Matches(text)) + { + if (match.Index < index) continue; + var end = TagEnd(text, match.Index); + if (end < 0) continue; + + var name = match.Groups[1].Value; + var tag = text[match.Index..(end + 1)]; + var hasClass = ClassAttribute.IsMatch(tag); + if (hasClass && name == "a") continue; + + var defaults = TagDefaults[name]; + var updated = hasClass + ? ClassAttribute.Replace(tag, m => $"{m.Value}{defaults} ", 1) + : tag.Insert(match.Length, $" class=\"{defaults}\""); + builder.Append(text, index, match.Index - index).Append(updated); + index = end + 1; + } + builder.Append(text, index, text.Length - index); + return builder.ToString(); + } + + // Razor attribute values can hold @(...) expressions with quotes and '>' inside, so the tag end is found by scanning. + private static int TagEnd(string text, int start) + { + var depth = 0; + var inQuote = false; + for (var i = start + 1; i < text.Length; i++) + { + var c = text[i]; + if (depth > 0) + { + if (c == '(') depth++; + else if (c == ')') depth--; + } + else if (c == '"') inQuote = !inQuote; + else if (inQuote && c == '(' && text[i - 1] == '@') depth = 1; + else if (!inQuote && c == '>') return i; + else if (!inQuote && c == '<') return -1; + } + return -1; + } + + internal static bool UsesBootstrap(string content) => + StaticClass.Matches(content).Any(m => m.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Any(token => ClassMap.ContainsKey(token) || token.StartsWith("btn", StringComparison.Ordinal))); + + public static Result Apply(string cwd, string rootNamespace) + { + var restyled = new List(); + var edited = new List(); + foreach (var file in DashboardSetup.EnumerateSources(cwd, "*.razor")) + { + var rel = Path.GetRelativePath(cwd, file).Replace('\\', '/'); + var marker = rel.IndexOf("Components/Account/", StringComparison.OrdinalIgnoreCase); + if (marker < 0) continue; + + var content = File.ReadAllText(file); + if (IsStock(rel[(marker + "Components/Account/".Length)..], content, rootNamespace)) + { + var updated = Restyle(content); + if (updated == content) continue; + File.WriteAllText(file, updated); + restyled.Add(rel); + } + else if (UsesBootstrap(content)) + { + edited.Add(rel); + } + } + + var notes = edited + .Select(f => $"Kept {f} because it was modified. It still uses Bootstrap classes, which no longer have styles.") + .ToList(); + return new Result(restyled, notes); + } +} diff --git a/src/ShellUI.CLI/Services/AuthSetup.cs b/src/ShellUI.CLI/Services/AuthSetup.cs new file mode 100644 index 0000000..9b6bd65 --- /dev/null +++ b/src/ShellUI.CLI/Services/AuthSetup.cs @@ -0,0 +1,81 @@ +using System.Text.RegularExpressions; +using ShellUI.Core.Models; +using Spectre.Console; + +namespace ShellUI.CLI.Services; + +public static class AuthSetup +{ + public static readonly IReadOnlyDictionary Layouts = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["auth-01"] = "AuthLayout01", + ["auth-02"] = "AuthLayout02", + ["auth-03"] = "AuthLayout03" + }; + + internal const string Hint = "Style the Identity pages under Components/Account with a block: shellui add auth-01 (centered card), auth-02 (split screen) or auth-03 (minimal)."; + + private static readonly Regex LayoutDirective = new(@"^([ \t]*@layout[ \t]+)([\w.:]+)([ \t]*)\r?$", RegexOptions.Compiled | RegexOptions.Multiline); + + public static async Task WireAsync(string component, ProjectInfo projectInfo, ShellUIConfig config) + { + var cwd = Directory.GetCurrentDirectory(); + var layoutName = Layouts[component]; + var changes = new List(); + var notes = new List(); + + var imports = Path.Combine(cwd, "Components", "Account", "Pages", "_Imports.razor"); + if (!DashboardSetup.IsIdentityApp(cwd) || !File.Exists(imports)) + { + notes.Add($"{component} styles the ASP.NET Core Identity pages, and this app has none. Create the app with `dotnet new blazor --auth Individual`, or put @layout {layoutName} on your own sign-in pages."); + } + else + { + var typeRef = LayoutTypeName(projectInfo.RootNamespace, config.LayoutPath, layoutName); + var original = await File.ReadAllTextAsync(imports); + var updated = SetLayout(original, typeRef); + if (updated == null) + notes.Add($"Components/Account/Pages/_Imports.razor sets its own layout. Change it to @layout {typeRef} to use {component}."); + else if (updated != original) + { + await File.WriteAllTextAsync(imports, updated); + changes.Add($"Components/Account/Pages/_Imports.razor: sign-in pages use {layoutName}"); + } + + var pages = AccountPages.Apply(cwd, projectInfo.RootNamespace); + if (pages.Restyled.Count > 0) + changes.Add($"Restyled {pages.Restyled.Count} Identity page(s) under Components/Account (Bootstrap classes → Tailwind)"); + notes.AddRange(pages.Notes); + } + + AnsiConsole.MarkupLine(""); + AnsiConsole.MarkupLine($"[cyan]Auth pages ({layoutName}):[/]"); + foreach (var change in changes) + AnsiConsole.MarkupLine($" [green]✓[/] {Markup.Escape(change)}"); + foreach (var note in notes) + AnsiConsole.MarkupLine($" [yellow]![/] {Markup.Escape(note)}"); + if (changes.Count == 0 && notes.Count == 0) + AnsiConsole.MarkupLine(" [dim]Already set up, nothing changed.[/]"); + } + + internal static string LayoutTypeName(string rootNamespace, string layoutPath, string layoutName) + { + var parts = layoutPath.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries).Where(p => p != "."); + return string.Join(".", new[] { rootNamespace }.Concat(parts).Append(layoutName)); + } + + // .NET 9 and 10 set no layout here and .NET 8 uses its AccountLayout; the Manage pages keep ManageLayout through their own _Imports. + internal static string? SetLayout(string imports, string typeRef) + { + var match = LayoutDirective.Match(imports); + if (!match.Success) + { + var newline = imports.Contains("\r\n") ? "\r\n" : "\n"; + return imports.TrimEnd() + newline + $"@layout {typeRef}" + newline; + } + + var current = match.Groups[2].Value.Split('.').Last(); + if (current != "AccountLayout" && !Layouts.Values.Contains(current)) return null; + return imports[..match.Groups[2].Index] + typeRef + imports[(match.Groups[2].Index + match.Groups[2].Length)..]; + } +} diff --git a/src/ShellUI.CLI/Services/ComponentInstaller.cs b/src/ShellUI.CLI/Services/ComponentInstaller.cs index 9f06e44..047bd06 100644 --- a/src/ShellUI.CLI/Services/ComponentInstaller.cs +++ b/src/ShellUI.CLI/Services/ComponentInstaller.cs @@ -37,6 +37,8 @@ public static async Task InstallComponents(string[] components, bool force { componentList.AddRange(comp.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); } + if (componentList.Any(DashboardSetup.Layouts.ContainsKey) && DashboardSetup.IsIdentityApp(Directory.GetCurrentDirectory())) + componentList.Add("sidebar-account"); var successCount = 0; var skippedCount = 0; @@ -88,6 +90,10 @@ await Loaders.SnakeStatus() { await DashboardSetup.WireAsync(dashboard, projectInfo, config, layoutSwitch); } + foreach (var auth in componentList.Where(c => AuthSetup.Layouts.ContainsKey(c) && installedSet.Contains(c))) + { + await AuthSetup.WireAsync(auth, projectInfo, config); + } var updatedJson = JsonSerializer.Serialize(config, new JsonSerializerOptions { @@ -404,8 +410,7 @@ private static async Task> InstallNuGetDependenciesAsync(P return failed; } - // Consumer pages reference helper types such as ButtonVariant and CommandItem, which live in - // sub-namespaces; the imports are only added once an installed file declares that namespace. + // Pages use helper types such as ButtonVariant from sub-namespaces, imported once an installed file declares them. public static IEnumerable RequiredImports(string rootNamespace, IEnumerable templateContents) { var contents = templateContents.ToList(); diff --git a/src/ShellUI.CLI/Services/DashboardSetup.cs b/src/ShellUI.CLI/Services/DashboardSetup.cs index c550a6f..24fcb1a 100644 --- a/src/ShellUI.CLI/Services/DashboardSetup.cs +++ b/src/ShellUI.CLI/Services/DashboardSetup.cs @@ -22,8 +22,7 @@ public static class DashboardSetup internal const string StockProjectName = "ShellStockProbe"; - // `dotnet new blazor` output for net8.0/net9.0/net10.0 across --interactivity, --all-interactive, --auth and --empty, - // hashed after NormalizeStock. + // `dotnet new blazor` for net8.0/net9.0/net10.0 across --interactivity, --all-interactive, --auth and --empty, after NormalizeStock. private static readonly Dictionary> StockHashes = new(StringComparer.OrdinalIgnoreCase) { ["MainLayout.razor"] = new(StringComparer.Ordinal) @@ -143,6 +142,7 @@ public static async Task WireAsync(string component, ProjectInfo projectInfo, Sh } await UpdateSidebarLinksAsync(cwd, config, changes); + await UseAccountMenuAsync(cwd, config, changes, notes); AnsiConsole.MarkupLine(""); AnsiConsole.MarkupLine($"[cyan]Dashboard setup ({layoutName}):[/]"); @@ -398,5 +398,46 @@ private static async Task UpdateSidebarLinksAsync(string cwd, ShellUIConfig conf changes.Add($"AppSidebar.razor: links for your pages ({string.Join(", ", links.Select(l => l.Title))})"); } + private static readonly Regex IdentityRegistration = new(@"\.Add(Identity|IdentityCore|DefaultIdentity)<", RegexOptions.Compiled); + private static readonly Regex FooterPattern = new(@".*?", RegexOptions.Compiled | RegexOptions.Singleline); + + internal const string AccountFooter = "\n \n "; + + internal static bool IsIdentityApp(string cwd) => + Directory.Exists(Path.Combine(cwd, "Components", "Account")) + && EnumerateSources(cwd, "*.cs").Any(f => IdentityRegistration.IsMatch(File.ReadAllText(f))); + + // Only the template's placeholder user footer is replaced; a customized footer is left alone. + internal static string? RewriteSidebarFooter(string sidebar, string templateFooter) + { + var match = FooterPattern.Match(sidebar); + if (!match.Success) return null; + static string Squash(string s) => Regex.Replace(s, @"\s+", ""); + if (Squash(match.Value) != Squash(templateFooter)) return null; + + var newline = sidebar.Contains("\r\n") ? "\r\n" : "\n"; + return sidebar[..match.Index] + AccountFooter.Replace("\n", newline) + sidebar[(match.Index + match.Length)..]; + } + + private static async Task UseAccountMenuAsync(string cwd, ShellUIConfig config, List changes, List notes) + { + var sidebarPath = Path.Combine(cwd, config.ComponentsPath, "AppSidebar.razor"); + if (!File.Exists(sidebarPath) || !File.Exists(Path.Combine(cwd, config.ComponentsPath, "SidebarAccount.razor"))) return; + + var sidebar = await File.ReadAllTextAsync(sidebarPath); + if (sidebar.Contains(" to it for Log in, Register and Log out."); + return; + } + + await File.WriteAllTextAsync(sidebarPath, rewritten); + changes.Add("AppSidebar.razor: footer shows Log in, Register and Log out (ASP.NET Core Identity)"); + } + private static string Rel(string cwd, string path) => Path.GetRelativePath(cwd, path).Replace('\\', '/'); } diff --git a/src/ShellUI.CLI/Services/InitService.cs b/src/ShellUI.CLI/Services/InitService.cs index d92e9a6..e5d77b3 100644 --- a/src/ShellUI.CLI/Services/InitService.cs +++ b/src/ShellUI.CLI/Services/InitService.cs @@ -139,6 +139,9 @@ await LogoLoader.RunAsync("Installing ShellUI components...", async status => foreach (var note in pages.Notes) AnsiConsole.MarkupLine($"[yellow]![/] {Markup.Escape(note)}"); + if (DashboardSetup.IsIdentityApp(Directory.GetCurrentDirectory())) + AnsiConsole.MarkupLine($"[yellow]![/] {Markup.Escape(AuthSetup.Hint)}"); + status("Setting up MSBuild integration..."); var buildPath = Path.Combine(Directory.GetCurrentDirectory(), "Build"); Directory.CreateDirectory(buildPath); @@ -435,7 +438,7 @@ private static async Task BootstrapHostAsync(ProjectInfo projectInfo) if (File.Exists(appRazor)) { var original = await File.ReadAllTextAsync(appRazor); - var patched = RewriteAppRazor(original); + var patched = RewriteAppRazor(original, DashboardSetup.IsIdentityApp(cwd)); if (patched != original) { await File.WriteAllTextAsync(appRazor, patched); @@ -462,13 +465,31 @@ private static async Task BootstrapHostAsync(ProjectInfo projectInfo) } // Idempotent. Tags that already have attributes are left for the user. - internal static string RewriteAppRazor(string content) + internal const string IdentityRenderMode = +@"@code { + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + // The Identity pages under /Account set cookies, so they need a static render with a real HTTP request. + private IComponentRenderMode? PageRenderMode => + HttpContext.Request.Path.StartsWithSegments(""/Account"") ? null : InteractiveServer; +} +"; + + internal static string RewriteAppRazor(string content, bool identity = false) { // init deletes the template's local Bootstrap copy; CDN links are left alone. content = Regex.Replace(content, @"^[ \t]*", @""); - content = Regex.Replace(content, @"", @""); + var renderMode = identity ? "PageRenderMode" : "InteractiveServer"; + var beforeRenderMode = content; + content = Regex.Replace(content, @"", $@""); + content = Regex.Replace(content, @"", $@""); + if (identity && content != beforeRenderMode && !content.Contains("PageRenderMode =>", StringComparison.Ordinal)) + { + var newline = content.Contains("\r\n") ? "\r\n" : "\n"; + content = content.TrimEnd() + newline + newline + IdentityRenderMode.Replace("\r\n", "\n").Replace("\n", newline); + } // Sets `dark` before first paint to avoid a light flash. if (!content.Contains("ShellUI theme bootstrap")) diff --git a/src/ShellUI.CLI/Services/Loaders.cs b/src/ShellUI.CLI/Services/Loaders.cs index d1fe328..50a80a2 100644 --- a/src/ShellUI.CLI/Services/Loaders.cs +++ b/src/ShellUI.CLI/Services/Loaders.cs @@ -9,8 +9,7 @@ public static Status SnakeStatus() => AnsiConsole.Status().Spinner(SnakeSpinner.Instance).SpinnerStyle(Style.Parse("green")); } -// The Loading component's "snake" grid (3x3) as a one-line spinner. A Braille cell is 2 dots wide -// and 4 tall, so the grid fits in two characters. Spectre falls back to ASCII without Unicode. +// The Loading "snake" grid as a two-character Braille spinner; Spectre falls back to ASCII without Unicode. public sealed class SnakeSpinner : Spinner { private static readonly int[] Path = { 0, 1, 2, 5, 8, 7, 6, 3 }; @@ -41,8 +40,7 @@ internal static string Braille(Func lit) } } -// The Loading component's "logo" variant: the 5x5 ShellUI mark lights up along the diagonal, -// with the current step beside it. Falls back to the snake spinner when there is no terminal. +// The Loading "logo" variant beside the current step; the snake spinner is used when there is no terminal. public static class LogoLoader { private static readonly string[] Pattern = { "X...X", ".X..X", "..X.X", ".X..X", "X...X" }; diff --git a/src/ShellUI.CLI/Services/StockPages.cs b/src/ShellUI.CLI/Services/StockPages.cs index 6ffa7da..f24fa0d 100644 --- a/src/ShellUI.CLI/Services/StockPages.cs +++ b/src/ShellUI.CLI/Services/StockPages.cs @@ -4,8 +4,7 @@ namespace ShellUI.CLI.Services; -// init removes Bootstrap, so the template's sample pages lose their styling. Unmodified copies are -// restyled with Tailwind classes that match ShellUI's Button and Table; edited pages are only reported. +// init removes Bootstrap; unmodified sample pages get ShellUI's Tailwind classes and edited ones are only reported. public static class StockPages { // `dotnet new blazor` pages for net8.0/net9.0/net10.0 across --interactivity, --all-interactive and --auth. @@ -107,12 +106,12 @@ public static Result Apply(string cwd, IEnumerable skipDirs) var skip = skipDirs.Select(d => d.Replace('\\', '/').Trim('/') + "/").ToList(); var restyled = new List(); var leftovers = new List(); - var accountPages = 0; foreach (var file in DashboardSetup.EnumerateSources(cwd, "*.razor")) { var rel = Path.GetRelativePath(cwd, file).Replace('\\', '/'); if (skip.Any(d => rel.StartsWith(d, StringComparison.OrdinalIgnoreCase))) continue; + if (rel.Contains("Components/Account/", StringComparison.OrdinalIgnoreCase)) continue; var content = File.ReadAllText(file); if (IsStock(Path.GetFileName(file), content)) @@ -124,17 +123,13 @@ public static Result Apply(string cwd, IEnumerable skipDirs) } else if (UsesBootstrap(content)) { - if (rel.Contains("/Account/", StringComparison.OrdinalIgnoreCase)) accountPages++; - else leftovers.Add(rel); + leftovers.Add(rel); } } var notes = leftovers .Select(f => $"Kept {f} because it was modified. It still uses Bootstrap classes, which no longer have styles.") .ToList(); - if (accountPages > 0) - notes.Add($"{accountPages} Identity page(s) under Account/ still use Bootstrap classes and need restyling."); - return new Result(restyled, notes); } } diff --git a/src/ShellUI.Templates/ComponentRegistry.cs b/src/ShellUI.Templates/ComponentRegistry.cs index 0d67f65..215d750 100644 --- a/src/ShellUI.Templates/ComponentRegistry.cs +++ b/src/ShellUI.Templates/ComponentRegistry.cs @@ -70,8 +70,12 @@ public static class ComponentRegistry { "sidebar-inset", SidebarInsetTemplate.Metadata }, { "sidebar-rail", SidebarRailTemplate.Metadata }, { "app-sidebar", AppSidebarTemplate.Metadata }, + { "sidebar-account", SidebarAccountTemplate.Metadata }, { "dashboard-01", DashboardLayout01Template.Metadata }, { "dashboard-02", DashboardLayout02Template.Metadata }, + { "auth-01", AuthLayout01Template.Metadata }, + { "auth-02", AuthLayout02Template.Metadata }, + { "auth-03", AuthLayout03Template.Metadata }, { "dropdown", DropdownTemplate.Metadata }, { "dropdown-trigger", DropdownTriggerTemplate.Metadata }, { "dropdown-content", DropdownContentTemplate.Metadata }, @@ -315,8 +319,12 @@ public static class ComponentRegistry "sidebar-inset" => SidebarInsetTemplate.Content, "sidebar-rail" => SidebarRailTemplate.Content, "app-sidebar" => AppSidebarTemplate.Content, + "sidebar-account" => SidebarAccountTemplate.Content, "dashboard-01" => DashboardLayout01Template.Content, "dashboard-02" => DashboardLayout02Template.Content, + "auth-01" => AuthLayout01Template.Content, + "auth-02" => AuthLayout02Template.Content, + "auth-03" => AuthLayout03Template.Content, "dropdown" => DropdownTemplate.Content, "dropdown-trigger" => DropdownTriggerTemplate.Content, "dropdown-content" => DropdownContentTemplate.Content, diff --git a/src/ShellUI.Templates/README.md b/src/ShellUI.Templates/README.md index a9b2b30..886828e 100644 --- a/src/ShellUI.Templates/README.md +++ b/src/ShellUI.Templates/README.md @@ -32,10 +32,10 @@ The global tool command is `shellui`; a local .NET tool is invoked as `dotnet sh ## Template inventory -The registry contains **239 entries**: +The registry contains **243 entries**: -- **90 direct install targets** displayed by `shellui list` -- **149 hidden dependency entries** installed recursively and omitted from the direct list +- **93 direct install targets** displayed by `shellui list` +- **150 hidden dependency entries** installed recursively and omitted from the direct list Each entry provides content plus metadata used by the CLI, including its display name, category, description, target path, version, dependencies, and optional NuGet dependencies. The catalog also includes hidden subcomponents, variants, models, services, JavaScript, and stylesheet assets required by direct targets. diff --git a/src/ShellUI.Templates/Templates/AuthLayout01Template.cs b/src/ShellUI.Templates/Templates/AuthLayout01Template.cs new file mode 100644 index 0000000..a91e889 --- /dev/null +++ b/src/ShellUI.Templates/Templates/AuthLayout01Template.cs @@ -0,0 +1,32 @@ +using ShellUI.Core.Models; + +namespace ShellUI.Templates.Templates; + +public static class AuthLayout01Template +{ + public static ComponentMetadata Metadata => new() + { + Name = "auth-01", + DisplayName = "Auth 01", + Description = "Centered card on a muted background for the sign-in, sign-up and account recovery pages (shadcn login-02/03 style)", + Category = ComponentCategory.Layout, + FilePath = "AuthLayout01.razor", + IsLayoutBlock = true, + Tags = new List { "auth", "login", "signup", "layout", "block", "card" } + }; + + public static string Content => @"@inherits LayoutComponentBase + +
+
+ + A + My App + +
+ @Body +
+
+
+"; +} diff --git a/src/ShellUI.Templates/Templates/AuthLayout02Template.cs b/src/ShellUI.Templates/Templates/AuthLayout02Template.cs new file mode 100644 index 0000000..43f1abe --- /dev/null +++ b/src/ShellUI.Templates/Templates/AuthLayout02Template.cs @@ -0,0 +1,38 @@ +using ShellUI.Core.Models; + +namespace ShellUI.Templates.Templates; + +public static class AuthLayout02Template +{ + public static ComponentMetadata Metadata => new() + { + Name = "auth-02", + DisplayName = "Auth 02", + Description = "Split screen with the form beside a brand panel for the sign-in, sign-up and account recovery pages (shadcn login-04/05 style)", + Category = ComponentCategory.Layout, + FilePath = "AuthLayout02.razor", + IsLayoutBlock = true, + Tags = new List { "auth", "login", "signup", "layout", "block", "split" } + }; + + public static string Content => @"@inherits LayoutComponentBase + +
+
+ + A + My App + +
+
+ @Body +
+
+
+ +
+"; +} diff --git a/src/ShellUI.Templates/Templates/AuthLayout03Template.cs b/src/ShellUI.Templates/Templates/AuthLayout03Template.cs new file mode 100644 index 0000000..731677a --- /dev/null +++ b/src/ShellUI.Templates/Templates/AuthLayout03Template.cs @@ -0,0 +1,30 @@ +using ShellUI.Core.Models; + +namespace ShellUI.Templates.Templates; + +public static class AuthLayout03Template +{ + public static ComponentMetadata Metadata => new() + { + Name = "auth-03", + DisplayName = "Auth 03", + Description = "Minimal centered form for the sign-in, sign-up and account recovery pages (shadcn login-01 style)", + Category = ComponentCategory.Layout, + FilePath = "AuthLayout03.razor", + IsLayoutBlock = true, + Tags = new List { "auth", "login", "signup", "layout", "block", "minimal" } + }; + + public static string Content => @"@inherits LayoutComponentBase + +
+
+ + A + My App + + @Body +
+
+"; +} diff --git a/src/ShellUI.Templates/Templates/SidebarAccountTemplate.cs b/src/ShellUI.Templates/Templates/SidebarAccountTemplate.cs new file mode 100644 index 0000000..de27617 --- /dev/null +++ b/src/ShellUI.Templates/Templates/SidebarAccountTemplate.cs @@ -0,0 +1,86 @@ +using ShellUI.Core.Models; + +namespace ShellUI.Templates.Templates; + +public static class SidebarAccountTemplate +{ + public static ComponentMetadata Metadata => new() + { + Name = "sidebar-account", + DisplayName = "Sidebar Account", + Description = "Log in, Register and Log out for the dashboard sidebar in apps that use ASP.NET Core Identity", + Category = ComponentCategory.Layout, + FilePath = "SidebarAccount.razor", + Dependencies = new List { "sidebar", "avatar" }, + IsAvailable = false, + Tags = new List { "sidebar", "identity", "auth", "account" } + }; + + public static string Content => @"@namespace YourProjectNamespace.Components.UI +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@implements IDisposable +@inject NavigationManager Navigation + + + + + + + +
+ @context.User.Identity?.Name + Manage account +
+
+
+ +
+ + + + + Log out + + +
+
+ + + + + Log in + + + + + + Register + + + +
+
+ +@code { + private string? _currentUrl; + + protected override void OnInitialized() + { + _currentUrl = Navigation.ToBaseRelativePath(Navigation.Uri); + Navigation.LocationChanged += OnLocationChanged; + } + + private void OnLocationChanged(object? sender, LocationChangedEventArgs e) + { + _currentUrl = Navigation.ToBaseRelativePath(e.Location); + InvokeAsync(StateHasChanged); + } + + private static string Initial(string? name) => string.IsNullOrEmpty(name) ? ""?"" : name[..1].ToUpperInvariant(); + + public void Dispose() => Navigation.LocationChanged -= OnLocationChanged; +} +"; +}