Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
270522e
Scaffold modules/users/ skeleton (#users-task-1)
antosubash Apr 15, 2026
48132ca
Keep empty modules/users/tests/ present on disk
antosubash Apr 15, 2026
03d5fa3
Add PermissionRegistry.map_role for module role→perm bindings
antosubash Apr 15, 2026
4ec08b1
Add users module models and initial migrations
antosubash Apr 15, 2026
b58cc54
Fix UserAccessToken.user_id type annotation
antosubash Apr 15, 2026
b1f0474
Wire users module core: settings, mailer, DB adapter, manager, auth b…
antosubash Apr 15, 2026
e6e42e9
Tighten mailer: runtime_checkable Protocol + eager template env
antosubash Apr 15, 2026
4b98cdc
Add local-user AuthMiddleware + UserContext.from_user
antosubash Apr 15, 2026
1117e86
Mount users API: wrapper login, fastapi-users routers, admin, accept-…
antosubash Apr 15, 2026
08acf15
ruff: fix import ordering in framework/db/tests after _models.py refa…
antosubash Apr 15, 2026
66a592f
Add Inertia pages + view routes for users module
antosubash Apr 15, 2026
76e7d69
Remove Keycloak; activate local AuthMiddleware
antosubash Apr 15, 2026
2f04a07
Add sm-users create-admin CLI and env-var bootstrap
antosubash Apr 15, 2026
94d6447
Register users menu items + GET /users/logout shim
antosubash Apr 15, 2026
f22b5de
E2E tests for invite flow + docs refresh (post-Keycloak)
antosubash Apr 15, 2026
58a2337
simplify: cleanup after post-merge review
antosubash Apr 15, 2026
e6c7526
simplify: address deferred items from review
antosubash Apr 15, 2026
4d58df2
Fix first-boot user management: migration GUID, imports, tsconfig, pa…
antosubash Apr 15, 2026
dd8f0ce
Merge origin/main (i18n) into feature/hopeful-vaughan
antosubash Apr 15, 2026
d15e035
Seed a second (standard-user) account + add dev-mode quick-login buttons
antosubash Apr 15, 2026
9007874
Fix CI: Python typecheck, JS format, 300-line cap
antosubash Apr 15, 2026
aba1200
Subtler landing animations
antosubash Apr 15, 2026
892377b
Remove all landing / shell page-load animations
antosubash Apr 15, 2026
110e010
Make Profile / Logout as visible as primary nav items
antosubash Apr 15, 2026
df3c674
Hide Profile/Logout in a user dropdown; fix double divider at sidebar…
antosubash Apr 15, 2026
882b4fe
Add 'user' icon to NavIcon map (singular, for Profile)
antosubash Apr 15, 2026
f6b3738
Don't render the email twice when user has no full_name
antosubash Apr 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 37 additions & 10 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,47 @@
SM_DATABASE_URL=sqlite+aiosqlite:///./app.db
# SM_DATABASE_URL=postgresql+asyncpg://sm:sm@localhost:5432/simple_module

# Keycloak (auth module settings — prefix must match AuthSettings.env_prefix)
SM_AUTH_KEYCLOAK_URL=http://localhost:8080
SM_AUTH_KEYCLOAK_REALM=simple-module
SM_AUTH_KEYCLOAK_CLIENT_ID=simple-module-app
SM_AUTH_KEYCLOAK_CLIENT_SECRET=change-me-in-production

# App
SM_ENVIRONMENT=development
SM_SECRET_KEY=change-me-in-production
SM_VITE_DEV_URL=http://localhost:5050

# Multi-tenancy (default off). Turn on only for deployments that actually
# partition data by tenant. SM_TENANT_HEADER enables a header source for
# tenant resolution when there's no authenticated user — leave empty in
# production to force tenant resolution through the auth token.
# Users module — local email+password auth (replaces Keycloak)
#
# Signup mode: false = admin-invite only (default), true = public signup
SM_USERS_ALLOW_SIGNUP=false

# Token secrets — MUST change in production. Dev placeholders are visible in
# log lines so they're obvious to rotate.
SM_USERS_RESET_PASSWORD_TOKEN_SECRET=dev-reset-token-secret-change-me
SM_USERS_VERIFICATION_TOKEN_SECRET=dev-verify-token-secret-change-me

# Mailer: console (logs tokenized links at INFO) or smtp
SM_USERS_MAILER=console
SM_USERS_BASE_URL=http://localhost:8000
# SM_USERS_SMTP_HOST=
# SM_USERS_SMTP_PORT=587
# SM_USERS_SMTP_USERNAME=
# SM_USERS_SMTP_PASSWORD=
# SM_USERS_SMTP_FROM=no-reply@localhost
# SM_USERS_SMTP_TLS=true

# First-admin bootstrap (auto-create an admin on first boot iff users table
# is empty AND both are set). Leave blank to disable — use `sm-users
# create-admin` instead.
# SM_USERS_BOOTSTRAP_EMAIL=admin@example.com
# SM_USERS_BOOTSTRAP_PASSWORD=changeme

# Optional second seeded user with the "user" role. Seeded alongside the
# admin on first boot (same empty-table guard). When both env vars are set
# AND SM_ENVIRONMENT=development, the login page renders quick-login buttons
# for both accounts to speed up manual testing. Leave blank in production.
# SM_USERS_BOOTSTRAP_USER_EMAIL=user@example.com
# SM_USERS_BOOTSTRAP_USER_PASSWORD=changeme

# Cookie: set cookie_secure=false in dev (HTTPS-only otherwise)
SM_USERS_COOKIE_SECURE=false

# Multi-tenancy (default off).
# SM_MULTI_TENANT=false
# SM_TENANT_HEADER=
58 changes: 53 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ A modular-monolith framework for Python. Each feature lives in its own self-cont

- **Backend:** Python 3.12, FastAPI, SQLAlchemy async, Alembic
- **Frontend:** Inertia.js + React + Tailwind CSS 4, Vite HMR
- **Auth:** Keycloak (OIDC, cookie-based sessions)
- **Auth:** Local user management (email+password, cookie-based sessions) via fastapi-users
- **Tooling:** uv workspaces, Ruff, ty, Biome, pytest

## Quickstart
Expand All @@ -18,7 +18,7 @@ make install
# 2. Copy env template (defaults work for local SQLite dev)
cp .env.example .env

# 3. Start Keycloak + Postgres (skip if sticking with SQLite)
# 3. Start Postgres (skip if using SQLite — the default .env uses SQLite)
make docker-up

# 4. Run migrations
Expand All @@ -28,7 +28,7 @@ make migrate
make dev
```

Hit `http://localhost:8000` — you land on the public page. `/auth/login` takes you through Keycloak, `/dashboard` is the authenticated home, `/products` is a fully-working example module.
Hit `http://localhost:8000` — you land on the public page. `/users/login` is the email+password login, `/dashboard` is the authenticated home, `/products` is a fully-working example module.

## Create a new module

Expand Down Expand Up @@ -85,7 +85,7 @@ docs/
| `make migration msg="..."` | Autogenerate a new migration |
| `make new-module name=<name>` | Scaffold a new module |
| `make kill` | Stop any running dev servers (ports 8000, 5173) |
| `make docker-up` / `docker-down` | Manage Keycloak + Postgres containers |
| `make docker-up` / `docker-down` | Manage the Postgres container (SQLite needs no Docker) |

## Configuration

Expand All @@ -96,12 +96,60 @@ All settings are `SM_`-prefixed env vars. Defaults in `.env.example` cover local
| `SM_DATABASE_URL` | `sqlite+aiosqlite:///./app.db` | Async URL. Postgres: `postgresql+asyncpg://...` |
| `SM_ENVIRONMENT` | `development` | Anything else triggers strict module discovery |
| `SM_SECRET_KEY` | _(placeholder)_ | **Must** change in production — signs session cookies |
| `SM_AUTH_KEYCLOAK_URL` | `http://localhost:8080` | Auth module settings (note the `SM_AUTH_` prefix) |
| `SM_USERS_ALLOW_SIGNUP` | `false` | Enable public signup (else admin-invite only) |
| `SM_USERS_MAILER` | `console` | `console` logs links; `smtp` uses SMTP config (see `.env.example`) |
| `SM_USERS_RESET_PASSWORD_TOKEN_SECRET` | _(dev placeholder)_ | **Must** change in production |
| `SM_USERS_VERIFICATION_TOKEN_SECRET` | _(dev placeholder)_ | **Must** change in production |
| `SM_USERS_BOOTSTRAP_EMAIL` | `` | First-admin email; combined with `SM_USERS_BOOTSTRAP_PASSWORD`, creates admin on first boot iff users table is empty |
| `SM_USERS_BOOTSTRAP_PASSWORD` | `` | Paired with above |
| `SM_MULTI_TENANT` | `false` | Set `true` to enable `TenantMiddleware` |
| `SM_TENANT_HEADER` | `` | Empty = token-only; set e.g. `X-Tenant-ID` to enable header fallback |

See `framework-conventions.md` for the settings-per-module convention.

## User management

### Creating the first admin

Either use the CLI:

```bash
uv run sm-users create-admin --email admin@example.com --password changeme
```

Or let the app bootstrap it automatically on first boot by setting env vars **before** running `make migrate && make dev`:

```
SM_USERS_BOOTSTRAP_EMAIL=admin@example.com
SM_USERS_BOOTSTRAP_PASSWORD=changeme
```

The auto-bootstrap is idempotent — it only creates the user if the `users_user` table is empty.

### Inviting users

1. Log in as admin and navigate to `/users/admin/invite`.
2. Fill in the invitee's email and optionally a full name and role(s). Click **Send invite**.
3. With the default `console` mailer, the invite link is logged to stdout (`tail -f` the server log). Copy the link and send it to the user. With `smtp`, the email is delivered automatically.
4. The invitee opens the link (`/users/invite/accept?token=…`), sets a password, and is immediately logged in.

### Enabling public signup

Set `SM_USERS_ALLOW_SIGNUP=true` and restart the server. The `/users/register` page becomes accessible.

### Switching to SMTP

```
SM_USERS_MAILER=smtp
SM_USERS_BASE_URL=https://your-domain.com
SM_USERS_SMTP_HOST=smtp.example.com
SM_USERS_SMTP_PORT=587
SM_USERS_SMTP_USERNAME=no-reply@example.com
SM_USERS_SMTP_PASSWORD=secret
SM_USERS_SMTP_FROM=no-reply@example.com
SM_USERS_SMTP_TLS=true
```

## Architecture

- **Modules**: discovered via Python entry points at boot. Each module subclasses `ModuleBase` and opts into the lifecycle hooks it needs (`register_routes`, `register_menu_items`, `register_permissions`, `register_middleware`, `on_startup`, ...).
Expand Down
24 changes: 13 additions & 11 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,22 +145,24 @@ async def client(app) -> AsyncGenerator[httpx.AsyncClient, None]:

@pytest.fixture
async def authenticated_client(app) -> AsyncGenerator[httpx.AsyncClient, None]:
"""Authenticated async HTTP client (admin user via signed session cookie)."""
"""HTTPX client with a signed session cookie carrying a seeded admin user's id."""
import json
from base64 import b64encode

from itsdangerous import TimestampSigner

userinfo = {
"sub": "test-user-id",
"email": "test@example.com",
"name": "Test User",
"preferred_username": "testuser",
"realm_access": {"roles": ["admin"]},
}
session_data = {"userinfo": userinfo}
from users.bootstrap import create_admin

async with app.state.db.session_factory() as session:
result = await create_admin(
session,
email="admin@test",
password="test-password",
full_name="Test Admin",
)
user_id = str(result.user.id)

session_data = {"user_id": user_id}
data = b64encode(json.dumps(session_data).encode())

signer = TimestampSigner(str(app.state.settings.secret_key))
signed = signer.sign(data).decode("utf-8")

Expand Down
11 changes: 0 additions & 11 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,15 +1,4 @@
services:
keycloak:
image: quay.io/keycloak/keycloak:latest
command: start-dev --import-realm
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
volumes:
- ./keycloak/realm-export.json:/opt/keycloak/data/import/realm.json
ports:
- "8080:8080"

postgres:
image: postgres:16
environment:
Expand Down
58 changes: 40 additions & 18 deletions docs/e2e-testing.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
# End-to-End Testing

The repo ships Playwright-driven smoke tests at
[tests/e2e/test_smoke.py](../tests/e2e/test_smoke.py). Two tests drive a real
[tests/e2e/test_smoke.py](../tests/e2e/test_smoke.py). Four tests drive a real
Chromium browser through the core flows:

* **`test_login_and_browse_smoke`** — landing → Keycloak login → dashboard →
products browse → logout. Minimal regression guard.
* **`test_login_and_browse_smoke`** — landing → local email+password login →
dashboard → products browse → logout. Minimal regression guard.
* **`test_products_crud_smoke`** — same login + a full create / edit / delete
round-trip against the products module.
* **`test_password_reset_smoke`** — **skipped** (see inline comment in the
test file). `fastapi-users` `reset_password()` validates a password
fingerprint (`password_fgpt`) that is only available server-side. The
full HTTP-layer flow is covered by unit tests in
`modules/users/tests/test_api_auth.py`.
* **`test_admin_invite_smoke`** — admin invites a new user via the UI; the
invitee accepts the invite in a fresh browser context and lands on the
dashboard. Token is minted locally using the dev-default verify secret
(equivalent to what the ConsoleMailer logs).

End-to-end tests are gated behind the `e2e` pytest marker (declared in
[pyproject.toml](../pyproject.toml)) and are **excluded from the default
Expand All @@ -25,11 +34,20 @@ uv run playwright install chromium
Then bring up the full stack (in a separate terminal, leave it running):

```bash
make docker-up # Keycloak + Postgres
make docker-up # Postgres (skip if using the default SQLite config)
make migrate # apply Alembic migrations
make dev # FastAPI on :8000 + Vite on :5173
```

Create the first admin user (needed for e2e auth):

```bash
uv run sm-users create-admin --email admin@example.com --password admin
```

Or set `SM_USERS_BOOTSTRAP_EMAIL` / `SM_USERS_BOOTSTRAP_PASSWORD` in `.env`
before the first `make dev` run.

## Running

```bash
Expand All @@ -44,23 +62,21 @@ uv run pytest -m e2e tests/e2e

## Configuration

The test reads three environment variables (all optional):

| Variable | Default | Notes |
| -------------- | ------------------------- | ------------------------------------------------------------------ |
| `E2E_BASE_URL` | `http://localhost:8000` | Where the FastAPI host is listening. |
| `E2E_USERNAME` | `admin` | Keycloak username. The seeded `admin` user has role `admin`. |
| `E2E_PASSWORD` | `admin` | Keycloak password for the above user. |
The tests read these environment variables (all optional):

Seeded Keycloak users live in [keycloak/realm-export.json](../keycloak/realm-export.json).
The defaults match the `admin`/`admin` user out of the box.
| Variable | Default | Notes |
| -------------- | ------------------------- | ------------------------------------------------------------ |
| `E2E_BASE_URL` | `http://localhost:8000` | Where the FastAPI host is listening. |
| `E2E_USERNAME` | `admin@example.com` | Email of the admin user created via `sm-users create-admin`. |
| `E2E_PASSWORD` | `admin` | Password of the above admin user. |
| `SM_USERS_VERIFICATION_TOKEN_SECRET` | `dev-verify-token-secret-change-me` | Must match the running server's value so locally-minted invite tokens are accepted. |

## What the smoke tests cover

**`test_login_and_browse_smoke`**

1. Landing page renders (`/`) with the "Get Started" CTA.
2. Keycloak OIDC login round-trip.
2. Local email+password login via `/users/login`.
3. Dashboard (`/dashboard/`) renders — proves session cookie + AuthMiddleware +
Inertia resolver + AuthenticatedLayout.
4. Products browse (`/products/`) renders — proves module pages resolve.
Expand All @@ -73,10 +89,16 @@ The defaults match the `admin`/`admin` user out of the box.
3. Edit its name and verify the new name appears in the list.
4. Delete it through the confirm dialog and verify the row disappears.

The CRUD test relies on the Keycloak `simple-module-app` client shipping a
`realm roles` protocol mapper that emits `realm_access.roles` into userinfo
(see [keycloak/realm-export.json](../keycloak/realm-export.json)); without
that, `RequiresPermission("products.create")` returns 403.
The CRUD test relies on the admin user having the `admin` role (created
automatically by `sm-users create-admin` or the bootstrap env vars).

**`test_admin_invite_smoke`**

1. Admin logs in and submits the invite form at `/users/admin/invite`.
2. The test looks up the new user's UUID via the admin API.
3. A verify token is minted locally (same secret the server uses).
4. A fresh browser context navigates to `/users/invite/accept?token=…`, sets
a password, and verifies a redirect to `/dashboard`.

These are **not** pixel-perfect regression tests — the goal is to catch broad
breakage in the auth + render + CRUD spine.
Expand Down
2 changes: 1 addition & 1 deletion docs/framework-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ SM_DEBUG, SM_LOG_LEVEL, SM_LOG_FORMAT, SM_MULTI_TENANT, SM_TENANT_HEADER
```

Module settings should:
- Use a per-module prefix: `SM_<MODULE>_*` (e.g. `SM_AUTH_KEYCLOAK_URL`).
- Use a per-module prefix: `SM_<MODULE>_*` (e.g. `SM_USERS_ALLOW_SIGNUP`).
- Be stored on `app.state.<module>_settings` during `register_settings(app)`.
- `SM012` diagnostic fires if `register_settings` is overridden but no `app.state.<module>_settings` is added.

Expand Down
4 changes: 4 additions & 0 deletions docs/plans/2026-04-13-alembic-migrations-design.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Alembic Database Migrations — Design Document

> **Note (2026-04-15):** Keycloak integration was removed; see plan
> `cryptic-juggling-lightning`. References to "Keycloak" below are
> historical context from the original design — the patterns still apply.

**Goal:** Replace the `create_all` approach in module `on_startup` hooks with Alembic-managed schema versioning, adding migration diagnostics to the existing framework.

**Tech Stack:** Python 3.12, SQLAlchemy async, Alembic, FastAPI, UV workspace
Expand Down
4 changes: 4 additions & 0 deletions docs/superpowers/plans/2026-04-13-module-lifecycle-hooks.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Module Lifecycle Hooks Implementation Plan

> **Note (2026-04-15):** Keycloak integration was removed; see plan
> `cryptic-juggling-lightning`. References to "Keycloak" below are
> historical context from the original design — the patterns still apply.

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add three new ModuleBase lifecycle hooks (register_exception_handlers, register_health_checks, register_settings), restructure the app boot sequence, add SM010 diagnostic, and migrate auth settings out of the framework.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Module Lifecycle Hooks: Exception Handlers, Health Checks, Module Settings

> **Note (2026-04-15):** Keycloak integration was removed; see plan
> `cryptic-juggling-lightning`. References to "Keycloak" below are
> historical context from the original design — the patterns still apply.

**Date**: 2026-04-13
**Status**: Draft
**Scope**: Add three new lifecycle hooks to `ModuleBase` and restructure `create_app()` sequencing
Expand Down
Loading