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:
parent
38596ac41e
commit
73935ed11c
4 changed files with 173 additions and 0 deletions
19
backend/templates/auth_form_errors.templ
Normal file
19
backend/templates/auth_form_errors.templ
Normal 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>
|
||||
}
|
||||
}
|
||||
17
backend/templates/auth_forms.go
Normal file
17
backend/templates/auth_forms.go
Normal 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
|
||||
}
|
||||
72
backend/templates/auth_signup.templ
Normal file
72
backend/templates/auth_signup.templ
Normal 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>
|
||||
}
|
||||
65
backend/templates/auth_signup_test.go
Normal file
65
backend/templates/auth_signup_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue