Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
930033b
Initial plan
Copilot Oct 24, 2025
c7cf110
Add passkey template files and update existing templates
Copilot Oct 24, 2025
f54f8ba
Update tests for passkey support
Copilot Oct 24, 2025
13e6aef
[WIP] Update to match .NET 10 template
MackinnonBuck Oct 27, 2025
4c27b8f
Remove IdentityUserAccessor and AccountLayout, add AccessDenied, fix …
Copilot Oct 27, 2025
6947f16
Update IdentityNoOpEmailSender to use file-scoped namespace and corre…
Copilot Oct 27, 2025
f1373d8
Convert root-level .cs templates to file-scoped namespaces
Copilot Oct 27, 2025
f6b973e
Remove IdentityUserAccessor service registration from Program.cs changes
Copilot Oct 27, 2025
612afb5
Sync 20 template files with .NET 10 reference (Pages and Shared)
Copilot Oct 27, 2025
7ef05dd
Sync 13 Manage page templates with .NET 10 reference
Copilot Oct 27, 2025
dcca213
Complete template synchronization - all 46 files match .NET 10 reference
Copilot Oct 28, 2025
64543ef
Fix .Interfaces.cs files to contain only empty partial class declarat…
Copilot Oct 28, 2025
49fa765
Remove BOM characters and sync dotnet-scaffold templates with VS.Web.…
Copilot Oct 28, 2025
298f0d6
[WIP] Fixes + generate `.cs` output
MackinnonBuck Nov 12, 2025
89c95bc
More fixes + simplify
MackinnonBuck Nov 13, 2025
f50e497
Fix `ManageLayout`
MackinnonBuck Nov 13, 2025
9499015
Merge remote-tracking branch 'origin/main' into copilot/add-passkey-s…
MackinnonBuck Nov 13, 2025
f14da1a
Revert changes to `dotnet-scaffolding`
MackinnonBuck Nov 13, 2025
dc52139
Fix line endings + add static asset copying to new scaffolder
MackinnonBuck Nov 14, 2025
257e594
Fix `NewDbContext.tt` pack location
MackinnonBuck Nov 14, 2025
3faab25
Apply UI updates to new scaffolder
MackinnonBuck Nov 14, 2025
6014e83
Merge remote-tracking branch 'origin/main' into copilot/add-passkey-s…
MackinnonBuck Nov 14, 2025
82d26ba
More reliable script replacement
MackinnonBuck Nov 14, 2025
dcaa3df
Fix DbContext output
MackinnonBuck Nov 14, 2025
8728578
Normalize `.Interfaces.cs` files
MackinnonBuck Nov 14, 2025
9b2f202
Add comments to `.csproj` files
MackinnonBuck Nov 15, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ private IEnumerable<string> GeneralT4Files
}
}

private IReadOnlyList<(string TemplateFolder, string FullPath)> _allBlazorIdentityStaticFiles;
private IReadOnlyList<(string TemplateFolder, string FullPath)> AllBlazorIdentityStaticFiles
{
get => _allBlazorIdentityStaticFiles ??= [.. BlazorIdentityHelper.GetBlazorIdentityStaticFiles(FileSystem, TemplateFolders)];
}

private IList<Type> _blazorIdentityTemplateTypes;
private IList<Type> BlazorIdentityTemplateTypes
{
Expand Down Expand Up @@ -141,6 +147,7 @@ public async Task GenerateCode(BlazorIdentityCommandLineModel model)

var blazorTemplateModel = await ValidateAndBuild(model);
ExecuteTemplates(blazorTemplateModel);
AddStaticFiles(blazorTemplateModel);
AddReadmeFile(blazorTemplateModel.BaseOutputPath);
await ModifyFilesAsync(blazorTemplateModel);
}
Expand Down Expand Up @@ -463,8 +470,9 @@ private void ExecuteTemplates(BlazorIdentityModel templateModel)
var templatedString = templateInvoker.InvokeTemplate(contextTemplate, dictParams);
if (!string.IsNullOrEmpty(templatedString))
{
//currently only Identity...cs files are of CSharp type, rest are razor
string extension = templateName.StartsWith("identity", StringComparison.OrdinalIgnoreCase) ? ".cs" : ".razor";
// Files in Pages and Shared folders are Razor components, others are C# files
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 templatedFilePath = $"{templatePath}{extension}";
Expand Down Expand Up @@ -546,6 +554,31 @@ private void ExecuteDbContextTemplate(IdentityDbContextModel model)
}
}

private void AddStaticFiles(BlazorIdentityModel templateModel)
{
var identityComponentsAccountPath = Path.Combine(templateModel.BaseOutputPath, "Components", "Account");
if (!FileSystem.DirectoryExists(identityComponentsAccountPath))
{
FileSystem.CreateDirectory(identityComponentsAccountPath);
}

foreach (var (templateFolder, sourceFilePath) in AllBlazorIdentityStaticFiles)
{
var relativeFilePath = Path.GetRelativePath(templateFolder, sourceFilePath);
var destinationFilePath = Path.Combine(identityComponentsAccountPath, relativeFilePath);

var folderName = Path.GetDirectoryName(destinationFilePath);
if (!FileSystem.DirectoryExists(folderName))
{
FileSystem.CreateDirectory(folderName);
}

var fileContent = FileSystem.ReadAllText(sourceFilePath);
FileSystem.WriteAllText(destinationFilePath, fileContent);
Logger.LogMessage($"Added static file : {destinationFilePath}");
}
}

private ITextTransformation GetBlazorIdentityTransformation(string templateName)
{
if (string.IsNullOrEmpty(templateName))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ internal static class BlazorIdentityHelper
internal const string GetConnectionString = nameof(GetConnectionString);
internal const string UseSqlite = nameof(UseSqlite);
internal const string UseSqlServer = nameof(UseSqlServer);

private static readonly HashSet<string> _staticFileExtensions = [".js"];

internal static string GetFormattedRelativeIdentityFile(string fullFileName)
{
string identifier = "BlazorIdentity\\";
Expand Down Expand Up @@ -56,8 +59,8 @@ internal static string EditIdentityStrings(string stringToModify, string dbConte
}
if (stringToModify.Contains(GetConnectionString))
{
modifiedString = modifiedString.Replace("GetConnectionString(\"{0}\")", $"GetConnectionString(\"{dbContextClassName}Connection\")");
modifiedString = modifiedString.Replace("Connection string '{0}'", $"Connection string '{dbContextClassName}Connection'");
modifiedString = modifiedString.Replace("GetConnectionString(\"{0}\")", $"GetConnectionString(\"{dbContextClassName}\")");
modifiedString = modifiedString.Replace("Connection string '{0}'", $"Connection string '{dbContextClassName}'");
}

return modifiedString;
Expand All @@ -74,6 +77,20 @@ internal static IEnumerable<string> GetGeneralT4Files(IFileSystem fileSystem, IE
return null;
}

internal static IEnumerable<(string TemplateFolder, string FullPath)> GetBlazorIdentityStaticFiles(IFileSystem fileSystem, IEnumerable<string> templateFolders)
{
var blazorIdentityTemplateFolder = templateFolders.FirstOrDefault(x => x.Contains("BlazorIdentity", StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrEmpty(blazorIdentityTemplateFolder) && fileSystem.DirectoryExists(blazorIdentityTemplateFolder))
{
var allFiles = fileSystem.EnumerateFiles(blazorIdentityTemplateFolder, "*.*", SearchOption.AllDirectories);
return allFiles
.Where(f => _staticFileExtensions.Contains(Path.GetExtension(f)))
.Select(f => (blazorIdentityTemplateFolder, f));
}

return null;
}

/// <summary>
/// returning full file paths (.tt) for all blazor identity templates
/// TODO throw exception if nothing found, can't really scaffold is no files were found
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,6 @@
"Newline": true
}
},
{
"InsertBefore": [ "var app = WebApplication.CreateBuilder.Build();" ],
"Block": "WebApplication.CreateBuilder.Services.AddScoped<IdentityUserAccessor>()",
"LeadingTrivia": {
"Newline": true
}
},
{
"InsertBefore": [ "var app = WebApplication.CreateBuilder.Build();" ],
"Block": "WebApplication.CreateBuilder.Services.AddScoped<IdentityRedirectManager>()",
Expand Down Expand Up @@ -71,7 +64,11 @@
"InsertBefore": [ "var app = WebApplication.CreateBuilder.Build();" ],
"CheckBlock" : "builder.Services.AddIdentityCore",
"MultiLineBlock": [
"builder.Services.AddIdentityCore<ApplicationUser>(options => options.SignIn.RequireConfirmedAccount = true)",
"builder.Services.AddIdentityCore<ApplicationUser>(options =>",
" {",
" options.SignIn.RequireConfirmedAccount = true;",
" options.Stores.SchemaVersion = IdentitySchemaVersions.Version3;",
" })",
" .AddEntityFrameworkStores<ApplicationDbContext>()",
" .AddSignInManager()",
" .AddDefaultTokenProviders()"
Expand Down Expand Up @@ -247,6 +244,21 @@
"CheckBlock": "Microsoft.AspNetCore.Components.Authorization"
}
]
},
{
"FileName": "Components\\App.razor",
"Replacements": [
{
"ReplaceSnippet": [
"</body>"
],
"MultiLineBlock": [
" <script src=\"@Assets[\"Components/Account/Shared/PasskeySubmit.razor.js\"]\" type=\"module\"></script>",
"</body>"
],
"CheckBlock": "PasskeySubmit.razor.js"
}
]
}
]
}
}
4 changes: 2 additions & 2 deletions src/Scaffolding/VS.Web.CG.Mvc/Templates/Blazor/Delete.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,8 @@ public virtual string TransformText()
this.Write(this.ToStringHelper.ToStringWithCulture(primaryKeyName));
this.Write(");\r\n\r\n if (");
this.Write(this.ToStringHelper.ToStringWithCulture(modelNameLowerInv));
this.Write(" is null)\r\n {\r\n NavigationManager.NotFound();\r\n " +
" }\r\n }\r\n\r\n private async Task Delete");
this.Write(" is null)\r\n {\r\n NavigationManager.NotFound();\r\n }\r\n }" +
"\r\n\r\n private async Task Delete");
this.Write(this.ToStringHelper.ToStringWithCulture(modelName));
this.Write("()\r\n {\r\n using var context = DbFactory.CreateDbContext();\r\n cont" +
"ext.");
Expand Down
4 changes: 2 additions & 2 deletions src/Scaffolding/VS.Web.CG.Mvc/Templates/Blazor/Details.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,8 @@ public virtual string TransformText()
this.Write(this.ToStringHelper.ToStringWithCulture(primaryKeyName));
this.Write(");\r\n\r\n if (");
this.Write(this.ToStringHelper.ToStringWithCulture(modelNameLowerInv));
this.Write(" is null)\r\n {\r\n NavigationManager.NotFound();\r\n " +
" }\r\n }\r\n}\r\n");
this.Write(" is null)\r\n {\r\n NavigationManager.NotFound();\r\n }\r\n }" +
"\r\n}\r\n");
return this.GenerationEnvironment.ToString();
}
private global::Microsoft.VisualStudio.TextTemplating.ITextTemplatingEngineHost hostValue;
Expand Down
6 changes: 3 additions & 3 deletions src/Scaffolding/VS.Web.CG.Mvc/Templates/Blazor/Edit.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,9 @@ public virtual string TransformText()
this.Write(this.ToStringHelper.ToStringWithCulture(modelName));
this.Write("!.");
this.Write(this.ToStringHelper.ToStringWithCulture(primaryKeyName));
this.Write("))\r\n {\r\n NavigationManager.NotFound();\r\n " +
" }\r\n else\r\n {\r\n throw;\r\n " +
" }\r\n }\r\n\r\n NavigationManager.NavigateTo(\"/");
this.Write("))\r\n {\r\n NavigationManager.NotFound();\r\n }\r\n" +
" else\r\n {\r\n throw;\r\n }\r\n " +
"}\r\n\r\n NavigationManager.NavigateTo(\"/");
this.Write(this.ToStringHelper.ToStringWithCulture(pluralModelLowerInv));
this.Write("\");\r\n }\r\n\r\n private bool ");
this.Write(this.ToStringHelper.ToStringWithCulture(modelName));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,3 @@ public partial class IdentityComponentsEndpointRouteBuilderExtensions : ITextTra
{
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public virtual string TransformText()
$"{Model.BlazorIdentityNamespace}.Pages",
$"{Model.BlazorIdentityNamespace}.Pages.Manage",
"Microsoft.AspNetCore.Authentication",
"Microsoft.AspNetCore.Antiforgery",
"Microsoft.AspNetCore.Components.Authorization",
"Microsoft.AspNetCore.Http.Extensions",
"Microsoft.AspNetCore.Identity",
Expand Down Expand Up @@ -83,7 +84,7 @@ public static IEndpointConventionBuilder MapAdditionalIdentityEndpoints(this IEn

accountGroup.MapPost(""/Logout"", async (
ClaimsPrincipal user,
SignInManager<");
[FromServices] SignInManager<");
this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName));
this.Write(@"> signInManager,
[FromForm] string returnUrl) =>
Expand All @@ -92,6 +93,51 @@ public static IEndpointConventionBuilder MapAdditionalIdentityEndpoints(this IEn
return TypedResults.LocalRedirect($""~/{returnUrl}"");
});

accountGroup.MapPost(""/PasskeyCreationOptions"", async (
HttpContext context,
[FromServices] UserManager<");
this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName));
this.Write("> userManager,\r\n [FromServices] SignInManager<");
this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName));
this.Write(@"> signInManager,
[FromServices] IAntiforgery antiforgery) =>
{
await antiforgery.ValidateRequestAsync(context);

var user = await userManager.GetUserAsync(context.User);
if (user is null)
{
return Results.NotFound($""Unable to load user with ID '{userManager.GetUserId(context.User)}'."");
}

var userId = await userManager.GetUserIdAsync(user);
var userName = await userManager.GetUserNameAsync(user) ?? ""User"";
var optionsJson = await signInManager.MakePasskeyCreationOptionsAsync(new()
{
Id = userId,
Name = userName,
DisplayName = userName
});
return TypedResults.Content(optionsJson, contentType: ""application/json"");
});

accountGroup.MapPost(""/PasskeyRequestOptions"", async (
HttpContext context,
[FromServices] UserManager<");
this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName));
this.Write("> userManager,\r\n [FromServices] SignInManager<");
this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName));
this.Write(@"> signInManager,
[FromServices] IAntiforgery antiforgery,
[FromQuery] string? username) =>
{
await antiforgery.ValidateRequestAsync(context);

var user = string.IsNullOrEmpty(username) ? null : await userManager.FindByNameAsync(username);
var optionsJson = await signInManager.MakePasskeyRequestOptionsAsync(user);
return TypedResults.Content(optionsJson, contentType: ""application/json"");
});

var manageGroup = accountGroup.MapGroup(""/Manage"").RequireAuthorization();

manageGroup.MapPost(""/LinkExternalLogin"", async (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ using System.Text.Json;
$"{Model.BlazorIdentityNamespace}.Pages",
$"{Model.BlazorIdentityNamespace}.Pages.Manage",
"Microsoft.AspNetCore.Authentication",
"Microsoft.AspNetCore.Antiforgery",
"Microsoft.AspNetCore.Components.Authorization",
"Microsoft.AspNetCore.Http.Extensions",
"Microsoft.AspNetCore.Identity",
Expand Down Expand Up @@ -58,13 +59,52 @@ namespace Microsoft.AspNetCore.Routing

accountGroup.MapPost("/Logout", async (
ClaimsPrincipal user,
SignInManager<<#= Model.UserClassName #>> signInManager,
[FromServices] SignInManager<<#= Model.UserClassName #>> signInManager,
[FromForm] string returnUrl) =>
{
await signInManager.SignOutAsync();
return TypedResults.LocalRedirect($"~/{returnUrl}");
});

accountGroup.MapPost("/PasskeyCreationOptions", async (
HttpContext context,
[FromServices] UserManager<<#= Model.UserClassName #>> userManager,
[FromServices] SignInManager<<#= Model.UserClassName #>> signInManager,
[FromServices] IAntiforgery antiforgery) =>
{
await antiforgery.ValidateRequestAsync(context);

var user = await userManager.GetUserAsync(context.User);
if (user is null)
{
return Results.NotFound($"Unable to load user with ID '{userManager.GetUserId(context.User)}'.");
}

var userId = await userManager.GetUserIdAsync(user);
var userName = await userManager.GetUserNameAsync(user) ?? "User";
var optionsJson = await signInManager.MakePasskeyCreationOptionsAsync(new()
{
Id = userId,
Name = userName,
DisplayName = userName
});
return TypedResults.Content(optionsJson, contentType: "application/json");
});

accountGroup.MapPost("/PasskeyRequestOptions", async (
HttpContext context,
[FromServices] UserManager<<#= Model.UserClassName #>> userManager,
[FromServices] SignInManager<<#= Model.UserClassName #>> signInManager,
[FromServices] IAntiforgery antiforgery,
[FromQuery] string? username) =>
{
await antiforgery.ValidateRequestAsync(context);

var user = string.IsNullOrEmpty(username) ? null : await userManager.FindByNameAsync(username);
var optionsJson = await signInManager.MakePasskeyRequestOptionsAsync(user);
return TypedResults.Content(optionsJson, contentType: "application/json");
});

var manageGroup = accountGroup.MapGroup("/Manage").RequireAuthorization();

manageGroup.MapPost("/LinkExternalLogin", async (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,3 @@ public partial class IdentityNoOpEmailSender : ITextTransformation
{
}
}

Loading