feat(02-04): signup templates (full page + HTMX fragment) with render tests

- Create auth_form_errors.templ: FieldError and GeneralError primitives
- Create auth_signup.templ: SignupPage (full) and SignupFormFragment (HTMX swap target)
- Define SignupForm and SignupErrors types in templates/auth_forms.go
- Add three smoke tests: renders form, renders errors, does not echo password
This commit is contained in:
Arthur Belleville 2026-05-14 22:14:28 +02:00
parent 38596ac41e
commit 73935ed11c
No known key found for this signature in database
4 changed files with 173 additions and 0 deletions

View file

@ -0,0 +1,19 @@
package templates
// FieldError renders a small red error message beneath a form field.
// Renders nothing when msg is empty so callers can unconditionally include it.
templ FieldError(msg string) {
if msg != "" {
<p class="mt-1 text-sm text-red-700">{ msg }</p>
}
}
// GeneralError renders a full-width error banner above the form.
// Renders nothing when msg is empty.
templ GeneralError(msg string) {
if msg != "" {
<div class="mb-4 rounded border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
{ msg }
</div>
}
}

View file

@ -0,0 +1,17 @@
package templates
// SignupForm carries the submitted field values back to the template so
// the email field can be repopulated on validation failure.
// Password is intentionally never echoed back to the client (T-2-01, D-25).
type SignupForm struct {
Email string
Password string // held here only for length validation; never passed to templates
}
// SignupErrors holds per-field and general error messages for the signup form.
// A field with an empty string means "no error for this field".
type SignupErrors struct {
Email string
Password string
General string
}

View file

@ -0,0 +1,72 @@
package templates
import "backend/internal/web/ui"
// SignupPage renders the full /signup page wrapped in the base Layout.
// It delegates the form section to SignupFormFragment so HTMX can swap just the
// form on validation errors without re-rendering the surrounding shell.
templ SignupPage(form SignupForm, errs SignupErrors) {
@Layout("Sign up") {
<div class="flex min-h-[60vh] items-start justify-center pt-16">
@ui.Card(nil) {
<div class="w-full max-w-sm px-6 py-8">
<h1 class="mb-6 text-2xl font-semibold">Create your account</h1>
@SignupFormFragment(form, errs)
</div>
}
</div>
}
}
// SignupFormFragment is the bare form used for HTMX swaps.
// hx-post targets this component itself so the form can be replaced inline
// on validation failure (D-19, D-25).
// The outer id="signup-form" must match the hx-target on this element.
templ SignupFormFragment(form SignupForm, errs SignupErrors) {
<form
id="signup-form"
method="POST"
action="/signup"
hx-post="/signup"
hx-target="#signup-form"
hx-swap="outerHTML"
class="space-y-5"
>
<!-- CSRF field added in Plan 07 -->
@GeneralError(errs.General)
<div>
<label for="email" class="block text-sm font-medium text-slate-700">Email address</label>
<input
id="email"
type="email"
name="email"
value={ form.Email }
required
autocomplete="email"
class="mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-sm placeholder-slate-400 focus:border-slate-500 focus:outline-none"
placeholder="you@example.com"
/>
@FieldError(errs.Email)
</div>
<div>
<label for="password" class="block text-sm font-medium text-slate-700">Password</label>
<input
id="password"
type="password"
name="password"
required
autocomplete="new-password"
class="mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-sm placeholder-slate-400 focus:border-slate-500 focus:outline-none"
placeholder="12 characters minimum"
/>
@FieldError(errs.Password)
</div>
@ui.Button(ui.ButtonProps{
Label: "Create account",
Variant: ui.ButtonVariantDefault,
Tone: ui.ButtonToneSolid,
Size: ui.SizeMD,
Type: "submit",
})
</form>
}

View file

@ -0,0 +1,65 @@
package templates
import (
"bytes"
"context"
"strings"
"testing"
)
// TestSignupPage_RendersForm verifies the full SignupPage output contains the
// expected form attributes and that email value round-trips correctly.
func TestSignupPage_RendersForm(t *testing.T) {
var buf bytes.Buffer
err := SignupPage(SignupForm{Email: "x@y.z"}, SignupErrors{}).Render(context.Background(), &buf)
if err != nil {
t.Fatalf("SignupPage.Render: %v", err)
}
body := buf.String()
for _, want := range []string{
`name="email"`,
`name="password"`,
`action="/signup"`,
`hx-post="/signup"`,
`value="x@y.z"`,
} {
if !strings.Contains(body, want) {
t.Errorf("SignupPage body missing %q", want)
}
}
}
// TestSignupFormFragment_RendersErrors verifies that SignupFormFragment renders
// field-specific error messages and does NOT include a full <html> tag (it is
// a fragment, not a complete page).
func TestSignupFormFragment_RendersErrors(t *testing.T) {
var buf bytes.Buffer
errs := SignupErrors{Password: "Password must be 12-128 characters"}
err := SignupFormFragment(SignupForm{}, errs).Render(context.Background(), &buf)
if err != nil {
t.Fatalf("SignupFormFragment.Render: %v", err)
}
body := buf.String()
if !strings.Contains(body, "Password must be 12-128 characters") {
t.Errorf("fragment missing error message; body: %s", body)
}
if strings.Contains(body, "<html") {
t.Errorf("fragment must not contain <html> tag; got full page")
}
}
// TestSignupPage_DoesNotEchoPassword verifies that the password value is never
// reflected back into any rendered HTML — even when form.Password is set
// (security requirement T-2-01, D-25).
func TestSignupPage_DoesNotEchoPassword(t *testing.T) {
var buf bytes.Buffer
err := SignupPage(SignupForm{Email: "a@b.com", Password: "hunter2hunter2"}, SignupErrors{}).Render(context.Background(), &buf)
if err != nil {
t.Fatalf("SignupPage.Render: %v", err)
}
if strings.Contains(buf.String(), "hunter2") {
t.Errorf("SignupPage must not echo back the password value")
}
}