From d15fdd3a4f7ddd546f8da8d79c6bd7099fa60ac6 Mon Sep 17 00:00:00 2001 From: haileymck Date: Tue, 19 May 2026 14:50:23 -0700 Subject: [PATCH] Fix Blazor Identity scaffolding to target client project for WASM/Auto Global apps In Blazor WASM/Auto Global interactivity projects, all interactive pages and layout components live in the .Client project, not the server project. The Identity scaffolder was always generating Account pages into the server project, so they were never reachable and no register/login UI appeared. Fix: detect WASM/Auto Global projects by checking for the absence of Components/Layout/MainLayout.razor in the server project directory (same heuristic as PR #3764). When detected, redirect the scaffolded Account files to the .Client sibling project and update all namespaces accordingly. Changes: - BlazorIdentityModel: add RootNamespace property for path resolution - BlazorIdentityGenerator (VS path): detect WASM/Auto Global via project references, set BaseOutputPath/BlazorIdentityNamespace/BlazorLayoutNamespace/ RootNamespace to the client project; use RootNamespace in ExecuteTemplates - IdentityModel (CLI): add IdentityProjectName for client project path resolution - ValidateIdentityStep (CLI): detect WASM/Auto Global via sibling .Client folder, set BaseOutputPath/IdentityNamespace/IdentityLayoutNamespace/IdentityProjectName - BlazorIdentityHelper (CLI): use IdentityProjectName in StringUtil.ToPath call - BlazorIdentityScaffolderBuilderExtensions (CLI): use IdentityModel.BaseOutputPath for static file placement instead of always using the server project directory Does not repro on Blazor Server Global or per-page/component projects because MainLayout.razor exists in the server project for those configurations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BlazorIdentity/BlazorIdentityGenerator.cs | 30 ++++++++++++++++++- .../BlazorIdentity/BlazorIdentityModel.cs | 3 ++ ...azorIdentityScaffolderBuilderExtensions.cs | 10 +++++++ .../AspNet/Helpers/BlazorIdentityHelper.cs | 3 +- .../AspNet/Models/IdentityModel.cs | 5 ++++ .../ScaffoldSteps/ValidateIdentityStep.cs | 29 +++++++++++++++++- 6 files changed, 77 insertions(+), 3 deletions(-) diff --git a/src/Scaffolding/VS.Web.CG.Mvc/BlazorIdentity/BlazorIdentityGenerator.cs b/src/Scaffolding/VS.Web.CG.Mvc/BlazorIdentity/BlazorIdentityGenerator.cs index 987be5ff2c..41c31c8441 100644 --- a/src/Scaffolding/VS.Web.CG.Mvc/BlazorIdentity/BlazorIdentityGenerator.cs +++ b/src/Scaffolding/VS.Web.CG.Mvc/BlazorIdentity/BlazorIdentityGenerator.cs @@ -323,6 +323,34 @@ internal async Task ValidateAndBuild(BlazorIdentityCommandL } blazorIdentityModel.BaseOutputPath = Path.Combine(AppInfo.ApplicationBasePath, commandlineModel.RelativeFolderPath); + blazorIdentityModel.RootNamespace = ProjectContext.RootNamespace; + + // For WASM/Auto Global Blazor projects, Identity Account files belong in the client project, + // not the server project. In server/server-global projects, MainLayout.razor lives at + // Components/Layout/MainLayout.razor. Its absence indicates a WASM/Auto Global setup where + // the client project holds all interactive components. + var mainLayoutInServerProject = Path.Combine(AppInfo.ApplicationBasePath, "Components", "Layout", "MainLayout.razor"); + if (!FileSystem.FileExists(mainLayoutInServerProject)) + { + var clientProjectRef = ProjectContext.ProjectReferenceInformation + ?.FirstOrDefault(p => + (p.AssemblyName?.EndsWith(".Client", StringComparison.OrdinalIgnoreCase) == true) || + (p.ProjectName?.EndsWith(".Client", StringComparison.OrdinalIgnoreCase) == true)); + if (clientProjectRef != null) + { + var clientProjectPath = Path.GetDirectoryName(clientProjectRef.FullPath); + if (!string.IsNullOrEmpty(clientProjectPath)) + { + var clientRootNamespace = clientProjectRef.AssemblyName + ?? Path.GetFileNameWithoutExtension(clientProjectRef.FullPath); + blazorIdentityModel.BaseOutputPath = Path.Combine(clientProjectPath, commandlineModel.RelativeFolderPath); + blazorIdentityModel.RootNamespace = clientRootNamespace; + blazorIdentityModel.BlazorIdentityNamespace = $"{clientRootNamespace}.Components.Account"; + blazorIdentityModel.BlazorLayoutNamespace = $"{clientRootNamespace}.Layout.MainLayout"; + } + } + } + return blazorIdentityModel; } @@ -474,7 +502,7 @@ private void ExecuteTemplates(BlazorIdentityModel templateModel) string extension = templateName.StartsWith("Pages", StringComparison.OrdinalIgnoreCase) || templateName.StartsWith("Shared", StringComparison.OrdinalIgnoreCase) ? ".razor" : ".cs"; string templateNameWithNamespace = $"{templateModel.BlazorIdentityNamespace}.{templateName}"; - string templatePath = StringUtil.ToPath(templateNameWithNamespace, templateModel.BaseOutputPath, ProjectContext.RootNamespace); + string templatePath = StringUtil.ToPath(templateNameWithNamespace, templateModel.BaseOutputPath, templateModel.RootNamespace ?? ProjectContext.RootNamespace); string templatedFilePath = $"{templatePath}{extension}"; var folderName = Path.GetDirectoryName(templatedFilePath); if (!FileSystem.DirectoryExists(folderName)) diff --git a/src/Scaffolding/VS.Web.CG.Mvc/BlazorIdentity/BlazorIdentityModel.cs b/src/Scaffolding/VS.Web.CG.Mvc/BlazorIdentity/BlazorIdentityModel.cs index ec051a7ecc..1e3ee8b0c2 100644 --- a/src/Scaffolding/VS.Web.CG.Mvc/BlazorIdentity/BlazorIdentityModel.cs +++ b/src/Scaffolding/VS.Web.CG.Mvc/BlazorIdentity/BlazorIdentityModel.cs @@ -23,5 +23,8 @@ public BlazorIdentityModel() public DbProvider DatabaseProvider { get; set; } public List FilesToGenerate { get; set; } public string BaseOutputPath { get; set; } + // The root namespace used for computing output file paths. Defaults to the server project's + // root namespace, but overridden to the client project's namespace for WASM/Auto Global projects. + public string RootNamespace { get; set; } } } diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Extensions/BlazorIdentityScaffolderBuilderExtensions.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Extensions/BlazorIdentityScaffolderBuilderExtensions.cs index 7f566adbae..4371409f97 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Extensions/BlazorIdentityScaffolderBuilderExtensions.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Extensions/BlazorIdentityScaffolderBuilderExtensions.cs @@ -127,6 +127,16 @@ public static IScaffoldBuilder WithBlazorIdentityStaticFilesStep(this IScaffoldB step.ProjectPath = projectPath; + if (context.Properties.TryGetValue(nameof(IdentityModel), out var identityModelObj) && + identityModelObj is IdentityModel identityModel && + !string.IsNullOrEmpty(identityModel.BaseOutputPath) && + Directory.Exists(identityModel.BaseOutputPath)) + { + step.BaseOutputDirectory = Path.Combine(identityModel.BaseOutputPath, "Components", "Account", "Shared"); + step.FileName = "PasskeySubmit.razor.js"; + return; + } + if (context.Properties.TryGetValue(nameof(IdentitySettings), out var commandSettingsObj) && commandSettingsObj is IdentitySettings commandSettings) { var projectDirectory = Path.GetDirectoryName(commandSettings.Project); diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Helpers/BlazorIdentityHelper.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Helpers/BlazorIdentityHelper.cs index 3ff3ffe038..3c0c3beb4e 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Helpers/BlazorIdentityHelper.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Helpers/BlazorIdentityHelper.cs @@ -37,7 +37,8 @@ internal static IEnumerable GetTextTemplatingProperties( x.FullName.Contains(templateFullName) && x.Name.Equals(typeName, StringComparison.OrdinalIgnoreCase)); - var projectName = Path.GetFileNameWithoutExtension(blazorIdentityModel.ProjectInfo.ProjectPath); + var projectName = blazorIdentityModel.IdentityProjectName + ?? Path.GetFileNameWithoutExtension(blazorIdentityModel.ProjectInfo.ProjectPath); if (!string.IsNullOrEmpty(templatePath) && templateType is not null && !string.IsNullOrEmpty(projectName)) { diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Models/IdentityModel.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Models/IdentityModel.cs index 54aa86ef9a..073b719d0b 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Models/IdentityModel.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Models/IdentityModel.cs @@ -42,6 +42,11 @@ internal class IdentityModel /// public required string BaseOutputPath { get; set; } /// + /// Gets or sets the project name used for identity file namespace and path resolution. + /// For WASM/Auto Global projects this is the client project name; otherwise the server project name. + /// + public string? IdentityProjectName { get; set; } + /// /// Gets or sets a value indicating whether to overwrite existing files. /// public bool Overwrite { get; set; } diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/ValidateIdentityStep.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/ValidateIdentityStep.cs index fe5d105c11..cb62189b26 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/ValidateIdentityStep.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/ValidateIdentityStep.cs @@ -217,6 +217,32 @@ public override async Task ExecuteAsync(ScaffolderContext context, Cancell userClassNamespace = $"{projectName}.Data"; } + // For WASM/Auto Global Blazor projects, Identity Account files belong in the client project. + // In server/server-global projects, MainLayout.razor lives at Components/Layout/MainLayout.razor. + // Its absence indicates a WASM/Auto Global setup where the client project holds all interactive components. + string baseOutputPath = projectDirectory; + string? identityProjectName = null; + if (settings.BlazorScenario && !string.IsNullOrEmpty(projectName)) + { + var mainLayoutInServerProject = Path.Combine(projectDirectory, "Components", "Layout", "MainLayout.razor"); + if (!_fileSystem.FileExists(mainLayoutInServerProject)) + { + var parentDirectory = Path.GetDirectoryName(projectDirectory); + if (!string.IsNullOrEmpty(parentDirectory)) + { + var clientProjectFolderName = projectName + ".Client"; + var clientProjectDirectory = Path.Combine(parentDirectory, clientProjectFolderName); + if (_fileSystem.DirectoryExists(clientProjectDirectory)) + { + baseOutputPath = clientProjectDirectory; + identityProjectName = clientProjectFolderName; + identityNamespace = $"{clientProjectFolderName}.Components.Account"; + identityLayoutNamespace = $"{clientProjectFolderName}.Layout.MainLayout"; + } + } + } + } + IdentityModel scaffoldingModel = new() { ProjectInfo = projectInfo, @@ -225,7 +251,8 @@ public override async Task ExecuteAsync(ScaffolderContext context, Cancell UserClassName = AspNetConstants.Identity.UserClassName, UserClassNamespace = userClassNamespace, IdentityLayoutNamespace = identityLayoutNamespace, - BaseOutputPath = projectDirectory, + BaseOutputPath = baseOutputPath, + IdentityProjectName = identityProjectName, Overwrite = settings.Overwrite };