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