Files
inter-hub/docs/ihp-controllers-views-forms.md
tegwick 8b6ce5bbc8 docs: add specification, reference docs, workplan, and agent guidance
Adds all Phase 0 content that was created but never committed:
- CLAUDE.md and SCOPE.md — agent and developer orientation
- specs/TailwindForInteractionHubs_v0.2.md — IHF Tailwind coding guide
- docs/ — five IHP v1.5 reference guides (overview, data, controllers, realtime, ihf-mapping)
- workplans/IHUB-WP-0001 — Phase 1 implementation plan (12 tasks, state-hub synced)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 02:07:13 +01:00

273 lines
8.5 KiB
Markdown

# IHP: Controllers, Views, Forms, and Authentication
---
## Controllers and Actions
Controllers live in `Web/Controller/`. Each action is a constructor in `Web/Types.hs` and a case in the controller's `action` handler.
```haskell
-- Web/Controller/Widgets.hs
instance Controller WidgetsController where
action WidgetsAction = do
widgets <- query @Widget |> fetch
render IndexView { .. }
action ShowWidgetAction { widgetId } = do
widget <- fetch widgetId
events <- query @InteractionEvent
|> filterWhere (#widgetId, widgetId)
|> orderByDesc #occurredAt
|> limit 20
|> fetch
render ShowView { .. }
action CreateWidgetAction = do
let widget = newRecord @Widget
widget
|> fill @'["name", "widgetType", "hubId"]
|> validateField #name nonEmpty
|> ifValid \case
Left widget -> render NewView { widget }
Right widget -> do
widget <- createRecord widget
setSuccessMessage "Widget created"
redirectTo WidgetsAction
```
### Key Patterns
| Pattern | Description |
|---------|-------------|
| `fill @'["fieldA", "fieldB"]` | Type-safe HTTP param binding; compile error if field doesn't exist |
| `validateField #name nonEmpty` | Chains a validator onto the record |
| `ifValid \case Left r -> ... Right r -> ...` | Branches on validation success/failure |
| `createRecord r` | INSERT; returns the persisted record |
| `updateRecord r` | UPDATE |
| `deleteRecord r` | DELETE |
| `redirectTo SomeAction { ... }` | Type-safe redirect |
| `render SomeView { .. }` | Passes in-scope bindings to the view (record wildcard) |
| `respondJson value` | JSON response (no view) |
| `respondHtml someHtml` | Partial HTML response (useful with htmx) |
### Before Filters
```haskell
instance Controller WidgetsController where
beforeAction = do
ensureIsUser -- redirects to login if not authenticated
-- custom per-controller guards here
action ...
```
### ControllerContext
An implicit key-value map threaded through all actions and views. Stores current user, request, flash messages, layout. Access via `fromContext @SomeType` or framework helpers like `currentUser`.
---
## Views and HSX
### The View Typeclass
Views are data types with a `View` instance:
```haskell
data ShowView = ShowView
{ widget :: !Widget
, events :: ![InteractionEvent]
}
instance View ShowView where
html ShowView { .. } = [hsx|
<div class="widget-detail">
<h1>{widget.name}</h1>
<div class="events">
{forEach events renderEvent}
</div>
</div>
|]
```
Views can also implement a `json` method for content-negotiated API responses.
### HSX Syntax
HSX is a quasi-quoter (`[hsx| ... |]`) that compiles to BlazeHtml. Checked at compile time.
```haskell
[hsx|
-- Interpolation (auto-escaped, XSS-safe)
<span>{widget.name}</span>
-- Raw HTML (opt-out of escaping)
<div>{preEscapedToHtml trustedHtml}</div>
-- Conditionals
{if widget.status == "active"
then [hsx|<span class="badge active">Active</span>|]
else [hsx|<span class="badge inactive">Inactive</span>|]}
-- Loops
<ul>{forEach events renderEvent}</ul>
-- Boolean attributes: True → present, False → omitted
<input disabled={isReadOnly} />
-- Maybe attributes: Just → present, Nothing → omitted
<a target={maybeTarget}>Link</a>
-- Multiple root elements are OK (unlike JSX)
<dt>Name</dt>
<dd>{widget.name}</dd>
|]
```
**`<script>` and `<style>` blocks:** curly braces inside are **literal** (not interpolated). Pass values via `data-` attributes instead.
**Tag variants:**
- `hsx` — strict whitelist of known HTML tags/attributes
- `uncheckedHsx` — skips tag/attribute validation (for web components)
- `customHsx` — extends the whitelist
### Layouts
Layouts have type `Layout = Html -> Html`. They wrap the view's `html` output:
```haskell
-- Web/View/Layout.hs
defaultLayout :: Layout
defaultLayout inner = [hsx|
<!DOCTYPE html>
<html>
<head>
<title>IHF</title>
{autoRefreshMeta}
<script src="/vendor/morphdom.js"></script>
<script src="/vendor/ihp-auto-refresh.js"></script>
</head>
<body>
<nav>...</nav>
<main>{inner}</main>
</body>
</html>
|]
```
Set per-controller in `beforeAction` or globally in `FrontController.initContext`:
```haskell
setLayout defaultLayout
```
Data shared between layout and views (e.g., current user for the nav) is passed via `putContext`/`fromFrozenContext`.
---
## Forms and Validation
### Building Forms
`formFor` binds a record to a form, auto-generating method and action:
```haskell
renderForm :: Widget -> Html
renderForm widget = formFor widget [hsx|
{textField #name}
{selectField #widgetType widgetTypeOptions}
{selectField #hubId hubOptions}
{textareaField #description}
{submitButton}
|]
```
IHP detects whether the record is new (POST to `/CreateWidget`) or persisted (POST to `/UpdateWidget`) automatically.
**Available field helpers:** `textField`, `emailField`, `passwordField`, `urlField`, `numberField`, `colorField`, `dateField`, `dateTimeField`, `textareaField`, `selectField`, `radioField`, `checkboxField`, `fileField`, `hiddenField`.
**Field customisation options:** `helpText`, `fieldLabel`, `placeholder`, `required`, `disabled`, `autofocus`, `fieldClass`, `labelClass`, `additionalAttributes`, `disableLabel`, `disableGroup`, `disableValidationResult`.
**Custom forms:** `formForWithOptions` — override `formAction`, `formClass`, form ID, HTTP method, disable AJAX submission.
**AJAX submission:** forms submit via AJAX + TurboLinks by default (no full page reload). Opt out with `disableJavascriptSubmission = True`.
### Validation
```haskell
annotation
|> validateField #body nonEmpty
|> validateField #category (`elem` validCategories)
|> ifValid \case
Left invalid -> render NewView { annotation = invalid }
Right valid -> do
createRecord valid
redirectTo WidgetAnnotationsAction { widgetId = valid.widgetId }
```
**Built-in validators:** `nonEmpty`, `isEmail`, `isPhoneNumber`, `isInRange (min, max)`.
**Custom validators:** return `Success` or `Failure "message"`. For DB-aware validators, use `validateFieldIO`.
**How errors attach:** `validateField` stores failures in `record.meta.annotations` (a `[(fieldName, errorMessage)]` list). When `formFor` renders a field with a failure, it auto-adds `is-invalid` CSS class and renders the error message.
**Manual attachment:** `|> attachFailure #field "Custom message"`.
---
## Authentication
IHP provides built-in session-based authentication using salted password hashing (`pwstore-fast`). Accounts are locked for 1 hour after 10 failed attempts.
### Database Requirements
The `users` table must include: `id`, `email`, `password_hash`, `locked_at`, `failed_login_attempts`.
### Setup
```haskell
-- Web/Types.hs
import IHP.LoginSupport.Types
type CurrentUserRecord = User
instance HasNewSessionUrl User where
newSessionUrl _ = "/NewSession"
-- Web/FrontController.hs
instance InitControllerContext WebApplication where
initContext = do
initAuthentication @User
```
Mount `parseRoute @SessionsController` in routes. The framework handles `NewSessionAction`, `CreateSessionAction`, `DeleteSessionAction`.
### Accessing the Current User
```haskell
currentUser -- User; redirects to login if absent
currentUserOrNothing -- Maybe User
currentUserId -- convenience shortcut
```
### Hooks
`SessionsControllerConfig` instance supports `beforeLogin` callback for login history, account status checks, and external identity validation (relevant for IHF's multi-tenant actor attribution).
### Password Handling
```haskell
-- On registration:
user <- newRecord @User
|> fill @'["email", "passwordHash"]
|> set #passwordHash (hashPassword plaintext)
|> createRecord
-- On update (preserve hash when blank):
updatedUser <- user
|> fill @'["email"]
|> ifPasswordChanged (set #passwordHash . hashPassword)
|> updateRecord
```
### Actor Attribution in IHF
For IHF's interaction capture, the `actor_id` and `actor_type` on `InteractionEvent` and `Annotation` should be populated from `currentUserOrNothing` in controllers. For anonymous/low-trust actors, `actor_type = "anonymous"` with a session token as `actor_id`.