Registration, verification, sessions, and password recovery can dominate a small application. I built UserHarbor to handle account operations through a small, framework-independent Python API.
Let's combine it with FastHTML and HTMX in a Hypermedia-Driven Application (HDA). The server owns application state and returns HTML; links and forms describe actions, and responses update cards or navigate between pages.
We'll build registration, email verification, login/logout, password recovery, a paginated directory, profiles, password changes, and deletion. Basecoat provides UI components.
The FastHTML Starter template includes the finished app; this walkthrough uses a smaller dashboard shell.
Why UserHarbor fits this stack
Each layer has a clear responsibility:
- UserHarbor validates accounts, hashes passwords and tokens, manages sessions, and performs account operations.
- FastHTML parses requests and renders Python components as HTML.
- HTMX submits forms and applies HTML responses in the browser.
- Your application chooses the layout, routes, cookie policy, and access rules.
UserHarbor uses UserStore for storage and EmailSender for delivery; we'll supply SQLAlchemy and SMTP adapters. Its API also works with other frameworks or a CLI. Role and permission checks are available when your application needs them.
Routes call harbor.register(...), harbor.login(...), or harbor.get_current_user(...), then choose an HTML response. No separate client app or JSON API is needed.
Set up the project
Use Python 3.14 or later, matching the starter, and uv:
uv init --python 3.14 fasthtml-app
cd fasthtml-app
uv add python-fasthtml "userharbor[sqlalchemy,smtp]" \
fasthtml-basecoatui "fasthtml-tailwindcss[dev]" fasthtml-lucide
mkdir -p app/core app/components/auth app/layouts app/pages app/routes \
app/email_templates app/static
touch app/__init__.py app/core/__init__.py app/components/__init__.py \
app/components/auth/__init__.py app/layouts/__init__.py \
app/pages/__init__.py app/routes/__init__.py
The wrappers supply components, CSS, and icons. Separate configuration, components, layouts, pages, and routes as below.
Save blocks under their indicated paths; finish all files before running.
Give UserHarbor storage and email delivery
Create a root .env with a random secret and SMTP credentials; keep it out of version control. APP_URL sets the email links' base URL.
.env
SECRET_KEY=replace-with-a-long-random-secret
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USERNAME=your-smtp-username
SMTP_PASSWORD=your-smtp-password
SMTP_FROM_EMAIL=*Emails are not allowed*
APP_URL=http://localhost:8000
app/core/settings.py
import os
SECRET_KEY = os.environ["SECRET_KEY"]
SMTP_HOST = os.environ["SMTP_HOST"]
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
SMTP_USERNAME = os.getenv("SMTP_USERNAME")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD")
SMTP_FROM_EMAIL = os.environ["SMTP_FROM_EMAIL"]
APP_URL = os.getenv("APP_URL", "http://localhost:8000")
Configure one shared instance. SQLite uses the working directory (app/users.db with our run command); create_all() initializes tables.
app/core/harbor.py
from pathlib import Path
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from userharbor import UserHarbor
from userharbor_smtp import SMTPEmailSender
from userharbor_sqlalchemy import SQLAlchemyUserStore
from .settings import (
APP_URL,
SECRET_KEY,
SMTP_FROM_EMAIL,
SMTP_HOST,
SMTP_PASSWORD,
SMTP_PORT,
SMTP_USERNAME,
)
engine = create_engine("sqlite:///users.db")
SessionLocal = sessionmaker(bind=engine)
store = SQLAlchemyUserStore(SessionLocal)
store.metadata.create_all(engine)
email_sender = SMTPEmailSender(
host=SMTP_HOST,
port=SMTP_PORT,
username=SMTP_USERNAME,
password=SMTP_PASSWORD,
from_email=SMTP_FROM_EMAIL,
template_dir=Path(__file__).parent.parent / "email_templates",
template_context={"app_url": APP_URL},
)
harbor = UserHarbor(secret_key=SECRET_KEY, store=store, email_sender=email_sender)
Override verification and recovery templates; other notifications use the SMTP defaults.
app/email_templates/verification.html
<p>Hello {{ username }},</p>
<p>
<a href="{{ app_url.rstrip('/') }}/auth/verify_email?token={{ token | urlencode }}">Activate your account</a>
</p>
app/email_templates/password_reset.html
<p>Hello {{ username }},</p>
<p>
<a href="{{ app_url.rstrip('/') }}/auth/reset_password?token={{ token | urlencode }}">Reset your password</a>
</p>
Routes return HTML, responses, or HTML with headers. UserLike exposes public fields, excluding the password hash.
app/core/types.py
from fasthtml.common import FT, HttpHeader, Response
from userharbor.interfaces import UserLike
type HTMLResult = FT | Response | tuple[FT | HttpHeader, ...]
User = UserLike
Build an HTML shell that HTMX can update
BaseLayout loads HTMX, Basecoat, and the nova CSS theme. Tailwind watches classes during development. Boosted links retain URLs and history while normally replacing the body contents.
Forms inherit the spinner; feedback goes to #alerts.
app/layouts/base.py
from fasthtml.common import FT, Body, Div, Head, Html, Meta, Script, Title
from fasthtml_basecoat import BasecoatInput, BasecoatJS, Spinner
from fasthtml_tailwindcss import TailwindHead
def BaseLayout(*c, title: str, **kwargs) -> FT:
return Html(
Head(
Title(title),
Meta(charset="utf-8"),
Meta(name="viewport", content="width=device-width, initial-scale=1"),
Script(src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.11/dist/htmx.js"),
BasecoatJS(),
TailwindHead(input=BasecoatInput("nova"), watch=True),
),
Body(
Spinner(
id="global-spinner",
cls="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 pointer-events-none size-10 htmx-indicator",
),
Div(id="alerts", cls="fixed top-4 inset-x-0 z-50 flex flex-col items-center gap-4"),
*c,
hx_boost="true",
hx_indicator="#global-spinner",
),
**kwargs,
)
Add the theme switcher and dashboard header.
app/layouts/auth.py
from fasthtml.common import FT, Div
from fasthtml_basecoat import ThemeSwitcher
from .base import BaseLayout
def AuthLayout(*c, title: str, **kwargs) -> FT:
return BaseLayout(
Div(ThemeSwitcher(), cls="fixed top-4 right-4 z-50 flex items-center gap-4"),
*c,
title=title,
**kwargs,
)
app/layouts/dashboard.py
from urllib.parse import quote
from core.types import User
from fasthtml.common import FT, A, Div, Header, Main, Span
from fasthtml_basecoat import Button, ThemeSwitcher
from .base import BaseLayout
def DashboardLayout(*c, title: str, user: User) -> FT:
return BaseLayout(
Header(
A("Users", href="/"),
Span(title),
A(user.username, href=f"/users/{quote(user.username, safe='')}"),
Button(
"Logout",
type="button",
variant="ghost",
hx_post="/auth/logout",
hx_swap="none",
hx_disabled_elt="this",
),
ThemeSwitcher(variant="ghost"),
cls="flex flex-wrap items-center gap-4 border-b px-4 py-4",
),
Main(Div(*c, cls="mx-auto max-w-6xl space-y-6 px-4 py-8"), id="dashboard-content"),
title=title,
)
Feedback is HTML too
hx_swap_oob="beforeend:#alerts" appends feedback even with hx_swap="none", preserving form input. UserHarbor raises domain errors; our component displays them.
app/components/alert.py
from typing import Literal
from fasthtml.common import FT, H2, Div, Footer, Section
from fasthtml_basecoat import Button
from fasthtml_lucide import Icon
def Alert(heading: str, message: str, variant: Literal["info", "error"]) -> FT:
return Div(
Div(
Icon("info"),
H2(heading),
Section(message),
Footer(
Button(
Icon("circle-x"),
aria_label="Close",
data_tooltip="Close",
data_side="bottom",
data_variant="ghost",
onclick="this.closest('.alert').remove()",
)
),
cls="alert w-full max-w-md",
data_variant="destructive" if variant == "error" else None,
hx_on__load="setTimeout(() => { this.remove() }, 10000)",
),
hx_swap_oob="beforeend:#alerts",
)
Input names match FastHTML route parameters. hx_post submits; hx_swap="none" preserves the form. hx_disabled_elt disables only that form's controls during requests.
The recovery link selects a hidden Basecoat tab.
app/components/auth/login.py
from fasthtml.common import FT, H2, A, Div, Form, Header, P, Section
from fasthtml_basecoat import Button, Card, Input, Label
def LoginCard(**attrs) -> FT:
return Card(
Header(H2("Login to your account"), P("Enter your credentials to access your account.")),
Section(_LoginForm()),
**attrs,
)
def _LoginForm() -> FT:
return Form(
Div(
Label("Username", fr="username"),
Input(type="text", id="username", name="username", required=True),
cls="grid gap-2",
),
Div(
Div(
Label("Password", fr="password"),
A(
"Forgot your password?",
onclick="event.preventDefault(); this.closest('.tabs').select(document.getElementById('auth-forgot-password-tab'));",
cls="ms-auto inline-block text-sm underline-offset-4 hover:underline",
),
cls="flex items-center",
),
Input(type="password", id="password", name="password", required=True),
cls="grid gap-2",
),
Button("Login", cls="w-full"),
cls="grid gap-6",
id="login-form",
hx_post="login",
hx_disabled_elt="#login-form input, #login-form button",
hx_swap="none",
)
Registration success replaces its card using HX-Retarget and HX-Reswap. Preserve the panel ID and tab attributes.
app/components/auth/register.py
from core.types import HTMLResult
from fasthtml import HttpHeader
from fasthtml.common import FT, H2, Div, Form, Header, P, Section
from fasthtml_basecoat import Button, Card, Input, Label
def RegisterCard(**attrs) -> FT:
return Card(
Header(H2("Create an account"), P("Enter your details to create your account.")),
Section(_RegisterForm()),
**attrs,
)
def SuccessfulRegisterCard() -> HTMLResult:
return (
Card(
Header(
H2("Registration Successful"), P("Please check your email to verify your account.")
),
cls="w-full max-w-sm text-center",
id="auth-register-card",
role="tabpanel",
aria_labelledby="auth-register-tab",
),
HttpHeader("HX-Retarget", "#auth-register-card"),
HttpHeader("HX-Reswap", "outerHTML"),
)
def _RegisterForm() -> FT:
return Form(
Div(
Label("Username", fr="register-username"),
Input(
type="text",
id="register-username",
name="username",
autocomplete="username",
required=True,
),
cls="grid gap-2",
),
Div(
Label("Email", fr="register-email"),
Input(
type="email", id="register-email", name="email", autocomplete="email", required=True
),
cls="grid gap-2",
),
Div(
Label("Password", fr="register-password"),
Input(
type="password",
id="register-password",
name="password",
autocomplete="new-password",
required=True,
),
cls="grid gap-2",
),
Button("Create account", cls="w-full"),
cls="grid gap-6",
id="register-form",
hx_post="register",
hx_disabled_elt="#register-form input, #register-form button",
hx_swap="none",
)
Recovery returns a neutral confirmation for both known and unknown addresses.
app/components/auth/forgot_password.py
from core.types import HTMLResult
from fasthtml.common import FT, H2, Div, Form, Header, HttpHeader, P, Section
from fasthtml_basecoat import Button, Card, Input, Label
def ForgotPasswordCard(**attrs) -> FT:
return Card(
Header(
H2("Forgot your password?"),
P("Enter your email address to receive password reset instructions."),
),
Section(_ForgotPasswordForm()),
**attrs,
)
def SuccessfulForgotPasswordCard() -> HTMLResult:
return (
Card(
Header(
H2("Check your email"),
P(
"If an account exists for this email, you will receive password reset instructions."
),
),
cls="text-center",
id="auth-forgot-password-card",
role="tabpanel",
aria_labelledby="auth-forgot-password-tab",
),
HttpHeader("HX-Retarget", "#auth-forgot-password-card"),
HttpHeader("HX-Reswap", "outerHTML"),
)
def _ForgotPasswordForm() -> FT:
return Form(
Div(
Label("Email", fr="forgot-password-email"),
Input(
type="email",
id="forgot-password-email",
name="email",
autocomplete="email",
required=True,
),
cls="grid gap-2",
),
Button("Send reset instructions", cls="w-full"),
cls="grid gap-6",
id="forgot-password-form",
hx_post="forgot_password",
hx_disabled_elt="#forgot-password-form input, #forgot-password-form button",
hx_swap="none",
)
Reset carries the token in a hidden input and marks new passwords for password managers. The server compares both values.
app/components/auth/reset_password.py
from core.types import HTMLResult
from fasthtml.common import FT, H2, A, Div, Form, Header, HttpHeader, P, Section
from fasthtml_basecoat import Button, Card, Input, Label
def ResetPasswordCard(token: str, **attrs) -> FT:
return Card(
Header(H2("Reset your password"), P("Enter and confirm your new password.")),
Section(_ResetPasswordForm(token)),
**attrs,
)
def SuccessfulResetPasswordCard() -> HTMLResult:
return (
Card(
Header(
H2("Password Updated"), P("Your password has been updated. You can now log in.")
),
Section(A("Back to login", href="/auth/", cls="text-sm underline underline-offset-4")),
cls="w-full max-w-sm text-center",
id="reset-password-card",
),
HttpHeader("HX-Retarget", "#reset-password-card"),
HttpHeader("HX-Reswap", "outerHTML"),
)
def _ResetPasswordForm(token: str) -> FT:
return Form(
Input(type="hidden", name="token", value=token),
Div(
Label("New password", fr="new-password"),
Input(
type="password",
id="new-password",
name="new_password",
autocomplete="new-password",
required=True,
),
cls="grid gap-2",
),
Div(
Label("Confirm password", fr="confirm-password"),
Input(
type="password",
id="confirm-password",
name="confirm_password",
autocomplete="new-password",
required=True,
),
cls="grid gap-2",
),
Button("Reset password", cls="w-full"),
cls="grid gap-6",
id="reset-password-form",
hx_post="/auth/update_password",
hx_disabled_elt="#reset-password-form input, #reset-password-form button",
hx_swap="none",
)
Compose pages from cards
Compose the cards into tabs with matching aria_controls and panel IDs. Email links open separate reset and verification pages.
app/pages/auth.py
from components.auth.forgot_password import ForgotPasswordCard
from components.auth.login import LoginCard
from components.auth.register import RegisterCard
from fasthtml.common import FT, Button, Div, Nav
from fasthtml_basecoat import Tabs
from layouts.auth import AuthLayout
def AuthPage() -> FT:
return AuthLayout(
Div(
Tabs(
Nav(
Button(
"Login",
role="tab",
id="auth-login-tab",
aria_controls="auth-login-card",
aria_selected="true",
tabindex="0",
),
Button(
"Sign Up",
role="tab",
id="auth-register-tab",
aria_controls="auth-register-card",
aria_selected="false",
tabindex="-1",
),
Button(
"Reset password",
role="tab",
id="auth-forgot-password-tab",
aria_controls="auth-forgot-password-card",
aria_selected="false",
tabindex="-1",
hidden=True,
),
role="tablist",
aria_label="Account access",
cls="w-full",
),
LoginCard(role="tabpanel", id="auth-login-card", aria_labelledby="auth-login-tab"),
RegisterCard(
role="tabpanel",
id="auth-register-card",
aria_labelledby="auth-register-tab",
hidden=True,
),
ForgotPasswordCard(
role="tabpanel",
id="auth-forgot-password-card",
aria_labelledby="auth-forgot-password-tab",
hidden=True,
),
id="auth-tabs",
cls="w-full max-w-sm",
),
cls="min-h-dvh flex items-center justify-center p-4",
),
title="Account access",
)
app/pages/reset_password.py
from components.auth.reset_password import ResetPasswordCard
from fasthtml.common import FT, Div
from layouts.auth import AuthLayout
def ResetPasswordPage(token: str) -> FT:
return AuthLayout(
Div(
ResetPasswordCard(token, id="reset-password-card", cls="w-full max-w-sm"),
cls="min-h-dvh flex items-center justify-center p-4",
),
title="Reset your password",
)
app/pages/verify_email.py
from fasthtml.common import FT, H2, A, Div, Header, P, Section
from fasthtml_basecoat import Card
from layouts.auth import AuthLayout
def VerifyEmailPage(error: str | None = None) -> FT:
title = "Verification Failed" if error is not None else "Account Activated"
message = error if error is not None else "Your account has been activated."
return AuthLayout(
Div(
Card(
Header(H2(title), P(message)),
Section(
A("Back to login", href="/auth/", cls="text-sm underline underline-offset-4")
),
cls="w-full max-w-sm text-center",
),
cls="min-h-dvh flex items-center justify-center p-4",
),
title=title,
)
Mount this router at /auth, making /login available as /auth/login. Routes handle HTTP; UserHarbor handles accounts.
Registration validates input, hashes passwords, stores a token hash, and sends email. Verification activates the account. Login puts the raw session token in an HttpOnly cookie; storage keeps its hash.
Return HX-Redirect with HTTP 200: HTMX ignores these headers on 3xx responses. Use secure=True with production HTTPS. Logout invalidates the session and clears the cookie.
Recovery is neutral for unknown addresses. Reset checks the token and password, then invalidates all sessions. Token pages disable caching and referrer forwarding.
app/routes/auth.py
from components.alert import Alert
from components.auth.forgot_password import SuccessfulForgotPasswordCard
from components.auth.register import SuccessfulRegisterCard
from components.auth.reset_password import SuccessfulResetPasswordCard
from core.harbor import harbor
from core.types import HTMLResult
from fasthtml.common import FT, HttpHeader, Response, fast_app
from pages.auth import AuthPage
from pages.reset_password import ResetPasswordPage
from pages.verify_email import VerifyEmailPage
from userharbor.exceptions import UserHarborError
auth_app, rt = fast_app()
@rt(methods=["GET"])
def index() -> FT:
return AuthPage()
@rt(methods=["POST"])
def register(username: str, email: str, password: str) -> HTMLResult:
try:
harbor.register(username, email, password)
except UserHarborError as e:
return Alert("Registration Failed", str(e), variant="error")
return SuccessfulRegisterCard()
@rt(methods=["GET"])
def verify_email(token: str) -> HTMLResult:
try:
harbor.verify_email(token)
except UserHarborError as e:
page = VerifyEmailPage(error=str(e))
else:
page = VerifyEmailPage()
return (
page,
HttpHeader("Referrer-Policy", "no-referrer"),
HttpHeader("Cache-Control", "no-store"),
)
@rt(methods=["POST"])
def login(username: str, password: str) -> HTMLResult:
try:
session_token = harbor.login(username, password)
except UserHarborError as e:
return Alert("Login Failed", str(e), variant="error")
response = Response(headers={"HX-Redirect": "/"})
response.set_cookie(
"session_token",
session_token,
path="/",
httponly=True,
secure=False, # Set to True in production with HTTPS
samesite="lax",
)
return response
@rt(methods=["POST"])
def logout(request) -> HTMLResult:
try:
harbor.logout(request.cookies.get("session_token", ""))
except UserHarborError:
pass
response = Response(headers={"HX-Redirect": "/auth/"})
response.delete_cookie("session_token", path="/")
return response
@rt(methods=["POST"])
def forgot_password(email: str) -> HTMLResult:
try:
harbor.send_password_reset(email)
except UserHarborError as e:
return Alert("Password Reset Failed", str(e), variant="error")
return SuccessfulForgotPasswordCard()
@rt(methods=["GET"])
def reset_password(token: str) -> HTMLResult:
return (
ResetPasswordPage(token),
HttpHeader("Referrer-Policy", "no-referrer"),
HttpHeader("Cache-Control", "no-store"),
)
@rt(methods=["POST"])
def update_password(token: str, new_password: str, confirm_password: str) -> HTMLResult:
if new_password != confirm_password:
return Alert("Password Reset Failed", "Passwords do not match.", variant="error")
try:
harbor.reset_password(new_password=new_password, reset_token=token)
except UserHarborError as e:
return Alert("Password Reset Failed", str(e), variant="error")
return SuccessfulResetPasswordCard()
Add a user directory with real URLs
Render UserList.items; its total drives pagination. FastHTML escapes text, and profile URLs encode usernames.
app/components/user_table.py
from urllib.parse import quote
from fasthtml.common import FT, A, Tbody, Td, Th, Thead, Tr
from fasthtml_basecoat import Table
from userharbor.interfaces import UserList
def UserTable(users: UserList) -> FT:
return Table(
Thead(Tr(Th("Username"), Th("Email"), Th("Verified"))),
Tbody(
*[
Tr(
Td(
A(
u.username,
href=f"/users/{quote(u.username, safe='')}",
cls="hover:underline",
)
),
Td(u.email),
Td("Yes" if u.verified else "No"),
)
for u in users.items
]
),
)
Links such as ?page=2 inherit boosted navigation; current=True sets aria-current="page".
app/components/pagination.py
from fasthtml.common import FT
from fasthtml_basecoat import Pagination as BasePagination
from fasthtml_basecoat import PaginationLink
def Pagination(page: int, total_pages: int) -> FT | None:
return (
BasePagination(
PaginationLink("Previous", href=f"?page={page - 1}") if page > 1 else None,
*(
PaginationLink(str(number), href=f"?page={number}", current=number == page)
for number in range(1, total_pages + 1)
),
PaginationLink("Next", href=f"?page={page + 1}") if page < total_pages else None,
)
if total_pages > 1
else None
)
app/pages/users.py
from components.pagination import Pagination
from components.user_table import UserTable
from core.types import User
from fasthtml.common import FT
from layouts.dashboard import DashboardLayout
from userharbor.interfaces import UserList
def UsersPage(users: UserList, user: User, page: int, total_pages: int) -> FT:
return DashboardLayout(
UserTable(users), Pagination(page, total_pages), title="Users", user=user
)
Parse page, list with limit/offset, and clamp out-of-range pages. Empty results keep page one. The profile component follows next.
Username lookup ignores case, preserves stored casing, and returns None for missing users (HTTP 404 here). See User Manager for search and sorting.
app/routes/users.py
from core.harbor import harbor
from core.types import HTMLResult
from fasthtml.common import HTTPException, fast_app
from pages.user_details import UserDetailsPage
from pages.users import UsersPage
users_app, rt = fast_app()
@rt(methods=["GET"])
def index(request, page: int = 1) -> HTMLResult:
page_size = 10
page = max(1, page)
users = harbor.users.list(limit=page_size, offset=(page - 1) * page_size)
total_pages = max(1, (users.total + page_size - 1) // page_size)
if page > total_pages:
page = total_pages
users = harbor.users.list(limit=page_size, offset=(page - 1) * page_size)
return UsersPage(users, request.scope.get("user"), page, total_pages)
@rt("/users/{username}", methods=["GET"])
def details(request, username: str) -> HTMLResult:
profile = harbor.users.get_by_username(username)
if profile is None:
raise HTTPException(status_code=404, detail="User not found")
return UserDetailsPage(profile, request.scope["user"])
All signed-in users can read this demo's directory, including emails. User Manager does not enforce page permissions; choose your application's access rules.
Keep account actions tied to the signed-in user
Password changes require the old password and matching new values. Deletion requires the current password and confirmation, checked again on the server.
Keep the checkbox's label separately wrapped: Basecoat disables pointer events on labels immediately followed by disabled controls.
app/components/user_details.py
from fasthtml.common import FT, H2, Div, Form, Header, P, Section, Tbody, Td, Tr
from fasthtml_basecoat import Button, Card, Checkbox, Input, Label, Table
from userharbor.interfaces import UserLike
def UserDetailsCard(user: UserLike) -> FT:
return Card(
Header(H2("User details")),
Section(
Table(
Tbody(
Tr(Td("Username", cls="font-semibold"), Td(user.username)),
Tr(Td("Email", cls="font-semibold"), Td(user.email)),
Tr(Td("Verified", cls="font-semibold"), Td("Yes" if user.verified else "No")),
)
)
),
)
def ChangePasswordCard() -> FT:
return Card(
Header(
H2("Change password"), P("You will need to log in again after changing your password.")
),
Section(
Form(
Div(
Label("Current password", fr="old-password"),
Input(
type="password",
id="old-password",
name="old_password",
autocomplete="current-password",
required=True,
),
cls="grid gap-2",
),
Div(
Label("New password", fr="new-password"),
Input(
type="password",
id="new-password",
name="new_password",
autocomplete="new-password",
required=True,
),
cls="grid gap-2",
),
Div(
Label("Confirm password", fr="confirm-password"),
Input(
type="password",
id="confirm-password",
name="confirm_password",
autocomplete="new-password",
required=True,
),
cls="grid gap-2",
),
Button("Change password", type="submit"),
id="change-password-form",
hx_post="/account/change_password",
hx_disabled_elt="#change-password-form input, #change-password-form button",
hx_swap="none",
cls="grid gap-6",
)
),
)
def DeleteAccountCard() -> FT:
return Card(
Header(H2("Delete account"), P("Deleting your account is permanent.")),
Section(
Form(
Div(
Label("Current password", fr="delete-password"),
Input(
type="password",
id="delete-password",
name="password",
autocomplete="current-password",
required=True,
),
cls="grid gap-2",
),
Div(
Label(
Checkbox(
name="confirmed",
value="true",
required=True,
onchange="this.form.querySelector('button[type=submit]').disabled = !this.checked",
),
"I understand that my account will be permanently deleted.",
cls="flex items-center gap-2",
)
),
Button("Delete account", type="submit", variant="destructive", disabled=True),
id="delete-account-form",
hx_post="/account/delete",
hx_disabled_elt="#delete-account-form input, #delete-account-form button",
hx_swap="none",
cls="grid gap-6",
)
),
)
Show account forms only to the profile owner. Viewing another profile never changes the session's identity.
app/pages/user_details.py
from components.user_details import ChangePasswordCard, DeleteAccountCard, UserDetailsCard
from core.types import User
from fasthtml.common import FT
from layouts.dashboard import DashboardLayout
from userharbor.interfaces import UserLike
def UserDetailsPage(profile: UserLike, user: User) -> FT:
is_owner = profile.username == user.username
return DashboardLayout(
UserDetailsCard(profile),
(ChangePasswordCard(), DeleteAccountCard()) if is_owner else None,
title=profile.username,
user=user,
)
The /account handlers identify the account through the session cookie. UserHarbor checks that session and the password; hiding forms alone provides no authorization.
Changing a password invalidates all sessions. Both successful actions clear the cookie and redirect; errors return alerts.
app/routes/account.py
from components.alert import Alert
from core.harbor import harbor
from core.types import HTMLResult
from fasthtml.common import Response, fast_app
from userharbor.exceptions import UserHarborError
account_app, rt = fast_app()
@rt(methods=["POST"])
def change_password(
request, old_password: str, new_password: str, confirm_password: str
) -> HTMLResult:
if new_password != confirm_password:
return Alert("Password Change Failed", "Passwords do not match.", variant="error")
try:
harbor.change_password(
old_password=old_password,
new_password=new_password,
session_token=request.cookies.get("session_token", ""),
)
except UserHarborError as e:
return Alert("Password Change Failed", str(e), variant="error")
response = Response(headers={"HX-Redirect": "/auth/"})
response.delete_cookie("session_token", path="/")
return response
@rt(methods=["POST"])
def delete(request, password: str, confirmed: bool = False) -> HTMLResult:
if not confirmed:
return Alert("Account Deletion Failed", "Confirm account deletion first.", variant="error")
try:
harbor.delete_account(
password=password, session_token=request.cookies.get("session_token", "")
)
except UserHarborError as e:
return Alert("Account Deletion Failed", str(e), variant="error")
response = Response(headers={"HX-Redirect": "/auth/"})
response.delete_cookie("session_token", path="/")
return response
Protect every mounted application
Root middleware resolves request.scope["user"] before dispatch, protecting every mounted child.
Auth routes and assets stay public; signed-in users skip login. Verification, recovery, and logout remain available. Normal redirects use 303; HTMX gets HTTP 200 with HX-Redirect, avoiding a login page swapped into a form.
app/main.py
from contextlib import suppress
from core.harbor import harbor
from fasthtml.common import FastHTML, Response, StaticImmutable
from routes.account import account_app
from routes.auth import auth_app
from routes.users import users_app
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import RedirectResponse
from starlette.routing import Mount
from userharbor.exceptions import UserHarborError
def redirect(request, url: str):
if request.headers.get("HX-Request") == "true":
return Response(headers={"HX-Redirect": url})
return RedirectResponse(url, status_code=303)
async def check_session(request, call_next):
path = request.url.path
user = None
if token := request.cookies.get("session_token"):
with suppress(UserHarborError):
user = harbor.get_current_user(token)
request.scope["user"] = user
public = path == "/auth" or path.startswith(("/auth/", "/static/"))
if user and path in ("/auth", "/auth/", "/auth/login", "/auth/register"):
return redirect(request, "/")
if not user and not public:
response = redirect(request, "/auth/")
response.delete_cookie("session_token", path="/")
return response
return await call_next(request)
app = FastHTML(
middleware=[Middleware(BaseHTTPMiddleware, dispatch=check_session)],
routes=[
Mount("/static", StaticImmutable(directory="static"), name="static"),
Mount("/auth", auth_app, name="auth"),
Mount("/account", account_app, name="account"),
Mount("/", users_app, name="index"),
],
)
Run it and follow the complete flow
From the project root:
cd app
uv run --env-file ../.env uvicorn main:app --reload
Open http://localhost:8000. Running from app/ resolves the imports and puts users.db beside main.py.
- Register, verify your email, and log in.
- Compare profiles: only yours shows account forms.
- Create accounts; navigate to
?page=2 and back.
- Change your password, then log in with the new one.
- Log out and reset your password through email.
- Enter your password and confirm deletion to remove your account.
Requests show the spinner and disable the submitting controls; errors preserve forms. Logout disables only itself.
What makes this an HDA?
HTML carries content and actions. HTMX applies the next representation; UserHarbor manages accounts independently.
| HTMX attribute or header | Role in this application |
hx-boost | Enhance internal links and update browser history. |
hx-post | Submit form values to a Python handler. |
hx-swap="none" | Keep the form until an explicit update. |
hx-swap-oob | Append HTML feedback to the shared alerts container. |
HX-Retarget, HX-Reswap | Replace a card after success. |
HX-Redirect | Navigate after login, logout, or account changes. |
hx-disabled-elt | Disable the submitting form's controls or button. |
hx-indicator | Show the shared loading indicator. |
Inline handlers only close alerts, switch tabs, and enable confirmation. Account state stays on the server. HTMX documents boosting, disabled controls, and redirects.
Take it into your own application
The starter adds responsive navigation. Explore UserHarbor's documentation for adapters and configuration.
For deployment, configure HTTPS, secure cookies, CSRF protection, secrets, directory access, migrations, SMTP delivery, and rate limits. Replace the development CSS watcher and initial table creation with your production workflow.
This is why I made UserHarbor framework-independent: reuse account management while keeping control of the UI. FastHTML and HTMX let that UI stay close to Python and HTML.
Read Hypermedia Systems, the free book from HTMX's creators, for more on HDA.