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>
8.5 KiB
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.
-- 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
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:
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.
[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/attributesuncheckedHsx— 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:
-- 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:
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:
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
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
-- 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
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
-- 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.