generated from coulomb/repo-seed
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>
273 lines
8.5 KiB
Markdown
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`.
|