You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(users): one-click demo accounts (admin + standard user) for showcase instances (#331)
* feat(users): one-click demo account for showcase instances
Adds `demo_mode` to the users module: turn it on and the sign-in card
grows an "Explore the demo" button that signs a visitor in as a shared,
pre-seeded account — no email, no password, no signup. Intended for
hosting a public instance that shows the framework off.
Three pieces, because a demo account is a *published* credential and the
account alone would be a footgun:
- `users.demo` resolves the configured account from settings and
reconciles the row on every boot and every settings reload. Flipping
`demo_role` from admin back to user demotes the existing row rather
than leaving the grant behind. A blank `demo_password` seeds a random
secret, so the account is reachable only through the button — the
password never reaches the browser, unlike the dev quick-fill buttons,
which paste real credentials and stay development-only.
- `users.demo_guard.DemoReadOnlyMiddleware` refuses every unsafe HTTP
method from a demo session while `demo_read_only` is on (the default),
except signing out. It reads the session rather than
`request.state.user`, so it needs nothing from AuthMiddleware and
works wrapped outside it; matching on the user id as well as the
session stamp catches someone who signed in with a published
`demo_password` through the ordinary form. Inertia requests get the
protocol's 409 + X-Inertia-Location rather than a raw 403 body, which
Inertia would render as an error modal.
- A `demo` shared prop drives `DemoBanner`, a standing bar in every app
shell. Per session, not per install: an operator signed in to their
own account on a demo instance is doing real work and is unaffected,
by the banner and by the guard alike.
`demo_role = "admin"` with `demo_read_only = false` is a writable public
superuser. It is allowed — a database rebuilt on a timer is a legitimate
reason — but logs `users.demo.writable_admin` at WARNING and is called
out in the docs.
Also extracts two self-contained decisions out of `UsersModule.on_startup`
into `users.startup`; the hook was one line under the 300-line file cap.
Claude-Session: https://claude.ai/code/session_015SeBCuMuwvpfANx9FHv4qn
* feat(users): two demo accounts — admin and standard user
Reworks demo mode from one shared account into two, because the two
halves of the app look nothing alike. The admin surface — users, roles,
settings, modules, audit history — is what the framework is *for*, and a
visitor who only ever sees the end-user app never meets it; one who only
sees /admin/* never meets the app you build for people. The sign-in card
now offers both, admin first, and a visitor can switch between them
without signing out.
Settings replace the single-account fields: `demo_admin_email` /
`demo_user_email` and their optional passwords, with `demo_mode` and
`demo_read_only` still covering the pair. `demo_role` and `demo_full_name`
are gone — the role is now implied by which account, and the display
names are seeded constants rather than another knob. Blanking an email
switches that account off, closing its route as well as hiding its
button: the offer and the endpoint come from the same resolution, so
hiding a button can never leave a live way in behind it.
`POST /api/users/auth/demo` becomes `POST /api/users/auth/demo/{role}`.
The role is a path segment rather than a body field so each account gets
its own throughput budget — the limiter keys on path + client IP, so a
bot hammering one cannot lock a visitor out of the other — and so an
unknown role 404s in routing rather than in the handler. Both sign-in
routes are exempt from the read-only guard, since comparing the two
surfaces is the point of having two and must not require a sign-out.
`ensure_demo_users` seeds and reconciles both; a failure on one no longer
withdraws the other. `_sync_role` still drops the role it is replacing,
so repointing an address from the admin account to the user one actually
demotes the row. The guard matches any of the cached ids, and the banner
shared prop is unchanged — per session, so an operator signed in to their
own account on a demo instance sees nothing and writes freely.
Verified end to end against a booted app: both buttons sign in as the
right identity, both raise the banner, both are refused writes with
DEMO_READ_ONLY, an unknown role 404s, and blanking `demo_admin_email`
closes the admin route and drops its button.
Claude-Session: https://claude.ai/code/session_015SeBCuMuwvpfANx9FHv4qn
* fix(users): three ways the demo guard failed on its own state
Found by a review pass over the demo-accounts diff; all three reproduce
against a booted app.
**A stale demo stamp locked a visitor out of signing in.** Nothing ever
cleared `is_demo`: `UsersAuthProvider._forget` drops user_id, user_ctx,
session_version and the remember window, but left the marker behind. So
once a demo session lapsed — expiry, "sign out everywhere" on the demo
account, or a nightly reset that recreates the rows with new ids — the
browser was anonymous but still stamped, and its `POST
/api/users/auth/login` came back `403 DEMO_READ_ONLY`. No way back in
short of clearing cookies. The stamp now lives in `users.constants` so
`manager` and `provider` can reach it without importing the seeding
module; `on_after_login` pops it (the demo endpoint re-stamps after the
hook, so every *other* sign-in path clears it) and `_forget` pops it too.
`/api/users/auth/login` joins the guard's allowlist — it is how you stop
being the demo, it costs a real password, and the rate limiter still
applies. `/auth/token` deliberately stays blocked.
**Turning `demo_mode` off promoted every live demo session to a writable
superuser.** The guard short-circuited on `demo_mode`, so the operator
action that means "end the showcase" did the opposite for everyone
already inside it. The guard (and the banner prop, which has to agree
with it) now key on `demo_read_only` plus the session's own stamp.
`demo_mode` governs whether the instance still *offers* the demo, not
what an already-minted session is. Ending the sessions themselves is
disabling the rows, which is now documented.
**Reconcile re-enabled a demo account an admin had disabled.**
`reconcile_demo_user` wrote `is_active=True; disabled_at=None`
unconditionally, so the kill switch for an account being abused was
reverted on the next boot or the next unrelated Users settings save.
Both fields are now set on create only, matching the generated password.
Regression tests for each in `test_demo_guard.py`;
`test_users_shared_props.py`'s `test_inactive_when_demo_mode_is_off`
encoded the old rule and is replaced by two for the new one.
Claude-Session: https://claude.ai/code/session_015SeBCuMuwvpfANx9FHv4qn
---------
Co-authored-by: Claude <noreply@anthropic.com>
|`POST /api/users/auth/accept-invite`|`AcceptInviteRequest`| sets password + signs the user in |
36
+
|`POST /api/users/auth/demo/{role}`| — | one-click sign-in as a shared demo account (`role` ∈ `admin`, `user`); `404` unless that account is configured; rate-limited per role — see [Demo mode](#demo-mode-hosting-a-showcase-instance)|
36
37
|`POST /api/users/auth/token`|`TokenRequest` (email + password) | bearer login for mobile / API clients → `{access_token, refresh_token, token_type, expires_in}`; `401` for external/SSO users |
37
38
|`POST /api/users/auth/token/refresh`|`RefreshRequest` (refresh_token) | rotates a refresh token into a new pair (old one revoked) |
38
39
|`DELETE /api/users/auth/token`|`RefreshRequest` (refresh_token) | revokes a refresh token (idempotent) |
@@ -195,6 +196,11 @@ Everything else is DB-backed (initial values are pydantic defaults; edit under U
195
196
|`auth_rate_limit_attempts`|`10`|
196
197
|`auth_rate_limit_window_seconds`|`300`|
197
198
|`bootstrap_email`, `bootstrap_password`, `bootstrap_user_email`, `bootstrap_user_password`|`""` — see [Bootstrap](#bootstrap-the-first-admin)|
199
+
|`demo_mode`|`False` — see [Demo mode](#demo-mode-hosting-a-showcase-instance)|
200
+
|`demo_admin_email`|`"demo-admin@example.com"` — blank to stop offering the admin demo |
201
+
|`demo_user_email`|`"demo-user@example.com"` — blank to stop offering the standard-user demo |
202
+
|`demo_admin_password` / `demo_user_password`|`""` (a random one is generated, so the account is reachable only through its button) |
203
+
|`demo_read_only`|`True`|
198
204
|`oauth_google_client_id` / `oauth_google_client_secret`|`""` — Google OAuth |
@@ -252,6 +258,106 @@ Two paths to seed the first admin:
252
258
1.**CLI** — `smpy users create-admin ...`.
253
259
2.**Env vars** — set `SM_USERS_BOOTSTRAP_EMAIL` + `SM_USERS_BOOTSTRAP_PASSWORD` before first `make dev`. `bootstrap_admin_from_env(app)` runs at startup and creates the admin if the `users_user` table is empty. Optionally seed a non-admin too via `SM_USERS_BOOTSTRAP_USER_EMAIL` + `SM_USERS_BOOTSTRAP_USER_PASSWORD`.
254
260
261
+
## Demo mode (hosting a showcase instance)
262
+
263
+
Turn `demo_mode` on and the sign-in card grows a button per demo account.
264
+
One click signs the visitor straight in — no email, no password, no signup.
265
+
266
+
```
267
+
users.demo_mode = true
268
+
users.demo_admin_email = demo-admin@example.com
269
+
users.demo_user_email = demo-user@example.com
270
+
users.demo_read_only = true # default; leave it on
271
+
```
272
+
273
+
Set these under Users at `/admin/settings/`, or seed them in the settings store
274
+
before first boot. Changes apply live — the `SettingsReloaded` handler re-seeds
275
+
the accounts and refreshes the cached ids, so there is no restart.
276
+
277
+
### Two accounts, not one
278
+
279
+
An **administrator** and an ordinary **standard user**, because the two halves
280
+
of the app look nothing alike. The admin surface — users, roles, settings,
281
+
modules, audit history — is what the framework is *for*, and a visitor who
282
+
only ever sees the end-user app never meets it. One who only ever sees
283
+
`/admin/*` never meets the app you actually build for people. Offering both,
284
+
and letting someone switch between them without signing out, is what makes a
285
+
showcase instance answer "what is this?" in one visit.
286
+
287
+
Each is independently switchable: blank an email and that button disappears
288
+
*and* its route starts answering 404 — the offer and the endpoint come from
289
+
the same resolution, so hiding a button never leaves a live way in behind it.
290
+
Blank both and `demo_mode` has nothing to turn on (logged, since you plainly
291
+
meant something by switching it on).
292
+
293
+
### What it does
294
+
295
+
-**Seeds the accounts** on every boot and every settings reload
296
+
(`users.demo.ensure_demo_users`). Idempotent, and it *reconciles*: the admin
297
+
row carries `is_superuser` and the `admin` role, the user row carries
298
+
neither, and repointing an email from one to the other demotes the row
299
+
rather than leaving the old grant in place. One account failing to seed does
300
+
not withdraw the other.
301
+
-**Never sends a password to the browser.** Each button posts to
302
+
`POST /api/users/auth/demo/{role}` with an empty body and the server
303
+
resolves the account itself. This is the difference between demo mode and
304
+
the dev quick-login buttons, which paste real credentials into the form and
305
+
are therefore development-only. Leave a `demo_*_password` blank and that
306
+
account is seeded with a random secret nobody — including you — can type;
307
+
set one only if you also intend to publish the credentials (for an API
308
+
demo, say).
309
+
-**Refuses writes** while `demo_read_only` is on.
310
+
`DemoReadOnlyMiddleware` rejects every unsafe HTTP method from either demo
311
+
session with `403 {"detail": "DEMO_READ_ONLY"}`, except signing out and the
312
+
demo sign-in routes themselves — switching between the two accounts is the
313
+
point of having two, so it must not require a sign-out. Inertia requests get
314
+
the protocol's `409` + `X-Inertia-Location` instead, so a blocked save
315
+
re-renders the page rather than throwing up an error modal.
316
+
-**Says so.**`DemoBanner` renders a standing bar in every app shell, driven
317
+
by the `demo` shared prop. It is **per session**, not per install — you,
318
+
signed in to your own account on the same instance, do not see it, and your
319
+
writes are not touched.
320
+
321
+
### `demo_read_only = false`
322
+
323
+
With an admin demo configured, this hands anyone who can reach your sign-in
324
+
page a writable superuser: the settings editor (including the SMTP password
325
+
and OAuth client secrets), the user table, and maintenance mode. It is allowed
326
+
— an instance whose database is rebuilt on a timer has a legitimate reason to
327
+
want a writable demo — but it logs `users.demo.writable_admin` at WARNING on
328
+
every settings load. Do not run it against a database you care about. Blanking
329
+
`demo_admin_email` is the way to have a writable demo without that exposure.
330
+
331
+
### Limits worth knowing
332
+
333
+
- The accounts are **shared**. Two visitors exploring the same one at once see
334
+
each other's state, and with `demo_read_only = false` they can overwrite
335
+
each other. Periodically resetting the database is the only real answer.
336
+
-`demo_read_only` matches on the **session** — the stamp a demo endpoint
337
+
writes, or a session whose `user_id` is one of the demo accounts'. A bearer
338
+
token is not covered directly, but minting one is itself a `POST`, so a
339
+
read-only demo cannot get hold of one.
340
+
- The guard deliberately does **not** consult `demo_mode`. Switching demo mode
341
+
off withdraws the *offer* — it does not retract the sessions already handed
342
+
out, and promoting those live cookies to writable superusers on the way out
343
+
would be the opposite of what "turn the demo off" means. A stamped session
344
+
stays read-only (and keeps its banner) until it signs out or lapses. To end
345
+
the sessions themselves, disable the demo rows in the user editor; a
346
+
reconcile will not re-enable them.
347
+
- Signing in with real credentials (`POST /api/users/auth/login`) is allowed
348
+
from a demo session — it is how you *stop* being the demo, it takes a real
349
+
password, and the rate limiter still applies. Every non-demo sign-in clears
350
+
the stamp, so a browser that once held a demo session is never locked out of
351
+
the ordinary login.
352
+
- Each role's endpoint has its own `auth_rate_limit_*` budget (the limiter
353
+
keys on path + client IP), so a bot hammering one cannot lock a visitor out
354
+
of the other. Each click mints a session row, so a demo instance under real
355
+
traffic may want the budget raised.
356
+
- The demo **admin satisfies the first-run setup gate** — an administrator
357
+
exists, so `/setup` never appears. Seed your own admin first
358
+
(`smpy users create-admin`, or the `SM_USERS_BOOTSTRAP_*` vars), because a
359
+
read-only demo session cannot complete the wizard.
360
+
255
361
## Mailer backends
256
362
257
363
`mailer/` ships two implementations of the `Mailer` protocol:
0 commit comments