Skip to content

Commit 290cea4

Browse files
csharpfritzCopilot
andcommitted
Fix G3 auth redirect scaffold and G4 validator type inference
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent fb08940 commit 290cea4

9 files changed

Lines changed: 254 additions & 41 deletions

File tree

.squad/agents/bishop/history.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
- NEVER replace ListView/FormView/GridView — must use BWFC components (2026-05-07T09:24)
3838

3939
## Learnings
40+
- **2026-05-08T10:42:43-04:00:** Generated account-page stubs are far more benchmark-ready when the semantic rewrite, Program.cs scaffold, and redirect-handler annotator all agree on one POST-based auth contract. Emitting `/Account/LoginHandler` + `/Account/RegisterHandler`, preserving `ReturnUrl`, and configuring the application cookie paths in the scaffold removes a common first-pass auth failure without needing hand-edited Program.cs.
41+
- **2026-05-08T10:42:43-04:00:** `RequiredFieldValidator` generic inference should prefer the validated control's value type instead of a blanket default. In practice, mapping `TextBox` controls to `string` and only falling back to `object` when no control hint exists prevents noisy generic warnings while keeping the transform deterministic.
4042
- **2026-05-08T10:00:31-04:00:** `copilot-instructions.md` now needs explicit migration-tooling guidance: start from the CLI wrapper, preserve BWFC data controls, trust `WebFormsPageBase` shims, and keep transform registration instructions paired across `Program.cs` and `TestHelpers.CreateDefaultPipeline()` so future agents can repair WingtipToys-class migrations without outside help.
4143
- **2026-05-07T13:17:32-04:00:** ListView `GroupTemplate` and `LayoutTemplate` emission is more reliable when the CLI emits explicit `Context` names (`items`, `groups`) instead of leaving raw `@context` placeholders. That keeps generated placeholder markup readable and removes one common manual repair step on Wingtip fixtures.
4244
- **2026-05-07T13:17:32-04:00:** Typed `GridView` columns must inherit the parent grid `ItemType`; leaving `TemplateField` at `ItemType="object"` breaks migrated template expressions like `@Item.Quantity`. A dedicated post-attribute-strip pass that rewrites child BWFC column generics to the grid row type fixes this deterministically.

src/BlazorWebFormsComponents.Cli/Pipeline/RedirectHandlerAnnotator.cs

Lines changed: 71 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,13 @@ public async Task<int> AnnotateAsync(MigrationContext context, MigrationReport r
5353
if (context.SourceFiles.Any(IsLoginPage))
5454
{
5555
handlerBlocks.Add(BuildLoginHandlerBlock());
56-
report.AddManualItem("Account/Login.aspx", 0, "bwfc-identity", "Login.razor submits to /Account/PerformLoginreplace the generated stub with your real authentication endpoint.", "high");
56+
report.AddManualItem("Account/Login.aspx", 0, "bwfc-identity", "Login.razor now posts to /Account/LoginHandlerverify the generated Identity sign-in flow matches your app's redirect and claims requirements.", "medium");
5757
}
5858

5959
if (context.SourceFiles.Any(IsRegisterPage))
6060
{
6161
handlerBlocks.Add(BuildRegisterHandlerBlock());
62-
report.AddManualItem("Account/Register.aspx", 0, "bwfc-identity", "Register.razor submits to /Account/PerformRegisterreplace the generated stub with your real registration endpoint.", "high");
62+
report.AddManualItem("Account/Register.aspx", 0, "bwfc-identity", "Register.razor now posts to /Account/RegisterHandlerverify the generated Identity registration flow matches your app's user profile and confirmation requirements.", "medium");
6363
}
6464

6565
if (handlerBlocks.Count == 0)
@@ -139,30 +139,88 @@ private static bool IsRegisterPage(SourceFile sourceFile) =>
139139

140140
private static string BuildLoginHandlerBlock() =>
141141
"""
142-
app.MapGet("/Account/PerformLogin", (string? email, string? password, string? returnUrl) =>
142+
app.MapPost("/Account/LoginHandler", async (HttpContext context, SignInManager<IdentityUser> signInManager) =>
143143
{
144+
var form = await context.Request.ReadFormAsync();
145+
var email = form["Email"].ToString().Trim();
146+
var password = form["Password"].ToString();
147+
var returnUrl = form["ReturnUrl"].ToString();
148+
var rememberMe = string.Equals(form["RememberMe"], "on", StringComparison.OrdinalIgnoreCase)
149+
|| string.Equals(form["RememberMe"], "true", StringComparison.OrdinalIgnoreCase);
150+
var hasLocalReturnUrl = !string.IsNullOrWhiteSpace(returnUrl)
151+
&& Uri.IsWellFormedUriString(returnUrl, UriKind.Relative)
152+
&& returnUrl.StartsWith("/", StringComparison.Ordinal);
153+
144154
if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(password))
145-
return Results.Redirect("/Account/Login?error=Email%20and%20password%20are%20required");
155+
{
156+
var missingCredentialsUrl = hasLocalReturnUrl
157+
? $"/Account/Login?error=Email%20and%20password%20are%20required&returnUrl={Uri.EscapeDataString(returnUrl)}"
158+
: "/Account/Login?error=Email%20and%20password%20are%20required";
159+
return Results.LocalRedirect(missingCredentialsUrl);
160+
}
161+
162+
var result = await signInManager.PasswordSignInAsync(email, password, rememberMe, lockoutOnFailure: false);
163+
if (!result.Succeeded)
164+
{
165+
var invalidLoginUrl = hasLocalReturnUrl
166+
? $"/Account/Login?error=Invalid%20login%20attempt&returnUrl={Uri.EscapeDataString(returnUrl)}"
167+
: "/Account/Login?error=Invalid%20login%20attempt";
168+
return Results.LocalRedirect(invalidLoginUrl);
169+
}
146170
147-
// TODO(bwfc-identity): Replace this stub with a real authentication endpoint that issues cookies.
148-
return Results.Redirect(string.IsNullOrWhiteSpace(returnUrl)
149-
? "/Account/Login?error=Authentication%20is%20not%20wired%20yet"
150-
: $"/Account/Login?error=Authentication%20is%20not%20wired%20yet&returnUrl={Uri.EscapeDataString(returnUrl)}");
171+
return Results.LocalRedirect(hasLocalReturnUrl ? returnUrl : "/");
151172
});
152173
""";
153174

154175
private static string BuildRegisterHandlerBlock() =>
155176
"""
156-
app.MapGet("/Account/PerformRegister", (string? email, string? password, string? confirmPassword) =>
177+
app.MapPost("/Account/RegisterHandler", async (HttpContext context, UserManager<IdentityUser> userManager) =>
157178
{
179+
var form = await context.Request.ReadFormAsync();
180+
var email = form["Email"].ToString().Trim();
181+
var password = form["Password"].ToString();
182+
var confirmPassword = form["ConfirmPassword"].ToString();
183+
var returnUrl = form["ReturnUrl"].ToString();
184+
var hasLocalReturnUrl = !string.IsNullOrWhiteSpace(returnUrl)
185+
&& Uri.IsWellFormedUriString(returnUrl, UriKind.Relative)
186+
&& returnUrl.StartsWith("/", StringComparison.Ordinal);
187+
158188
if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(password))
159-
return Results.Redirect("/Account/Register?error=Email%20and%20password%20are%20required");
189+
{
190+
var missingCredentialsUrl = hasLocalReturnUrl
191+
? $"/Account/Register?error=Email%20and%20password%20are%20required&returnUrl={Uri.EscapeDataString(returnUrl)}"
192+
: "/Account/Register?error=Email%20and%20password%20are%20required";
193+
return Results.LocalRedirect(missingCredentialsUrl);
194+
}
160195
161196
if (!string.Equals(password, confirmPassword, StringComparison.Ordinal))
162-
return Results.Redirect("/Account/Register?error=Passwords%20do%20not%20match");
197+
{
198+
var mismatchedPasswordUrl = hasLocalReturnUrl
199+
? $"/Account/Register?error=Passwords%20do%20not%20match&returnUrl={Uri.EscapeDataString(returnUrl)}"
200+
: "/Account/Register?error=Passwords%20do%20not%20match";
201+
return Results.LocalRedirect(mismatchedPasswordUrl);
202+
}
203+
204+
var user = new IdentityUser
205+
{
206+
UserName = email,
207+
Email = email
208+
};
209+
210+
var result = await userManager.CreateAsync(user, password);
211+
if (!result.Succeeded)
212+
{
213+
var error = result.Errors.FirstOrDefault()?.Description ?? "Registration failed";
214+
var registrationErrorUrl = hasLocalReturnUrl
215+
? $"/Account/Register?error={Uri.EscapeDataString(error)}&returnUrl={Uri.EscapeDataString(returnUrl)}"
216+
: $"/Account/Register?error={Uri.EscapeDataString(error)}";
217+
return Results.LocalRedirect(registrationErrorUrl);
218+
}
163219
164-
// TODO(bwfc-identity): Replace this stub with a real registration endpoint that creates a user record.
165-
return Results.Redirect("/Account/Login?registered=1");
220+
var registeredUrl = hasLocalReturnUrl
221+
? $"/Account/Login?registered=1&returnUrl={Uri.EscapeDataString(returnUrl)}"
222+
: "/Account/Login?registered=1";
223+
return Results.LocalRedirect(registeredUrl);
166224
});
167225
""";
168226
}

src/BlazorWebFormsComponents.Cli/Scaffolding/ProgramCsEmitter.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ public string Generate(string projectName, RuntimeProfile profile, DatabaseProvi
2323
builder.AppendLine("var builder = WebApplication.CreateBuilder(args);");
2424
builder.AppendLine();
2525
builder.AppendLine("builder.Services.AddRazorComponents();");
26+
builder.AppendLine("builder.Services.AddAntiforgery();");
2627

2728
if (profile.NeedsSession)
2829
{
@@ -65,6 +66,11 @@ public string Generate(string projectName, RuntimeProfile profile, DatabaseProvi
6566
builder.AppendLine("// Add identityBuilder.AddEntityFrameworkStores<YourDbContext>() after migrating your auth store.");
6667
}
6768

69+
builder.AppendLine("builder.Services.ConfigureApplicationCookie(options =>");
70+
builder.AppendLine("{");
71+
builder.AppendLine(" options.LoginPath = \"/Account/Login\";");
72+
builder.AppendLine(" options.LogoutPath = \"/Account/Logout\";");
73+
builder.AppendLine("});");
6874
builder.AppendLine("builder.Services.AddAuthorization();");
6975
builder.AppendLine("builder.Services.AddCascadingAuthenticationState();");
7076
}

src/BlazorWebFormsComponents.Cli/SemanticPatterns/AccountPagesSemanticPattern.cs

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -122,11 +122,12 @@ private static string BuildNormalizedAccountMarkup(string markup, string fileNam
122122
builder.AppendLine("}");
123123
builder.AppendLine($"<form method=\"{GetFormMethod(fileName)}\" action=\"{formAction}\" class=\"form-horizontal\">");
124124

125-
if (fileName.Equals("Login", StringComparison.OrdinalIgnoreCase))
125+
if (fileName.Equals("Login", StringComparison.OrdinalIgnoreCase)
126+
|| fileName.Equals("Register", StringComparison.OrdinalIgnoreCase))
126127
{
127128
builder.AppendLine(" @if (!string.IsNullOrWhiteSpace(ReturnUrl))");
128129
builder.AppendLine(" {");
129-
builder.AppendLine(" <input type=\"hidden\" name=\"returnUrl\" value=\"@ReturnUrl\" />");
130+
builder.AppendLine(" <input type=\"hidden\" name=\"ReturnUrl\" value=\"@ReturnUrl\" />");
130131
builder.AppendLine(" }");
131132
}
132133

@@ -327,15 +328,11 @@ private static string DefaultButtonText(string fileName) =>
327328
: "Submit";
328329

329330
private static string GetFormAction(string fileName) =>
330-
fileName.Equals("Login", StringComparison.OrdinalIgnoreCase) ? "/Account/PerformLogin"
331-
: fileName.Equals("Register", StringComparison.OrdinalIgnoreCase) ? "/Account/PerformRegister"
331+
fileName.Equals("Login", StringComparison.OrdinalIgnoreCase) ? "/Account/LoginHandler"
332+
: fileName.Equals("Register", StringComparison.OrdinalIgnoreCase) ? "/Account/RegisterHandler"
332333
: $"/Account/{fileName}Handler";
333334

334-
private static string GetFormMethod(string fileName) =>
335-
fileName.Equals("Login", StringComparison.OrdinalIgnoreCase)
336-
|| fileName.Equals("Register", StringComparison.OrdinalIgnoreCase)
337-
? "get"
338-
: "post";
335+
private static string GetFormMethod(string fileName) => "post";
339336

340337
private static string ToTitle(string value)
341338
{
@@ -371,6 +368,10 @@ private static string EnsureAccountQueryParameters(string markup, string fileNam
371368
AddParameterIfMissing(markup, parameterLines, "Registered", "registered", "int?");
372369
AddParameterIfMissing(markup, parameterLines, "ReturnUrl", "returnUrl", "string?");
373370
}
371+
else if (fileName.Equals("Register", StringComparison.OrdinalIgnoreCase))
372+
{
373+
AddParameterIfMissing(markup, parameterLines, "ReturnUrl", "returnUrl", "string?");
374+
}
374375

375376
if (parameterLines.Count == 0)
376377
{

src/BlazorWebFormsComponents.Cli/Transforms/Markup/ValidatorGenericTypeTransform.cs

Lines changed: 129 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,28 +5,149 @@ namespace BlazorWebFormsComponents.Cli.Transforms.Markup;
55

66
/// <summary>
77
/// Adds explicit generic type arguments to validator components whose BWFC API
8-
/// requires them and defaults migrated form validators to string when the input
9-
/// type cannot be inferred mechanically.
8+
/// requires them. RequiredFieldValidator defaults to the validated control type
9+
/// when it can be inferred and falls back to object only when no type hint exists.
1010
/// </summary>
1111
public class ValidatorGenericTypeTransform : IMarkupTransform
1212
{
1313
public string Name => "ValidatorGenericType";
1414
public int Order => 615;
1515

16-
private static readonly (Regex Pattern, string TypeAttribute)[] ValidatorPatterns =
16+
private static readonly Regex AttributeRegex = new(
17+
@"\b(?<name>[A-Za-z_:][\w:.-]*)\s*=\s*""(?<value>[^""]*)""",
18+
RegexOptions.Compiled);
19+
20+
private static readonly Regex ControlTagRegex = new(
21+
@"<(?<tag>[A-Za-z][\w]*)\b(?<attributes>[^>]*)/?>",
22+
RegexOptions.Compiled | RegexOptions.IgnoreCase);
23+
24+
private static readonly Regex RequiredFieldValidatorRegex = new(
25+
@"<RequiredFieldValidator\b(?<attributes>[^>]*?)(?<selfClosing>\s*/?)>",
26+
RegexOptions.Compiled | RegexOptions.IgnoreCase);
27+
28+
private static readonly (Regex Pattern, string TypeAttribute, string DefaultType)[] SimpleValidatorPatterns =
1729
[
18-
(new Regex(@"<RequiredFieldValidator\b(?!(?:(?!>).)*\bType=)", RegexOptions.Compiled | RegexOptions.IgnoreCase), "Type"),
19-
(new Regex(@"<CompareValidator\b(?!(?:(?!>).)*\bInputType=)", RegexOptions.Compiled | RegexOptions.IgnoreCase), "InputType"),
20-
(new Regex(@"<RangeValidator\b(?!(?:(?!>).)*\bInputType=)", RegexOptions.Compiled | RegexOptions.IgnoreCase), "InputType")
30+
(new Regex(@"<CompareValidator\b(?<attributes>[^>]*?)(?<selfClosing>\s*/?)>", RegexOptions.Compiled | RegexOptions.IgnoreCase), "InputType", "string"),
31+
(new Regex(@"<RangeValidator\b(?<attributes>[^>]*?)(?<selfClosing>\s*/?)>", RegexOptions.Compiled | RegexOptions.IgnoreCase), "InputType", "string")
2132
];
2233

2334
public string Apply(string content, FileMetadata metadata)
2435
{
25-
foreach (var (pattern, typeAttribute) in ValidatorPatterns)
36+
var controlTypes = BuildControlTypeLookup(content);
37+
38+
content = RequiredFieldValidatorRegex.Replace(content, match =>
39+
{
40+
var attributes = match.Groups["attributes"].Value;
41+
if (HasAttribute(attributes, "Type"))
42+
{
43+
return match.Value;
44+
}
45+
46+
var validatorAttributes = ParseAttributes(attributes);
47+
var validatedControl = validatorAttributes.GetValueOrDefault("ControlToValidate")
48+
?? validatorAttributes.GetValueOrDefault("ControlRef");
49+
var inferredType = !string.IsNullOrWhiteSpace(validatedControl) && controlTypes.TryGetValue(validatedControl, out var controlType)
50+
? controlType
51+
: "object";
52+
53+
return InsertAttribute(match.Value, "Type", inferredType);
54+
});
55+
56+
foreach (var (pattern, typeAttribute, defaultType) in SimpleValidatorPatterns)
2657
{
27-
content = pattern.Replace(content, match => $"{match.Value} {typeAttribute}=\"string\"");
58+
content = pattern.Replace(content, match =>
59+
{
60+
if (HasAttribute(match.Groups["attributes"].Value, typeAttribute))
61+
{
62+
return match.Value;
63+
}
64+
65+
return InsertAttribute(match.Value, typeAttribute, defaultType);
66+
});
2867
}
2968

3069
return content;
3170
}
71+
72+
private static Dictionary<string, string> BuildControlTypeLookup(string content)
73+
{
74+
var controlTypes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
75+
76+
foreach (Match match in ControlTagRegex.Matches(content))
77+
{
78+
var tagName = match.Groups["tag"].Value;
79+
if (tagName.Equals("RequiredFieldValidator", StringComparison.OrdinalIgnoreCase)
80+
|| tagName.Equals("CompareValidator", StringComparison.OrdinalIgnoreCase)
81+
|| tagName.Equals("RangeValidator", StringComparison.OrdinalIgnoreCase))
82+
{
83+
continue;
84+
}
85+
86+
var attributes = ParseAttributes(match.Groups["attributes"].Value);
87+
var controlId = attributes.GetValueOrDefault("ID") ?? attributes.GetValueOrDefault("id");
88+
var inferredType = InferControlType(tagName, attributes);
89+
if (string.IsNullOrWhiteSpace(controlId) || string.IsNullOrWhiteSpace(inferredType))
90+
{
91+
continue;
92+
}
93+
94+
controlTypes[controlId] = inferredType;
95+
}
96+
97+
return controlTypes;
98+
}
99+
100+
private static string? InferControlType(string tagName, IReadOnlyDictionary<string, string> attributes)
101+
{
102+
if (tagName.Equals("TextBox", StringComparison.OrdinalIgnoreCase))
103+
{
104+
return "string";
105+
}
106+
107+
if (attributes.TryGetValue("Type", out var explicitType) && !string.IsNullOrWhiteSpace(explicitType))
108+
{
109+
return explicitType;
110+
}
111+
112+
if (attributes.TryGetValue("ValueType", out var valueType) && !string.IsNullOrWhiteSpace(valueType))
113+
{
114+
return valueType;
115+
}
116+
117+
if (attributes.TryGetValue("TValue", out var tValue) && !string.IsNullOrWhiteSpace(tValue))
118+
{
119+
return tValue;
120+
}
121+
122+
return null;
123+
}
124+
125+
private static bool HasAttribute(string attributes, string attributeName) =>
126+
AttributeRegex.Matches(attributes)
127+
.Any(match => string.Equals(match.Groups["name"].Value, attributeName, StringComparison.OrdinalIgnoreCase));
128+
129+
private static Dictionary<string, string> ParseAttributes(string attributes)
130+
{
131+
var parsed = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
132+
foreach (Match match in AttributeRegex.Matches(attributes))
133+
{
134+
parsed[match.Groups["name"].Value] = match.Groups["value"].Value;
135+
}
136+
137+
return parsed;
138+
}
139+
140+
private static string InsertAttribute(string tag, string attributeName, string attributeValue)
141+
{
142+
var firstSpaceIndex = tag.IndexOf(' ');
143+
if (firstSpaceIndex < 0)
144+
{
145+
var closeIndex = tag.IndexOf('>');
146+
return closeIndex < 0
147+
? tag
148+
: tag.Insert(closeIndex, $" {attributeName}=\"{attributeValue}\"");
149+
}
150+
151+
return tag.Insert(firstSpaceIndex, $" {attributeName}=\"{attributeValue}\"");
152+
}
32153
}

0 commit comments

Comments
 (0)