Skip to content

Screen wireframes: 15 UX rethinks across the admin app - #261

Merged
antosubash merged 17 commits into
mainfrom
worktree-wireframe-rethinks
Aug 13, 2026
Merged

Screen wireframes: 15 UX rethinks across the admin app#261
antosubash merged 17 commits into
mainfrom
worktree-wireframe-rethinks

Conversation

@antosubash

Copy link
Copy Markdown
Owner

Implements every "Rethink" annotation from the Screen Wireframes.dc.html design board. The board documented 26 already-shipped screens, so its 15 captions were the actual delta.

What changed

Item Change
1f Accept-invite card names the invitee and the roles granted, and says when an invite is already used
1g Dashboard module tiles link to their module's screen (gated on the per-user menu, so no tile leads to a 403)
1j Create + Invite merged into one "Add people" flow with a mode switch; both old URLs redirect into it
1k Bulk-paste invites with per-address outcomes, plus a copy-link panel when mail cannot be delivered
1l Edit user: details + roles share one dirty state and one Save, with a navigation guard
1n Role-inherited permissions read as held (not "off"), and name the granting role
1o /settings/ leads with per-module forms; the raw key/value store moves to /settings/store (308 from the old path)
1p Setting key autocomplete from registered module settings; selecting one also fills the value type
1q Each field reports its source (stored override / environment / default); "Test connection" runs the module's health checks on demand
1r Feature-flag tenant picker replaces the free-text id
1s File search + content-type filter, upload progress rows, and the missing pagination controls
1t Background-tasks status strip that doubles as a filter
1w Audit log resolves actor and entity ids to names and links
1x Branding previews the sidebar and banner live, without a save or reload
1y Error screens surface the correlation id, matched to the X-Correlation-ID header

Also fixed en route: file storage had no pagination controls at all, so anything past the first 20 files was unreachable.

Two new framework extension points

Both follow the existing register_design_packs precedent:

  • register_audit_links — modules declare where their own records live, so audit_log never learns anyone else's routes.
  • MenuItem.permissions — the sidebar stops offering entries that 403 on click. Roles alone could not express this: roles=["admin"] hides a screen from a custom role that legitimately holds the permission.

Health checks also gained module attribution and a probe flag, so checks that reach a third party are not run on a readiness-probe timer.

Verification

Six code-review passes and a browser QA cycle ran over this branch. Everything found was fixed; the notable ones:

  • Two authorization holes. POST /settings/test-connection/{package} was mounted on a router with no permission dependency, letting any signed-in account trigger outbound SMTP/S3 requests and read the raw exception text back. Separately, the settings view router was ungated entirely, so any authenticated user could read every module's configuration — while the equivalent API returned 403 for the same user.
  • Three sidebar entries (Settings, Audit Log, Feature Flags) visible to accounts that would 403 on click.
  • Raw translation keys on every admin screen after login (P1, found in the browser). Two causes: the client adopted a new catalogue only when the locale changed, and react-i18next re-renders on no store events by default. Pre-existing on main, latent until audience-scoped catalogs landed.
  • Audit entity links never matched (P1, found in the browser). Registrations were keyed by __tablename__, but the audit trail records the model class name. Invisible on screen, because an unmatched lookup falls back to showing entity_type as the label — so rows read "User <id>" and looked correct while never linking.

13 of the 15 items were exercised directly in a browser; the other two are covered by unit tests.

  • Local CI: make lint (ruff, ty, biome, 13 tsc projects, file-size, metadata, READMEs), 1901 pytest, 48 vitest, production build — all green
  • E2E: 13 passed, including a new regression test for the post-login i18n bug. The existing i18n e2e check could not catch it — it does a full page.goto() after login, which always worked. The new test was verified to fail without the fix.
  • make doctor: 0 errors (its one SM003 warning predates this branch)

Known limitation

Both module health checks are probe=False (they reach SMTP/S3 and must not run on a probe timer), so the host database check is the only probe-safe one in a default install — and it is owned by the host, not a module. The per-module health dot therefore has no data source until a module registers a cheap check of its own, and renders nothing, which is the honest "nothing is watching this" state. Restoring it would mean either polling third parties on a timer or caching on-demand results.

Test plan

  • Log in and confirm the dashboard renders translated text immediately, with no refresh
  • Open /settings/ as a non-admin and confirm both the screen and the sidebar entry are absent
  • Open the audit log and confirm actor and entity ids both link to their records
  • CI is green

Two items from the screen-wireframe board.

1y — an error page told the user something broke but gave them nothing to
quote in a support report. The correlation id is already on every log line
for the request and echoed as X-Correlation-ID; render it too, so the page
and the logs can be joined. Adds a shared CopyableId component, since
retyping a uuid by hand is the failure mode it exists to prevent.

1g — dashboard module tiles were inert. Each now links to its module's own
screen and shows that module's worst health status. Health checks gain a
`module` attribution, stamped by the host around register_health_checks so
module authors keep the existing add() signature.

Link targets are gated client-side against the per-user `menus` prop rather
than server-side: the stats payload is process-wide cached for 30s, so any
per-user filtering there would leak across sessions.
…upload progress

1t — the executions table had no status counts, so spotting a pile of failed
jobs meant paging through the list. Adds a counts strip that doubles as a
filter: clicking a tile filters to it, clicking again clears. Counts honour
the search box but deliberately ignore the status filter, since the strip is
how you pick a status. Failed/stuck go red only when non-zero — a permanent
red zero trains people to ignore it.

1s — files could not be searched or filtered by type, and past 20 rows were
simply unreachable (the page had no pager at all). Adds filename search,
content-type filter with a `image/`-style family option, facet counts drawn
from what is actually in the bucket, and the missing pagination controls.
Filter clauses are shared between the page query and its count so a filter
can never narrow the rows without narrowing the total.

Uploads now report per-file byte progress as rows above the table, via
XMLHttpRequest — fetch exposes no upload progress events in any current
browser. Failed rows persist until dismissed rather than vanishing.
The audit log showed `user_id` as a bare uuid and left `entity_id` unlinked,
so a row proved something changed without saying who did it or offering any
route to the record.

Actors now resolve to a display name (full name, else email) via one batched
query per page — an audit page is 50 rows authored by a handful of admins.
Deleted accounts keep showing the raw id rather than blanking the row: the id
is still the truthful record of who acted.

Entity ids link to the owning module's screen through a new AuditLinkRegistry
and `register_audit_links` hook, following the existing register_design_packs
precedent. Modules declare their own table -> URL mapping, so audit_log never
learns about anyone else's routes. Users, Settings and BackgroundTasks
register theirs; tables with no per-record screen (join rows, stored files)
simply render unlinked, which the registry treats as normal rather than an
error.

Resolution happens at render time only — the stored row keeps the bare id,
which is what makes it durable. Links widen no access: the target route
enforces its own permissions.
1n — the user-grants switch reflected only the direct grant, which is
correct (it is the only thing the form can change) but it was also the row's
only signal. A permission the user genuinely held through a role rendered
identically to one they did not hold, with a small badge as the sole clue.

Rows now answer two questions separately: a leading indicator says whether
the user has the permission at all, and the switch says whether it is granted
here. The badge names the granting role, because "inherited" does not tell an
admin which role to edit — `inherited_by` maps each key to its sources, and
deliberately includes keys that are also direct, so a permission granted both
ways cannot look purely direct.

1r — the flags scope was a free-text tenant id, where a typo silently showed
an empty scope rather than an error. Replaced with a picker over the tenants
that have overrides. The list cannot be closed — the framework has no tenant
registry, ids arrive on auth claims — so naming a new tenant by hand stays
possible for creating a tenant's first override. The currently-viewed tenant
is folded into the options, or it would vanish from its own picker.
The preview was a logo tile and the app name, so the two surfaces branding is
most visible on — the sidebar every authenticated page carries, and the
site-wide banner — could only be checked by saving and waiting for a reload.

The preview now renders both from form state, so it updates as you type. It
uses the same near-black `bg-app-sidebar` token as the real shell, so the
dark logo variant is judged against the surface it will actually sit on, and
it lists the viewer's own sidebar entries rather than invented ones.

The sidebar mark is rendered inline instead of through BrandingMark: that
component takes its badge colour as a Tailwind class, and the preview has to
show whatever hex is currently in the colour field.

The logo tile is kept below — the sidebar shows the dark variant, so it is
the only place the light-surface logo appears.
…nance

1o — /settings/ was the raw key/value store: a database view, keyed by dotted
strings, shown to anyone who clicked "Settings". The per-module forms now own
the section root and the store moves to /settings/store. /settings/modules
redirects (308) so existing links and bookmarks keep working.

1p — the setting key was free text, and a typo produced a row that looked
saved and was silently never read: a failure mode with no feedback at all.
The field now suggests every <package>.<field> an installed module declares,
and selecting one also fills in its declared value type. It stays free text —
a module can read keys this screen cannot enumerate — so an unrecognised key
gets an advisory warning rather than a validation error.

1q — a field showed its value and its env var name but never which one was in
force. Each field now reports its source (stored override / environment /
default), mirroring hydrate_settings' real precedence, and calls out the
genuinely confusing case: a stored override silently shadowing a set env var.

"Test connection" runs the module's health checks on demand rather than
inventing a parallel mechanism, so settings never learns what SMTP or S3 is.
Users and FileStorage gain the checks that makes this real: an SMTP session
that authenticates and hangs up without sending, and a HEAD for a key that
cannot exist. Both re-read live settings on every run rather than pinning the
boot-time instance, and both report the reason — "connection refused" and
"authentication failed" call for different fixes.
1j — create and invite were separate pages behind separate buttons, so an
admin chose between them before seeing what either involved. They take nearly
the same inputs and differ in exactly one respect (who sets the password),
which makes it a mode switch, not a fork in the navigation. Both old URLs
redirect into the merged form with their mode preselected.

1k — folded into that flow rather than into the standalone invite page it
replaces. Addresses are pasted as a block (newlines, commas, semicolons) and
reported per-address: one already-registered address in a list of twenty must
not discard the other nineteen. Repeats are collapsed and addresses lowercased
so a pasted column cannot mint two invites for one person. Capped at 100 per
submit, so one request cannot mint unbounded live tokens.

The copy-link panel appears only when the server says delivery did not happen
— the console mailer writes invite URLs to stdout and nowhere else, so an
admin could otherwise create an invite with no way to deliver it. A mailer
that does not declare itself is assumed to deliver, so a third-party mailer
never leaks tokens by omission. Delivery failures also fall back to a link
rather than stranding a half-finished invite.

1f — the accept card asked for a password while naming neither the invitee
nor the access granted, so a forwarded link was indistinguishable from the
right one. It now shows both, and says so when an invite has already been
used instead of presenting a form guaranteed to fail. The preview decodes the
token read-only: UserManager.verify marks the account verified as a side
effect, so routing the preview through it would spend the invite just by
looking at the page. A test pins that.

1l — details and roles now share one dirty state and one Save, with an
unsaved-changes marker and a navigation guard; only changed sections are sent,
so saving a renamed user does not rewrite every role assignment's audit trail.
Status changes stay immediate on purpose: disable/enable and mark-verified are
actions, not edits, and putting an account lockout behind a Save button would
be worse than the inconsistency it removes.

Also splits the boot-time module registration loop into _registrations.py to
stay under the 300-line cap.
Applied by /code-review --fix:
- SECURITY: POST /settings/test-connection/{package} was on the view router,
  which carries no permission dependency. Any authenticated user could force
  outbound SMTP AUTH / S3 requests and read raw exception text back
  (hostnames, bucket names, credential-failure reasons). Now gated on
  PERM_EDIT.
- audit_log imported users.models without declaring simple_module_users;
  a standalone install would ModuleNotFoundError at route registration.
- _overrides_by_package re-collected module settings and issued one full
  settings-table read per package (~15 per render). Replaced with a single
  SettingsStore.all_override_fields() bucketed by key prefix.
- Dashboard tile reachability matched view_prefix exactly, so Users (prefix
  /users, menu entry /users/admin) stayed permanently inert. Now resolves the
  first menu entry at or under the prefix.
- File search debounce was reset by every upload-progress re-render, so
  typing while uploading never fired a search. navigate/applyFilters memoised.
- Upload rows were cleared when router.reload() was issued rather than when it
  landed, flashing "No files yet" on a first upload.
- TestConnectionButton hardcoded a path the same change had added to ROUTES.

Fixed here, having been reported and skipped:
- Health checks that reach a third party no longer run on automatic pollers.
  /health/ready and the dashboard both ran the new SMTP login and S3 request
  on every poll — a k8s probe at 10s intervals means an SMTP AUTH every 10s,
  which earns a rate-limit and binds probe latency to someone else's uptime.
  Such dependencies are not readiness signals anyway: the app serves pages
  fine while its mailer is down. HealthCheck gains `probe`; "Test connection"
  still runs everything on demand.
- Bulk invite silently truncated at 100 addresses: paste 150 and the UI said
  "100 sent" while 50 people were never contacted. Overflow now comes back as
  explicit failed results naming the limit.
- Bulk invite's blanket except kept using a session that a real DB error had
  left needing rollback, so one bad row turned every later address into
  PendingRollbackError — the opposite of the documented partial success. Now
  rolls back before continuing, which is safe because the user manager commits
  each invite as it goes.
- The audit log's actor link hardcoded /users/admin/{id}, duplicating what
  AuditLinkRegistry owns; it now resolves through the registry and degrades to
  plain text when no module claims the users table.
- Edit user kept reporting "Unsaved changes" for details that had already
  persisted when the subsequent roles save failed. The dirty baseline now
  advances per section, and Discard reverts to what is persisted.
SECURITY — the settings *view* router carried no permission dependency, so
any signed-in account could GET /settings/ and read every module's
configuration (values, env var names, and the new source/env_set provenance),
plus /settings/create's catalog of every registered key. The equivalent
GET /api/settings/modules returned 403 for the same user, so the screen and
the API disagreed. Settings was the only module not guarding its view routes,
and moving the module-settings screen onto the section root widened the
exposure. Now RequiresPermission(PERM_VIEW) at the router, with
CREATE/EDIT/DELETE on the form actions, mirroring module_api.py.

Also fixes a bug the previous pass introduced: rolling back on a per-address
failure discarded flushed-but-uncommitted work from *earlier* successful
invites in the same request. UserManager.create commits the user row, but the
UserRole rows are only flushed — so inviting ["fresh", "taken"] with a role
left `fresh` created with no roles and nothing reported. Each invite is now
committed before the loop moves on. The old comment claimed "the user manager
commits each invite as it goes", which was true only of the user row.

Bulk invite no longer takes list[EmailStr]: pydantic rejected the entire body
over one malformed address, returning a 422 the UI could only report as a
generic failure — so a typo on one line of a pasted column discarded every
other address, defeating the per-address contract. Addresses are validated one
at a time and malformed ones come back as failed rows.

Two low findings reported and skipped upstream, closed here:
- The audit log showed "This account no longer exists" for any unresolved
  actor id, including ids from another id space (celery-worker-1) that never
  named an account. The copy is now neutral about why it did not resolve.
- The add-people screen reported mailer_delivers=true when no mailer existed
  at all, promising delivery in the one case that certainly cannot deliver.
Applied by /code-review --fix:
- Upload rows: clearing on reload kept only errors, so a file dropped while a
  previous reload was in flight had its in-progress row wiped. The XHR kept
  running but every patch() was then a no-op — upload with no progress and no
  completion feedback. In-flight jobs are now preserved.
- Retrying a task reloaded executions but not status_counts, so the red
  "Failed" tile kept its pre-retry number until a full navigation.
- bulk_invite treated a None mailer as delivering, so send_invite raised
  AttributeError and the handler surfaced "'NoneType' object has no attribute
  'send_invite'" verbatim in the admin UI — and disagreed with the add-people
  page, which already reported mailer_delivers: False for that case.
- The locale key `scope_other` collided with the CLDR `_other` plural suffix
  the i18n generator strips, emitting a phantom `feature_flags.browse.scope`
  key backed by no resource. Renamed to `scope_custom`.
- Dashboard tile targets could fall back to a POST-only menu entry (Logout),
  rendering a GET link to a 405. POST entries are now excluded.
- _package_of_module duplicated _module_settings._package_of; the two had to
  agree exactly or the "Test connection" button silently never appeared.

Fixed here, having been reported and skipped: gating /settings/ in pass 2 left
its sidebar entry visible to every authenticated account, 403ing on click.
MenuRegistry filtered on roles only, so there was no way to express "show this
to whoever holds settings.view" — roles=["admin"] would have hidden it from a
custom role that legitimately holds the permission while still showing it to
admin-adjacent roles that cannot open it.

MenuItem gains `permissions`, and get_for_user drops entries whose keys the
caller lacks. The middleware already had the expanded permission list one line
above the call. Settings and AuditLog now declare theirs — audit_log shipped
the same ungated-menu bug before the filter existed. Entries declaring nothing
are unaffected, and a caller passing no permissions fails closed.
- Feature Flags shipped the third instance of the ungated-menu bug: its
  sidebar entry declared neither roles nor permissions while its view router
  guards every route, so any signed-in non-admin saw the entry and got a 403
  on click. Now declares feature_flags.view, like Settings and Audit Log.
- The unsaved-changes guard on Edit user only hooked `beforeunload`, which
  never fires for an Inertia visit — and "Back to Users", "Cancel", "Manage
  permissions" and every sidebar link are Inertia visits. So every ordinary
  way of leaving the page silently discarded the edit, which is the exact
  accident the merged dirty state exists to prevent. Adds a router.on('before')
  confirm, with a savingRef so the page's own post-save reload isn't prompted.
- The file-storage empty state lost its `pagination.total === 0` guard, so
  deleting the last file on page 2 — or following a stale ?page=3 link —
  rendered "No files yet / Upload your first file" over a full bucket.
- Dashboard tile targets could adopt another module's menu entry: the prefix
  fallback did not check whether a different module owned a longer prefix, so
  a module at /admin would claim background_tasks' /admin/background-tasks.
- bulk invite now commits after bus.publish. UserInvited handlers run inline
  on the same request-scoped session, so a later address's rollback silently
  voided the previous invite's handler side effects — contradicting the
  "nothing durable is pending" comment the rollback relies on.
- KeyField's 150ms blur timer was never cleared, so navigating away inside
  that window set state on an unmounted component.
Applied by /code-review --fix:
- The env-provenance badge was inverted. `env_set` tested whether the SM_*
  *label* was in os.environ, but no settings class on that screen reads env
  vars: all declare SettingsConfigDict(extra="ignore") with no env_prefix and
  are built from defaults + DB hydration. A stale SM_USERS_SMTP_HOST therefore
  badged the field "From environment" while the live value was the pydantic
  default — exactly backwards from the "why isn't my setting taking effect"
  question the feature exists to answer. Now derived from the class's own
  env_prefix, so it is honest today and starts working by itself for any class
  that declares one. The old test only asserted the flag flipped, never that
  the value changed, so it passed on a false claim; replaced.
- The bulk-invite copy link was built from request.base_url while both mailers
  use the configured base_url, so behind a proxy without SM_TRUSTED_PROXY the
  link was the internal origin — and it is surfaced only when mail could not
  be delivered, i.e. exactly when the admin must pass it on by hand.
- The file search box adopted the server value unconditionally: typing
  "report" fires the debounce at "repo", and the reply landing mid-word
  rewrote the input and swallowed "rt".
- VIEW_BROWSE lacked a trailing slash, so every filter change and page click
  paid a 307 before the real request.
- The bulk-invite body was unbounded: MAX_ADDRESSES caps tokens minted, but
  validation and the response array are per submitted address. Capped at 1000,
  far above MAX_ADDRESSES so the over-the-limit outcomes stay visible.

Fixed here, having been reported and skipped: both health checks this branch
adds are probe=False, and they were the only checks in the tree — so
probe_checks was always empty, the new per-module health dot could never
render, and /health/ready answered "healthy" from an empty check set, a green
light proving nothing. test_every_module_entry_reports_health passed vacuously
on "". Adds the host's own database check (SELECT 1, probe-safe), which is the
one dependency no request can do without and the right thing for a readiness
probe to ask.

Also corrected a stale claim: a docstring and test name said the audit log
"still renders without the users module installed", which its module-scope
`from users.models import User` and declared dependency make unreachable. The
None case is a users module that ships no audit link, not an absent one.
Two bugs found by driving the app in a browser. Neither is reachable from
unit tests as written — both need a real login and a rendered page.

BUG-001 (P1) — every admin screen showed raw translation keys
("dashboard.home.title", "Total Users" → "dashboard.home.stats.total_users")
from the moment of login until the user happened to hard-refresh.

Two causes, both fixed:
  * host/client_app/i18n.ts applied an incoming catalog only when the *locale*
    changed. Audience-scoped catalogs mean the same locale carries a different
    payload after login — the anonymous snapshot withholds admin-only modules
    — so the catalog arrived and was dropped. A non-null `messages` IS the
    server's "you need this" signal; it sends null when the cache is good.
  * react-i18next binds to no store events by default, so the subsequent
    addResourceBundle updated the store without re-rendering anything that had
    already mounted. Set `react.bindI18nStore: 'added'`.

Pre-existing on main (i18n.ts is untouched by this branch) but latent until
audience scoping landed, and it defeated every screen this branch adds.

BUG-002 (P1) — audit entity ids never linked. `snapshot_changes` records
`type(obj).__name__`, but the AuditLink registrations were keyed by
`__tablename__` ("users_user"), so every lookup missed. The failure was
invisible: `entity_link` falls back to showing `entity_type` as the label, so
a User row rendered "User <id>" and looked right while silently never
linking — only the missing anchor gave it away. Registrations now use the
model class name, and the docstring says why. Adds a regression test that
asserts registered keys are class names and that a real audit payload comes
back with entity URLs populated.

Verified in-browser after each fix: dashboard renders "Dashboard" immediately
post-login, and audit rows link both actor and entity to their records.
Round 2 reviewed the QA fixes. No high-severity correctness bugs; the
audit-link class-name fix, menu permission filter, settings authz, i18n
audience fix and bulk-invite partial success all verified sound.

- The audit_links module docstring still said entries store "the table name"
  and pointed at app.state.audit_links — contradicting the entity_type
  docstring rewritten in the same commit, and describing exactly the
  misreading that caused the bug it was fixing.
- USER_ENTITY_TYPE re-hardcoded "User" while every module registration had
  moved to Model.__name__; a rename would have silently unlinked every actor
  cell, since unmatched lookups degrade to plain text rather than erroring.
  Now derived from the class, so both ends move together.
- bulk_invite's post-publish commit sat outside the per-address try, so an
  event handler writing something the DB rejects would 500 the whole request
  and discard every result already collected — the total-failure outcome this
  endpoint exists to prevent, and the invites are durable by then anyway.
- host/client_app/i18n.ts kept `activeLocale` as write-only dead state after
  the guard was removed, and its docstring still described the removed check.
- _module_settings docstrings asserted "every settings class on this screen
  declares no env_prefix". False — the host's Settings declares SM_ and the
  module scaffold ships SM_<PACKAGE>_, both of which legitimately report
  "From environment". Only the comments were wrong; the code handles both.

Known and accepted, documented rather than papered over: with both module
health checks now probe=False (they reach SMTP/S3 and must not run on a
probe timer), the host database check is the only probe-safe one in a default
install, and it is owned by the host rather than a module. So the per-module
health dot has no data source until a module registers a cheap check of its
own, and renders nothing — which is the honest "nothing is watching this"
state. Restoring per-module health would mean either polling third parties on
a timer (the hazard being avoided) or caching on-demand results, which is a
design change beyond this branch.
The existing parametrised check could not catch the raw-key bug this branch
fixes: it does `page.goto(path)` after logging in, and a full page load
re-bootstraps i18n from scratch — the path that always worked. The broken one
is the client-side navigation login itself performs, where the audience
changes while the locale does not.

This asserts on whatever the app navigated to after login, touching nothing.
Verified it fails without the fix (heading renders 'dashboard.home.title')
and passes with it.
Both are local verification audit trails (server logs, screenshots, reports),
not source. .qa/ was already ignored; .verify/ was not, so a /vf run left an
untracked server log in the tree.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 11, 2026

Copy link
Copy Markdown

Deploying simple-module-python with  Cloudflare Pages  Cloudflare Pages

Latest commit: 081243d
Status: ✅  Deploy successful!
Preview URL: https://9c57842a.simple-module-python.pages.dev
Branch Preview URL: https://worktree-wireframe-rethinks.simple-module-python.pages.dev

View logs

Found while photographing the bulk-invite screen for the wireframe gallery:
an already-registered address rendered in red with nothing beside it.

`str(UserAlreadyExists())` is the empty string — fastapi-users carries the
meaning in the exception type, not its message — and the endpoint passed that
straight into `detail`. So the single most common failure was the one case
that never explained itself, on the screen built to explain failures.

Map it to "Already registered", and fall back to the exception class name for
anything else whose `str()` is empty: a class name is a poor message but still
a lead, where a blank cell is nothing at all.

The existing test asserted only `status == "failed"`, which is what let this
ship. The new test asserts on the reason, and was confirmed to fail without
the fix.

Claude-Session: https://claude.ai/code/session_01QpbArQUekPoF8Svyge6LmB
@antosubash
antosubash merged commit 41b98fd into main Aug 13, 2026
13 checks passed
antosubash added a commit that referenced this pull request Aug 19, 2026
…ename__

`AuditLink.entity_type` matches `AuditEntry.entity_type`, which the listeners
write as `type(obj).__name__` — but the `ModuleBase.register_audit_links`
docstring said it was the `__tablename__` and used `entity_type="users_user"`
as its worked example. That is the docstring a module author actually reads,
since the hook is the thing they override.

Following it produces a link that never matches. Nothing errors: an unmatched
lookup falls back to rendering `entity_type` as the label, so the audit row
still shows text and the entry looks fine while silently never becoming a
link. `AuditLink.entity_type`'s own docstring already said all of this
correctly — the two directly contradicted each other, and the more prominent
one was wrong.

Every bundled module already registers `Model.__name__`, and background_tasks
and settings each carry an inline "Class name, not __tablename__" comment —
authors hit this and patched around it locally rather than the doc that
misled them.

Docstring only, no behaviour change. Pre-existing on main (last touched in
#261), surfaced by a review of the branch against v0.0.30.

Claude-Session: https://claude.ai/code/session_01NYVhsyUpmhhRbXtZk7EjAE
antosubash added a commit that referenced this pull request Aug 20, 2026
* feat(ui): empty states for users, tasks, files and audit log (2e)

The last unbuilt frame on the screen-wireframe board. Its other captions
were shipped by #261; 2e was labelled "proposals, not built" and stayed
that way.

Four list screens rendered the same shrug when they had no rows, and three
of them could not tell the two reasons apart. "No users yet" is wrong when
the workspace is full and the filter is merely too narrow, and "no audit
entries" is worse than wrong — it reads as evidence that nothing happened,
which is the last thing to tell someone working an incident. So the
filtered case now names the filters doing the excluding and offers a way
out of them, and the genuinely-empty case offers the one thing worth doing
next.

Adds a shared EmptyState wrapping the existing ui/empty primitives, so the
four screens read as one treatment rather than four hand-rolled ones.
file_storage already had the filtered/empty split and moves onto it.

Per screen:

* Users — a one-member workspace renders a perfectly valid table containing
  your own row and never suggests the obvious next step. A prompt now sits
  above that table rather than replacing it: the row is still the truthful
  answer to "who has access", and hiding it would cost the admin their only
  route to their own record.

* Background tasks — "No task has run yet" is reassuring and wrong when the
  real cause is that no worker was ever started and the queue is filling
  unattended. The view now polls the fleet to tell those apart, plus a
  third case for an unreachable broker. Polling costs an inspect timeout,
  so it is gated to an unfiltered list that came back empty — the one
  moment the answer changes what the screen says. A filtered-empty list
  says nothing about the fleet and never pays for it.

* Audit log — the empty panel echoes the applied filters, so the reader can
  see it is their query and not the record that is empty.

* Files — promotes the existing dropzone into the empty table as the call
  to action, and adds Clear filters to the no-match case.

Verified in the browser against the wireframe: solo prompt, filtered-empty
users, empty tasks, and both audit-log states.

Claude-Session: https://claude.ai/code/session_01NYVhsyUpmhhRbXtZk7EjAE

* feat(ui): section 3 wireframe frames — fix dead public auth links, add confirms and health signals

The deck's turn 3 added frames 3a–3h, covering the eight screens that had no
wireframe. 3h carries the only two items the deck labels "Rethink (not built)";
the rest are friction notes on shipped screens. Verified all eight against the
code and implemented the seven gaps that were real.

3a — every public entry point 404'd. Landing's "Get started"/"Sign up" and all
four PublicLayout buttons pointed at /auth/login, which is the JSON API prefix
(/api/users/auth/login, POST) and has no page behind it. The paths now live in
one place (packages/ui/src/lib/auth-routes.ts) and a test fails on any href
back into /auth/. "Sign up" is gated on a new `signup` shared prop from the
users module, because /users/register 404s when allow_signup is off — linking
it unconditionally would have swapped one dead end for another.

3h — user delete now asks for the address to be typed; it was one click on a
row that looks like every other row. Retry confirm names the task and shows the
args/kwargs it is about to re-enqueue: a blind retry of a bad payload just
fails the same way. List rows carry the payload for that one consumer.

3c — correlation_id was dead data: stored, selected, serialised into the page
props, never rendered or queryable, so one request that touched four entities
read as four unrelated rows. Adds a per-row pivot and a banner.

3f — a queue of twelve with a live worker and one with no worker render
identically. The check is a lazy client fetch, not part of the render: celery's
inspect waits out its full timeout even when healthy, so paying it on every
page load would slow the normal path to answer a question it isn't asking.

3g — the workers snapshot only refreshes on demand, so a page left open showed
a healthy fleet indefinitely. Ticks the reading's age and flags it stale.

3d — flag toggles wrote immediately with no confirm. The confirm restates the
scope, which is the part you cannot see from the switch: the same control means
"this tenant" or "everyone" depending on a selector further up the page.

Not done, and deliberately: 3b (move uploads out of the table body, bulk
select) and 3e (unify branding's three independent saves behind one dirty
state) are page reshapes rather than fixes, and want their own review.

Verified in a browser: both signup branches, the correlation pivot and its
round trip, both confirm dialogs cancel-safe and write on confirm, the
worker-health banner against a stubbed fleet, and the stale badge across the
60s threshold. make lint / make test (1926 py, 64 js) / make doctor all green.

Claude-Session: https://claude.ai/code/session_01NYVhsyUpmhhRbXtZk7EjAE

* feat(ui): app topbar from the hi-fi deck — breadcrumb, ⌘K palette, and a nav active state that works

`Hi-Fi Pages.dc.html` renders 26 screens on one shared shell. The token layer
it is drawn from is already the app's own — globals.css is literally themed
"Emerald (SimpleModulePython HiFi)", same Sora / DM Sans / JetBrains Mono, same
radius — and the per-screen content matches the real pages, because the deck
was built by reading them. The delta is the shell: every app screen in the deck
sits under a 56px topbar the app does not have.

Adds it, carrying what the deck puts there:

- Breadcrumb. `breadcrumb.tsx` had been vendored but never used. The section
  comes from the menu registry and the leaf from the page's own PageShell
  title, reported up through context — so all 18 app screens get a correct
  crumb with no per-page wiring and no route table to drift. The dynamic cases
  come out right for free: the user editor renders "Users / admin@example.com",
  which is exactly the crumb the deck specifies.
- ⌘K palette over the same menu entries the sidebar renders, so it inherits
  their permission filtering and cannot offer a destination that would 403.
  Account actions are included, which is the keyboard route to log out.
- Locale moved out of the sidebar header slot into the topbar beside search,
  where the deck has it; it read as part of the wordmark under the logo. The
  mobile bar keeps its own copy, since the topbar is desktop-only.

Two defects found while wiring it up:

- No sidebar item has ever been highlighted, on any page. `usePage().url` is
  absolute (`http://host/users/admin/add`) while menu urls are paths, so
  `currentUrl.startsWith(item.url)` was always false — the active-state design
  existed and the condition selecting it could not fire. Both the sidebar and
  the new breadcrumb now go through `isUnder()`, which compares paths
  segment-wise so `/users` cannot claim `/users-archive`.
- `LocaleSwitcher` carried its old sidebar chrome (`px-3 py-2 border-b
  border-white/[0.06]`) on its own root, so it painted a stray rule wherever
  else it was placed — including the public nav it already sat in. Placement
  now belongs to the caller.

Not done: the deck's nav is illustrative, not read from the registry — it lists
Permissions as a top-level entry (the module registers no menu item by design;
its pages are sub-pages with no index) and moves Doctor off the admin sidebar.
Following it literally would add a nav entry leading nowhere, so the registry
stays the source of truth.

16 new unit tests. Verified at 1440px: crumbs on index, sub-page, dynamic and
section-less routes; active nav; ⌘K filter-and-navigate; public nav unchanged.
make lint / make test (1926 py, 80 js) / make doctor all green.

Claude-Session: https://claude.ai/code/session_01NYVhsyUpmhhRbXtZk7EjAE

* docs(core): register_audit_links example keyed entity_type off __tablename__

`AuditLink.entity_type` matches `AuditEntry.entity_type`, which the listeners
write as `type(obj).__name__` — but the `ModuleBase.register_audit_links`
docstring said it was the `__tablename__` and used `entity_type="users_user"`
as its worked example. That is the docstring a module author actually reads,
since the hook is the thing they override.

Following it produces a link that never matches. Nothing errors: an unmatched
lookup falls back to rendering `entity_type` as the label, so the audit row
still shows text and the entry looks fine while silently never becoming a
link. `AuditLink.entity_type`'s own docstring already said all of this
correctly — the two directly contradicted each other, and the more prominent
one was wrong.

Every bundled module already registers `Model.__name__`, and background_tasks
and settings each carry an inline "Class name, not __tablename__" comment —
authors hit this and patched around it locally rather than the doc that
misled them.

Docstring only, no behaviour change. Pre-existing on main (last touched in
#261), surfaced by a review of the branch against v0.0.30.

Claude-Session: https://claude.ai/code/session_01NYVhsyUpmhhRbXtZk7EjAE

* fix(ui): give orphaned pages a section, and size the modules pane to the real chrome

Swept all 30 view routes at 1440px and audited each for topbar, breadcrumb,
active nav, console errors and horizontal overflow. Three pages were wrong.

The two permissions screens had no section at all: their paths live under
/permissions/ but they are reached from — and belong to — Users, so nothing
highlighted in the sidebar and the crumb read "Edit permissions for admin"
with no parent. A page can now declare the section it belongs to, resolved
against the *visible* menu, so it never offers a parent the viewer would be
refused at. Both now read "Users / …" with Users lit.

Profile is deliberately left without one: it is reachable by anyone with
users.self.profile, who may well not have the Users entry in their menu.

ModulesEdit sized its master/detail pane with h-[calc(100vh-64px)], a magic
number that predates the topbar and was already approximate. Both bars are
h-14 and mutually exclusive — topbar on lg, mobile bar below it — so 56px is
now exact at every width.

Also fixes a footgun found by its own test: menu urls are inconsistent about
trailing slashes (/users/admin but /file-storage/), so matching a declared
section by string equality would have silently failed for half of them.
`samePath` normalises both sides.

Left as-is, deliberately: /settings/ renders no h1 because it is a full-height
master/detail editor with its own rail, and its crumb still resolves from the
menu; the 404 page keeps its standalone layout, matching how the deck frames
errors.

8 new unit tests. make lint / make test (1926 py, 88 js) green.

Claude-Session: https://claude.ai/code/session_01NYVhsyUpmhhRbXtZk7EjAE

* fix: address code review findings (round 1, pass 1)

Two correctness bugs — the audit-log correlation banner's "show all"
navigated with the unapplied filter draft, and an out-of-range tasks page
miscounted the window total as 0 and rendered the never-ran empty state
(now clamps to the last page; regression test added). Plus cleanups:
sidebar and topbar share one active-section algorithm, the ⌘K palette
renders account items through the same loop as nav groups, the audit
empty-state derives labels from FilterBar's exported ACTIONS, retry
dialogs memoize their JSON preview, "/users/admin" became a shared
constant, and the modules pane reads the chrome height from a layout-
published --app-chrome-h instead of a hardcoded 56px.

Claude-Session: https://claude.ai/code/session_01NYVhsyUpmhhRbXtZk7EjAE

* fix: address code review findings (round 1, pass 2)

Two behavior fixes — the tasks empty state keyed "filtered vs never ran"
off the live search input instead of the server-confirmed filter, so it
could flash the wrong copy during the debounce window, and the audit-log
correlation banner now only renders when the correlation actually
matched rows. The rest is deduplication: a shared TableEmptyRow replaces
three hand-rolled filtered/empty table wrappers, InlineBanner replaces
two bespoke banner shells, diagnoseWorkerHealth() is the single
broker/worker predicate for the banner and the empty state, the landing
CTA computes its signup-gated href/label once, USERS_ADMIN_PATH is now
covered by the auth-route drift test, the correlation banner title
pluralizes via CLDR suffixes, and both chrome bars size themselves off
--app-chrome-h instead of independent h-14 classes.

Claude-Session: https://claude.ai/code/session_01NYVhsyUpmhhRbXtZk7EjAE

* fix: address code review findings (round 1, pass 3)

Deleting the last file on a trailing page no longer strands the file
list on a blank table — the view clamps past-the-end pages the same way
background_tasks now does. The settings REST endpoint reports
source/db_override correctly by passing the DB-overrides map the Inertia
view already used, filename search escapes LIKE wildcards so a literal
% or _ matches itself, test_connection runs a module's health checks
concurrently, the upload queue uses a bounded worker pool instead of
strictly serial uploads, and the users module's own auth pages consume
LOGIN_PATH/REGISTER_PATH instead of re-hardcoding the routes.

Claude-Session: https://claude.ai/code/session_01NYVhsyUpmhhRbXtZk7EjAE

* fix: address code review findings (round 1, pass 4) + translate the new empty states

Review fixes: ?page=0 no longer produces a negative SQL OFFSET (ge=1,
matching the sibling endpoint), the landing page's bottom CTA is
auth-aware like the hero, clear-filters can no longer race the search
debounce into reapplying a stale status, ⌘K account items merge into a
same-named nav group instead of overwriting it, and the settings
overrides helper moved to _module_settings as a shared public function
instead of a private cross-file import.

i18n: WorkerHealthBanner, TasksEmpty and UsersEmpty now render through
useT() like their audit_log sibling — new background_tasks
worker_health/tasks_empty keys, and a bootstrapped users locale
(locales/en.json + locale_dirs()) scoped to the empty-state copy.
file_storage needed nothing; it was already translated.

Claude-Session: https://claude.ai/code/session_01NYVhsyUpmhhRbXtZk7EjAE

* fix: address code review findings (round 1, pass 5)

Task-name search escapes LIKE metacharacters at both call sites — task
names are full of underscores, so an unescaped "_" made every search a
single-character wildcard (regression test added, mirroring the
file_storage fix). The ⌘K palette memoizes its group bucketing instead
of rebuilding it on every topbar render, and the breadcrumb collapse
rule is documented as the naming convention it is.

Claude-Session: https://claude.ai/code/session_01NYVhsyUpmhhRbXtZk7EjAE

* fix(qa): BUG-001, BUG-002 — clamp low pages in the views, escape the users search

QA round 1 caught two of the review passes' own hardening choices biting
back in the browser. ?page=0/-1 no longer 422s on the background-tasks
and file-storage views — the endpoints clamp like their past-the-end
paths already did (the JSON admin APIs keep strict validation for API
callers). The users admin search escapes LIKE metacharacters through the
same _contains_pattern shape as its two siblings, for email and
full_name both, and list_users clamps page/per_page at the service so
the raw-param admin view can't reach SQL with a negative offset.
Regression tests cover all three: page=0/-1 on both views, and a
literal-underscore search matching only its literal counterpart.

Claude-Session: https://claude.ai/code/session_01NYVhsyUpmhhRbXtZk7EjAE

* fix: address round-2 review findings — clamp parity, shared LIKE helper

The users admin listing gets the same past-the-end clamp-and-refetch its
two siblings received, with a regression test. like_contains_pattern and
LIKE_ESCAPE_CHAR move into simple_module_db so three modules share one
escaping implementation instead of three copies (one of them unnamed and
un-greppable). The file browse endpoint builds its filter set once for
both the initial query and the clamp refetch, the sidebar hands its
already-resolved active section to the topbar instead of both walking
the menu, and the tasks view runs its status counts and broker poll
concurrently on the one path that polls.

Claude-Session: https://claude.ai/code/session_01NYVhsyUpmhhRbXtZk7EjAE

* fix: pagination prop echoes clamped values; clear-filters guard arms only when needed

?page=0 rendered page-1 rows labelled "Page 0" and an oversized
per_page skewed totalPages — the users admin view now clamps with the
same bounds the service applies and echoes those values in the
pagination prop (regression test added). The tasks clear-filters guard
arms only when resetting the search box will actually fire the debounce
effect, so it can no longer swallow the first keystroke typed after
clearing an already-empty search.

Claude-Session: https://claude.ai/code/session_01NYVhsyUpmhhRbXtZk7EjAE

* fix: address round-2 pass-3 findings — prefix-filter escaping and three surface nits

The content-type family filter no longer widens under a crafted query —
like_prefix_pattern() joins the shared escaping helpers and the filter
uses it, with a regression test. The merged Add People form gets back
the password-policy hint the deleted Create page used to show, the
legacy /users/admin/{create,invite} aliases are covered by an
anonymous-access test again, and ExecutionRow calls the module's
formatTs() instead of reimplementing it inline.

Claude-Session: https://claude.ai/code/session_01NYVhsyUpmhhRbXtZk7EjAE

* fix: address round-2 pass-4 findings — audit_log clamp, honest worker banner

audit_log was the fourth listing with the past-the-end pagination bug —
a stale ?page= or shrunk correlation rendered the banner above an empty
table; its view now clamps and refetches like its three siblings. The
worker health banner only renders on the unfiltered view, because its
backlog derives from filter-scoped counts and a narrowed search could
mask a genuinely stuck fleet. Cleanups: AppTopbar's dead fallback branch
removed (activeMenuItem is required now), the audit filter interface is
declared once in FilterBar, stable NO_ITEMS references keep the palette
memo effective, CLEARED hoisted to module scope, a dead per_page guard
and a redundant list() wrapper dropped.

Claude-Session: https://claude.ai/code/session_01NYVhsyUpmhhRbXtZk7EjAE

* fix: close the last two escaping/bounds gaps from the final review pass

The permissions grant-search was the fourth unescaped LIKE — hidden from
earlier greps because it builds its pattern on a separate line — and now
goes through the shared like_contains_pattern helper, with its own test
file. The file-storage JSON API gains the same strict page/per_page
bounds as its background_tasks sibling, so a non-positive page can never
reach the database as a negative OFFSET (the Inertia view keeps its
clamp; API callers get the 422 contract).

Claude-Session: https://claude.ai/code/session_01NYVhsyUpmhhRbXtZk7EjAE

* test(e2e): shell smoke spec — breadcrumb, palette, clamping, literal search

Claude-Session: https://claude.ai/code/session_01NYVhsyUpmhhRbXtZk7EjAE

* test(e2e): audit intpk spec tolerates SQLite id reuse across the suite

Another test's created-then-deleted setting can leave an older audit
entry with the same reused integer id, so the spec asserts at least one
matching entry instead of exactly one — the guarded regression is only
that entity_id resolves non-empty.

Claude-Session: https://claude.ai/code/session_01NYVhsyUpmhhRbXtZk7EjAE
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant