Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e19011c
feat(ui): empty states for users, tasks, files and audit log (2e)
antosubash Aug 17, 2026
451ce79
feat(ui): section 3 wireframe frames — fix dead public auth links, ad…
antosubash Aug 19, 2026
28b0f08
feat(ui): app topbar from the hi-fi deck — breadcrumb, ⌘K palette, an…
antosubash Aug 19, 2026
eedee6a
docs(core): register_audit_links example keyed entity_type off __tabl…
antosubash Aug 19, 2026
d0a5054
fix(ui): give orphaned pages a section, and size the modules pane to …
antosubash Aug 19, 2026
953ea2d
fix: address code review findings (round 1, pass 1)
antosubash Aug 20, 2026
caae04c
fix: address code review findings (round 1, pass 2)
antosubash Aug 20, 2026
ef1e967
fix: address code review findings (round 1, pass 3)
antosubash Aug 20, 2026
25f84ed
fix: address code review findings (round 1, pass 4) + translate the n…
antosubash Aug 20, 2026
eb245ac
fix: address code review findings (round 1, pass 5)
antosubash Aug 20, 2026
bec46e4
fix(qa): BUG-001, BUG-002 — clamp low pages in the views, escape the …
antosubash Aug 20, 2026
fab39fc
fix: address round-2 review findings — clamp parity, shared LIKE helper
antosubash Aug 20, 2026
fc4dab2
fix: pagination prop echoes clamped values; clear-filters guard arms …
antosubash Aug 20, 2026
d869191
fix: address round-2 pass-3 findings — prefix-filter escaping and thr…
antosubash Aug 20, 2026
cc5981d
fix: address round-2 pass-4 findings — audit_log clamp, honest worker…
antosubash Aug 20, 2026
23b1a4f
fix: close the last two escaping/bounds gaps from the final review pass
antosubash Aug 20, 2026
ac54765
Merge origin/main (v0.0.31) into wireframe-ui
antosubash Aug 20, 2026
68ab539
test(e2e): shell smoke spec — breadcrumb, palette, clamping, literal …
antosubash Aug 20, 2026
f22e937
test(e2e): audit intpk spec tolerates SQLite id reuse across the suite
antosubash Aug 20, 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
14 changes: 9 additions & 5 deletions framework/core/simple_module_core/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,20 +185,24 @@ def register_design_packs(self, registry):
def register_audit_links(self, registry: AuditLinkRegistry) -> None:
"""Declare where this module's audited records can be viewed.

An audit entry stores a table name and a primary key, which tells the
reader what changed but gives them no way to open it. Override this
hook to make your module's rows reachable from the audit log::
An audit entry stores a model class name and a primary key, which
tells the reader what changed but gives them no way to open it.
Override this hook to make your module's rows reachable from the audit
log::

def register_audit_links(self, registry):
registry.register(
AuditLink(
entity_type="users_user",
entity_type=User.__name__,
url_template="/admin/users/{id}/edit",
label="User",
)
)

``entity_type`` is the ``__tablename__`` the rows are audited under.
``entity_type`` is the **model class name** the rows are audited under
(``"User"``), not the ``__tablename__`` (``"users_user"``):
``snapshot_changes`` records ``type(obj).__name__``, so a table-name
key silently never matches.
Registering only supplies the URL — the target route still enforces
its own permissions, so linking never widens access. Called once at
boot, in dependency order.
Expand Down
4 changes: 4 additions & 0 deletions framework/db/simple_module_db/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@
)
from simple_module_db.mixins import AuditMixin, MultiTenantMixin, SoftDeleteMixin, VersionedMixin
from simple_module_db.provider import DatabaseProvider, detect_provider
from simple_module_db.search import LIKE_ESCAPE_CHAR, like_contains_pattern, like_prefix_pattern
from simple_module_db.session import DatabaseState, init_db
from simple_module_db.transaction import CommitBeforeResponseMiddleware, finalize_session

__all__ = [
"LIKE_ESCAPE_CHAR",
"AuditMixin",
"AuditRecord",
"CommitBeforeResponseMiddleware",
Expand All @@ -32,6 +34,8 @@
"finalize_session",
"get_db",
"init_db",
"like_contains_pattern",
"like_prefix_pattern",
"make_include_object",
"make_process_revision_directives",
"render_item",
Expand Down
32 changes: 32 additions & 0 deletions framework/db/simple_module_db/search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Shared search-pattern helpers for LIKE/ILIKE filters."""

from __future__ import annotations

LIKE_ESCAPE_CHAR = "\\"


def _escape_like_term(term: str) -> str:
return term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")


def like_contains_pattern(term: str) -> str:
"""Contains-match LIKE pattern with metacharacters escaped, so a literal
``%`` or ``_`` in a search term matches as text, not as a wildcard.

Pair with ``ilike(pattern, escape=LIKE_ESCAPE_CHAR)`` (or ``like(...)``)
so the escape character actually takes effect.
"""
return f"%{_escape_like_term(term)}%"


def like_prefix_pattern(term: str) -> str:
"""Prefix-match LIKE pattern with metacharacters escaped, so a literal
``%`` or ``_`` in the prefix matches as text, not as a wildcard.

Without this, a caller-supplied prefix containing ``%`` (e.g. a
``content_type`` family filter built from a query param) widens the match
instead of narrowing it. Pair with ``ilike(pattern,
escape=LIKE_ESCAPE_CHAR)`` (or ``like(...)``) so the escape character
actually takes effect.
"""
return f"{_escape_like_term(term)}%"
23 changes: 19 additions & 4 deletions host/client_app/pages/Landing.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { Head } from '@inertiajs/react';
import { Head, usePage } from '@inertiajs/react';
import { keys, useT } from '@simple-module-py/i18n';
import { Button } from '@simple-module-py/ui/components/ui/button';
import { Card, CardContent } from '@simple-module-py/ui/components/ui/card';
import { PublicLayout } from '@simple-module-py/ui/layouts/PublicLayout';
import { authCta } from '@simple-module-py/ui/lib/auth-routes';
import type { SharedProps } from '@simple-module-py/ui/types';
import {
BookOpen,
Copy,
Expand Down Expand Up @@ -31,6 +33,15 @@ $ make dev

function Landing() {
const { t } = useT();
const { auth, signup } = usePage<{ props: SharedProps }>().props as unknown as SharedProps;

// Every public CTA used to point at /auth/login, which is the JSON API prefix
// and has no page — so both "Get started" and "Sign up" 404'd. Send a signed-in
// visitor onward, an anonymous one to signup when it is open, and to sign-in
// otherwise; a closed instance never advertises an account it won't create.
const signupOpen = signup?.allowed ?? false;
const cta = authCta(signupOpen);
const primaryHref = auth?.isAuthenticated ? '/dashboard/' : cta.href;

const features = [
{
Expand Down Expand Up @@ -94,7 +105,7 @@ function Landing() {
</p>
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row sm:gap-3">
<Button asChild size="lg" className="w-full gap-2 sm:w-auto">
<a href="/auth/login">
<a href={primaryHref}>
<Rocket className="h-4 w-4" aria-hidden="true" />
{t(keys.host.landing.cta_get_started)}
</a>
Expand Down Expand Up @@ -223,7 +234,11 @@ function Landing() {
Ready to ship modules?
</h3>
<p className="mt-1 text-sm text-white/85">
Sign up for the admin UI, or hack on the framework directly.
{auth?.isAuthenticated
? 'Head to the dashboard, or hack on the framework directly.'
: signupOpen
? 'Sign up for the admin UI, or hack on the framework directly.'
: 'Sign in to the admin UI, or hack on the framework directly.'}
</p>
</div>
<div className="flex shrink-0 gap-2">
Expand All @@ -232,7 +247,7 @@ function Landing() {
variant="secondary"
className="bg-white text-primary-700 hover:bg-white/90"
>
<a href="/auth/login">Sign up</a>
<a href={primaryHref}>{auth?.isAuthenticated ? 'Open Dashboard' : cta.label}</a>
</Button>
<Button
asChild
Expand Down
83 changes: 83 additions & 0 deletions host/tests/test_public_auth_links.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""No page may link a visitor to ``/auth/...``.

``/auth`` is the JSON **API** prefix — ``/api/users/auth/login`` is a POST that
returns 204. The sign-in *page* lives at ``/users/login``, under the users
module's view prefix. The two look interchangeable in a template and are not:
the landing page's "Get started" and "Sign up" buttons, and all four of
``PublicLayout``'s, pointed at ``/auth/login`` and every one of them 404'd —
which is every public entry point into the app.

The paths are one-way: nothing outside the users module should be spelling
either of them inline, so this test also keeps them funnelled through
``lib/auth-routes.ts``.
"""

from __future__ import annotations

import re
from pathlib import Path

import pytest

_ROOT = Path(__file__).resolve().parents[2]
_SOURCE_DIRS = ("host/client_app", "packages/ui/src", "modules")

# href/to="/auth/anything" — an href, not an API call inside a fetch().
_AUTH_HREF = re.compile(r"""(?:href|to)=\{?["'`]/auth/""")

_SKIP_DIRS = {"node_modules", "dist", "build", ".vite", "__pycache__"}


def _tsx_sources() -> list[Path]:
files: list[Path] = []
for rel in _SOURCE_DIRS:
base = _ROOT / rel
if not base.exists():
continue
for path in base.rglob("*.tsx"):
if _SKIP_DIRS.isdisjoint(path.parts):
files.append(path)
return files


def test_sources_exist_to_scan() -> None:
"""Guard the guard: a bad glob would make every assertion below vacuous."""
assert len(_tsx_sources()) > 20


def test_no_page_links_to_the_api_auth_prefix() -> None:
offenders = [
f"{path.relative_to(_ROOT)}:{n}"
for path in _tsx_sources()
for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1)
if _AUTH_HREF.search(line)
]
assert not offenders, (
"These link a visitor to the JSON API prefix, which has no page behind "
"it and renders 404. Use LOGIN_PATH / REGISTER_PATH from "
f"packages/ui/src/lib/auth-routes.ts instead: {offenders}"
)


def _declared_path(constant: str) -> str:
source = (_ROOT / "packages/ui/src/lib/auth-routes.ts").read_text(encoding="utf-8")
match = re.search(rf"export const {constant} = '([^']+)';", source)
assert match, f"{constant} is not declared in lib/auth-routes.ts"
return match.group(1)


@pytest.mark.parametrize(
("constant", "route"),
[("LOGIN_PATH", "login"), ("REGISTER_PATH", "register"), ("USERS_ADMIN_PATH", "admin")],
)
def test_auth_route_constants_track_the_users_view_prefix(constant: str, route: str) -> None:
"""Derived, not hardcoded: if the module's view prefix moves, this fails.

Hardcoding ``/users/login`` here would let the prefix change out from under
the constants and still pass — which is precisely the drift being guarded.
"""
from simple_module_core.discovery import discover_modules

prefixes = {m.meta.name.lower(): m.meta.view_prefix for m in discover_modules()}
assert "users" in prefixes, "users module not installed; cannot verify auth paths"
assert _declared_path(constant) == f"{prefixes['users']}/{route}"
2 changes: 2 additions & 0 deletions modules/audit_log/audit_log/endpoints/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ async def list_audit_entries(
entity_id: str | None = Query(default=None),
action: str | None = Query(default=None),
user_id: str | None = Query(default=None),
correlation_id: str | None = Query(default=None),
from_date: datetime | None = Query(default=None),
to_date: datetime | None = Query(default=None),
page: int = Query(default=1, ge=1),
Expand All @@ -33,6 +34,7 @@ async def list_audit_entries(
entity_id=entity_id,
action=action,
user_id=user_id,
correlation_id=correlation_id,
from_date=from_date,
to_date=to_date,
page=page,
Expand Down
33 changes: 23 additions & 10 deletions modules/audit_log/audit_log/endpoints/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import math
from datetime import datetime

from fastapi import APIRouter, Depends, Query, Request
Expand Down Expand Up @@ -47,6 +48,7 @@ async def browse(
entity_id: str | None = Query(default=None),
action: str | None = Query(default=None),
user_id: str | None = Query(default=None),
correlation_id: str | None = Query(default=None),
from_date: datetime | None = Query(default=None),
to_date: datetime | None = Query(default=None),
page: str | None = Query(default=None),
Expand All @@ -55,16 +57,26 @@ async def browse(
# Sanitize pagination — never raise a validation error for bad values.
page_int = max(_safe_int(page, 1), 1)
page_size_int = max(1, min(_safe_int(page_size, DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE))
result = await service.list_entries(
entity_type=entity_type,
entity_id=entity_id,
action=action,
user_id=user_id,
from_date=from_date,
to_date=to_date,
page=page_int,
page_size=page_size_int,
)
list_kwargs = {
"entity_type": entity_type,
"entity_id": entity_id,
"action": action,
"user_id": user_id,
"correlation_id": correlation_id,
"from_date": from_date,
"to_date": to_date,
"page_size": page_size_int,
}
result = await service.list_entries(page=page_int, **list_kwargs)
# A page requested past the end (a stale ?page= link, or a correlation
# pivot whose result set shrank between load and click) must be clamped
# and re-queried — otherwise the client gets an empty `items` list with a
# nonzero `total`, which renders the correlation banner ("N entries") right
# above an empty-state saying nothing matches.
total_pages = max(1, math.ceil(result.total / page_size_int))
if page_int > total_pages:
page_int = total_pages
result = await service.list_entries(page=page_int, **list_kwargs)
entity_types = await service.distinct_entity_types()

# Resolve ids for display only — the stored row keeps the bare id, which
Expand Down Expand Up @@ -98,6 +110,7 @@ async def browse(
"entity_id": entity_id,
"action": action,
"user_id": user_id,
"correlation_id": correlation_id,
"from_date": from_date.isoformat() if from_date else None,
"to_date": to_date.isoformat() if to_date else None,
},
Expand Down
11 changes: 11 additions & 0 deletions modules/audit_log/audit_log/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
"description": "Track all entity changes across the system.",
"empty_title": "No audit entries",
"empty_description": "Changes to entities will appear here automatically.",
"no_match_title": "No entries match these filters",
"no_match_description": "Entries exist outside the current filters.",
"clear_filters": "Clear filters",
"showing": "Showing {from}–{to} of {total} entries",
"previous": "Previous",
"next": "Next"
Expand Down Expand Up @@ -33,6 +36,14 @@
"deleted": "Deleted",
"soft_deleted": "Archived"
},
"correlation": {
"view_related": "Related",
"view_related_title": "Show every entry from this same request",
"banner_title_one": "One action, {count} entry",
"banner_title_other": "One action, {count} entries",
"banner_description": "Everything recorded by a single request.",
"banner_clear": "Show all entries"
},
"changes": {
"fields_set": "{count} fields set",
"show_more": "Show {count} more…",
Expand Down
Loading
Loading