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 };