README
¶
go-admin Web Example
A complete reference implementation demonstrating the go-admin library with production-ready integrations including go-auth authentication, activity tracking, settings management, and multi-tenant support.
Features
This example demonstrates:
- ✅ Authentication & Authorization via go-auth
- ✅ Activity Tracking with hooks pattern and dashboard integration
- ✅ User, Role, Profile & Preferences Management powered by go-users on shared SQLite (panels, search, onboarding, go-crud JSON APIs)
- ✅ Multi-Tenant Support with organizations and tenant isolation
- ✅ Dynamic Settings with hierarchical scopes (system/site/user)
- ✅ CMS Integration with widget areas and navigation menus
- ✅ Command Pattern for actions, jobs, and CLI operations
- ✅ Search Engine with typeahead and cross-resource queries
- ✅ Notification System with unread tracking
- ✅ Advanced Sidebar Navigation with collapse, groups, separators, submenus, and LabelKey/GroupTitleKey i18n
- ✅ Panel CRUD for Users, Pages, Posts, Media with filtering and bulk actions
- ✅ Job Registry with cron metadata for scheduling
- ✅ Theme System with dynamic tokens and variant support
Quick Start
Prerequisites
- Go toolchain required by the examples module
- Node from .node-version and npm from the frontend packageManager field (for the asset build)
Running the Example
# From the repo root
cd pkg/client/assets
# Install frontend dependencies (Tailwind + TS build)
npm install
# Build CSS + JS bundles
npm run build
# Run the server
cd ../../examples/web
go run .
# Run the server
go run .
The server starts at http://localhost:8080/admin
In development, the Debug Console enables both the Commands launcher and the
local bounded Command Runs runtime. Dispatch the example commands from
/admin/debug?panel=commands, then open
/admin/debug?panel=command_runs to inspect their live lifecycle rows. This
runtime is intentionally process-local and ephemeral; production split
deployments should inject shared transport/store implementations as described
in docs/GUIDE_DEBUG_MODULE.md. The catalog includes successful inline,
deterministic failure, and observed queued-progress samples. The latter two
also carry command-specific descriptor permissions, so non-superadmin roles
can be used to verify partial catalog/run visibility.
Data console
The kitchen sink also enables the independent Data module at /admin/data.
Log in as the seeded superadmin, or assign the data_viewer, data_operator
or data_custodian role through Users/Roles. Those roles grant no Debug access;
the existing developer/admin role gains no Data access. Viewer can read the seven
panels, including the read-only Explore panel; operator can validate, prepare,
refresh, verify and activate. Reset,
generation and cancellation are explicitly unsupported by this demo, even for
custodian. Data grants confer no CMS/user/export permissions.
Configuration lives in admin.data in config/app.json and accepts:
# From examples/web; Data works with Debug disabled.
APP_ADMIN__DEBUG__ENABLED=false \
APP_ADMIN__DATA__ENABLED=true \
APP_ADMIN__DATA__WRITES_ENABLED=true \
APP_ADMIN__DATA__STORE_PATH=data-console.db \
go run .
store_path is a dedicated SQLite file, relative to the working directory unless
absolute. The example creates its parent directory. Operations, immutable stages,
physical routing and receipts persist across restart. Retry identities remain
for 30 days; receipt retention is 7 days with active/recovery references protected.
Set writes_enabled=false for catalog/validation only; this example also disables
writes outside development. enabled=false omits Data without changing Debug.
Every lifecycle action starts from the row it changes. On Scenarios, each scenario shows its lifecycle steps, status and newest receipt; the next step is the row's primary button and routine work sits in its overflow menu. Overview shows what each target serves, what needs attention, the scenarios waiting on a step and the five most recent operations. A drawer states what the action does to the target before it runs; request IDs are generated per form and sit read-only under Advanced, so operators never type a key. Submitting the same form again replays the same request instead of duplicating it, and Preview plan runs a dry run with its own request ID.
- On Scenarios, open Prepare for Ready and choose Preview plan; the Verification panel labels its checks planned and no stage is created.
- Open Prepare for Ready again and choose Prepare. Completion creates a receipt; it does not activate anything. Changing an option after a submission starts new work with a new request ID; a same-ID request with different input is rejected as a conflict.
- Verify Ready. The receipt picker preselects the newest retained receipt.
Real SQLite queries check 3 orders, a total of 250, and the UTC sample day
2026-01-01. - Activate Ready. The confirmation shows the before and after scenario, receipt and generation. Overview reports active generation 1.
- Prepare Quiet, then verify its receipt. It has 0 orders and covered-empty sample evidence. Ready remains active throughout staging and verification.
- Activate Quiet separately. The physical route now selects the empty stage and advances generation. Activation checks the generation shown when you confirmed; a stale request is rejected rather than silently overwriting it. Replaying the same request preserves the original generation, including after refresh or restart.
The receipt picker pages through the target's retained receipts independently of the latest 100 operations and resolves a preselected receipt exactly, so older retained receipts beyond the first page stay selectable. The same ownership, scenario, verification and generation checks apply. After a failed or canceled operation, Try again on its row in Operations starts new work with the same input.
If an interrupted activation leaves writes paused, a custodian or superadmin
can choose Recover for the pending operation in Operations. Recovery checks
current admin.data.recover grants, waits for the previous lease to expire,
fences old workers and inspects physical routing without repeating provider
work. If the route is still ambiguous, it remains paused; retry recovery after
resolving the target health problem. Successful recovery records the supervisor
separately from the original requester and resumes writes.
The read-only Explore tab is the dataset catalog. Each dataset card shows the
provider's declared title and purpose, its scenarios with lifecycle status and the
catalog inventory. View details opens About, Contents and Used by for one
scenario in one context; identifiers, receipt, revision and generation sit under
Technical identity in About: the catalog example (declared, never
observed data), or the prepared receipt and the active data at its generation once
they exist. Contents previews up to 25 records per page: ready shows three
orders totaling 250 on 2026-01-01, while quiet is empty and the catalog
inventory stays three. Used by lists the declared target with its prepare, verify
and activate effects; this demo registers no application screen, so no link is
shown. When a new receipt or activation changes what you are exploring, the view
marks it stale and reads nothing until you choose Refresh. Exploring uses the
admin.data.view grant and never prepares, activates or exports data;
../../data/examples/datamodule/exploration.go is the reference exploration adapter.
Insights and Compare follow Contents in the details. Insights shows the
composition of the data shown (orders, fixture amount, orders by day) and its day
coverage as a chart or an equivalent table; catalog examples are labelled as
declarations, and a day counts as covered only with verification evidence for that
exact receipt. Compare reads the data shown against another scenario's catalog
example or prepared receipt, or against the active data pinned at its generation
(the active data is then the baseline, A). With Ready and Quiet prepared and
verified, Ready → Quiet is −3 orders and −250 fixture amount; example values are
shown side by side without a difference. Activating another receipt marks an open
comparison with the active data stale until you choose Compare with the current
data. These reads are bounded and change nothing; ../../data/examples/datamodule/insights.go is the
reference insights adapter.
App preview follows Used by in development, where this example enables
application previews. For a prepared receipt it lists the registered application
views you may open: here the Synthetic orders report, which needs
admin.reports.synthetic_orders.view besides Data view. Start preview opens a
bounded, read-only session pinned to that exact receipt, and Open preview shows
the application's own report (/admin/synthetic-orders/report renders the same
view over the active data) under a preview bar with the receipt identity, expiry,
Close preview and Return to Data. Ready shows three orders totaling 250;
Quiet shows the report's empty state. A preview never prepares, verifies or
activates anything; its mutations, jobs and exports are refused, and a closed,
expired or revoked session's page is refused without data. Sessions last 15
minutes unless admin.data.preview_lifetime says otherwise (for example
APP_ADMIN__DATA__PREVIEW_LIFETIME=2m; at most 30m). ../../data/examples/datamodule/preview.go is
the reference preview adapter.
The wiring is in data_module_example.go: data.NewService receives the
application's provider, managed target, operation store, current policy and
principal resolver; admin.NewDataModule receives that service, target, current
feature gate and console identity resolver. Its page actions dispatch the same
registered admin.data.*.v1 typed commands available to other trusted adapters.
../../data/examples/datamodule/runtime.go implements the synthetic provider/target using isolated
data_example_* tables and the opt-in data/examples/sqlitestore reference store.
setup/data_console.go rechecks account status, session expiry, scope and current
role assignments on every boundary, without the main permission cache.
This fixture exercises dataset management rather than replacing the kitchen sink's CMS/users seeds. Applications must supply and verify their own provider, target/recovery and durable-store adapters before enabling writes. No artifact downloads are advertised by this example.
If you are iterating on the quickstart submodule locally, make sure the root
module resolves it via either:
- a
replace github.com/goliatone/go-admin/quickstart => ./quickstartentry in the rootgo.mod, or - a
go.workfile that includes./quickstart.
Configuration model
The web example uses typed config loaded from:
examples/web/config/app.json(base defaults)APP_*env overrides using the__delimiter for nesting
Examples:
APP_ADMIN__SCOPE__MODE=single|multiAPP_ADMIN__SCOPE__DEFAULT_TENANT_ID=<uuid>APP_ADMIN__SCOPE__DEFAULT_ORG_ID=<uuid>APP_TRANSLATION__PROFILE=none|core|core+exchange|core+queue|full
For local development, examples/web/taskfile dev:serve exports canonical APP_* keys only.
Scope defaults (single vs multi-tenant)
The example uses quickstart scope defaults. Configure via:
admin.scope.mode(APP_ADMIN__SCOPE__MODE) default:singleadmin.scope.default_tenant_id(APP_ADMIN__SCOPE__DEFAULT_TENANT_ID) default:11111111-1111-1111-1111-111111111111admin.scope.default_org_id(APP_ADMIN__SCOPE__DEFAULT_ORG_ID) default:22222222-2222-2222-2222-222222222222
For multi-tenant mode, ensure your auth claims and seeded data share the same tenant/org IDs.
When running in single-tenant mode, the seed loader rewrites seeded rows to the configured defaults so the demo data stays in scope.
The standard quickstart go-users wiring also uses those defaults when ScopeResolver is omitted; custom direct go-users reads must use quickstart.ScopeBuilder(cfg) or an equivalent resolver.
Translation capability profiles (productized wiring)
The example app uses productized translation quickstart wiring through WithTranslationProductConfig(...).
Config matrix:
translation.profile(APP_TRANSLATION__PROFILE) =none|core|core+exchange|core+queue|full- Default in
app.json:full
- Default in
translation.exchange(APP_TRANSLATION__EXCHANGE) =true|falsetranslation.queue(APP_TRANSLATION__QUEUE) =true|false
Override precedence:
translation.profilesets module defaults.translation.exchange/translation.queueoverride profile module defaults when set differently from the profile baseline.
Exchange wizard UI config is optional. When translation.exchange_ui is omitted, the wizard keeps the demo fallback controls. Hosts that need a different contract can map their own runtime config into:
translation.exchange_ui.source_localetranslation.exchange_ui.source_localestranslation.exchange_ui.target_localestranslation.exchange_ui.resourcestranslation.exchange_ui.default_target_localestranslation.exchange_ui.default_resources
For an en/bo/zh host, set source_locale to en, target locales to bo and zh, and resources to the exact host resource IDs expected by the exchange store. Site supported locales are not read by go-admin automatically; the host must map them into this quickstart config.
Module routes when enabled:
- Translations menu group: seeded by quickstart with dashboard, queue, and exchange entrypoints
- Dashboard UI:
GET /admin/translations/dashboard - Exchange UI:
GET /admin/translations/exchange - Exchange API:
/admin/api/translations/exchange/* - Queue UI:
GET /admin/translations/queue - Queue compatibility alias:
GET /admin/content/translations - Queue API:
GET /admin/api/translations/queue - Queue panel route key:
admin.translations.queue - Contextual/deep-link routes: family detail
/admin/translations/families/:family_id, matrix/admin/translations/matrix, assignment editor/admin/translations/assignments/:assignment_id/edit
Operational verification:
- Start with
APP_TRANSLATION__PROFILE=fulland optional explicit overrides:APP_TRANSLATION__EXCHANGE=trueAPP_TRANSLATION__QUEUE=true
- Verify startup event
translation.capabilities.startupincludes expectedprofile,modules,routes, andresolver_keys. - Verify the sidebar includes a canonical
Translationsgroup with the expected production entrypoints:- Dashboard
- Queue
- Exchange
- Verify enabled-module routes:
GET /admin/translations/dashboard(dashboard UI)GET /admin/translations/exchange(exchange UI)GET /admin/translations/queue(queue UI)GET /admin/content/translations(queue compatibility alias)POST /admin/api/translations/exchange/export(exchange API)POST /admin/api/translations/exchange/import/validate(exchange validate API)POST /admin/api/translations/exchange/import/apply(exchange apply API)GET /admin/api/translations/queue(queue API)
- Verify contextual translation surfaces remain reachable without top-level shortcut menu items:
GET /admin/translations/families/:family_idGET /admin/translations/matrixGET /admin/translations/assignments/:assignment_id/edit- content list routes such as
GET /admin/content/pages?incomplete=true
- Verify disabled-module behavior by switching profiles:
APP_TRANSLATION__PROFILE=core: exchange + queue routes should not be exposed.APP_TRANSLATION__PROFILE=none: translation routes and translation operations entrypoints should not be exposed.
- Verify capabilities from runtime payload in templates (
translation_capabilities) or backend call (quickstart.TranslationCapabilities(adm)), ensuring module flags match route availability; this payload is also available on custom handlers that callhelpers.WithNav(for example/admin/users).
Permission model for translation modules:
- Quickstart wires routes/panels/commands and defines permission keys, but role assignment is application-owned.
- The example seeds privileged roles (
superadmin,owner) with both queue and exchange translation permissions. - Required permission keys for translation operations:
- Queue:
admin.translations.view,admin.translations.assign,admin.translations.edit,admin.translations.approve,admin.translations.manage,admin.translations.claim - Exchange:
admin.translations.export,admin.translations.import.view,admin.translations.import.validate,admin.translations.import.apply
- Queue:
- If role permissions are changed while the app is running, reload the page to pick up current role assignments.
- If using an existing DB seeded before these permissions existed, reseed roles (for example
APP_SEEDS__TRUNCATE=true) or update role permissions manually.
Authz preflight (DX guardrail):
APP_ADMIN__AUTHZ_PREFLIGHT__MODE=off|warn|strictwarnlogs startup warnings for missing translation permissions on privileged roles.strictfails startup when required permissions are missing.- default:
warnin development,offotherwise.
APP_ADMIN__AUTHZ_PREFLIGHT__ROLES=superadmin,owneroverrides which role keys are checked.- Checks run only when translation modules are enabled.
Quick smoke command matrix:
# full profile (exchange + queue expected)
APP_TRANSLATION__PROFILE=full go run .
# core profile (exchange + queue disabled)
APP_TRANSLATION__PROFILE=core go run .
# none profile (translation capabilities disabled)
APP_TRANSLATION__PROFILE=none go run .
Production-style translation entrypoints in examples/web:
- Navigation:
- Dashboard:
/admin/translations/dashboard - Queue:
/admin/content/translationsfrom the seeded menu entry, with/admin/translations/queuealso available directly - Exchange:
/admin/translations/exchange
- Dashboard:
- Contextual/deep links:
- Family detail:
/admin/translations/families/:family_id - Matrix:
/admin/translations/matrix?channel=production&tenant_id=tenant-1&org_id=org-1&locale_limit=4 - Editor:
/admin/translations/assignments/:assignment_id/edit - Content datagrid translation filters:
/admin/content/pages?incomplete=true - Queue review preset example:
/admin/translations/queue?preset=review_inbox
- Family detail:
Release-readiness artifacts:
- Pending rollout template:
examples/web/release/translation_release_checklist.json - Approved sample bundle:
examples/web/release/testdata/translation_release_checklist_approved_sample.json
DataGrid state persistence (content pages/posts)
Content-entry DataGrid persistence is local-first by default, with optional user-preferences sync.
Config toggles:
datagrid.state_store_mode(APP_DATAGRID__STATE_STORE_MODE) =local|preferences(default: unset)datagrid.sync_debounce_ms(APP_DATAGRID__SYNC_DEBOUNCE_MS) optional, preferences modedatagrid.max_share_entries(APP_DATAGRID__MAX_SHARE_ENTRIES) optionaldatagrid.url_max_length(APP_DATAGRID__URL_MAX_LENGTH) optional URL budgetdatagrid.url_max_filters_length(APP_DATAGRID__URL_MAX_FILTERS_LENGTH) optional filters budgetdatagrid.url_enable_state_token(APP_DATAGRID__URL_ENABLE_STATE_TOKEN) optional; enablesstate=<token>fallback when URL budget is exceeded
API Endpoints
- Health:
GET /admin/health - Dashboard Entry:
GET /admin- Redirects toGET /admin/dashboard - Dashboard HTML (SSR):
GET /admin/dashboard- Server-side rendered dashboard with inline state - Dashboard JSON:
GET /admin/api/dashboard- JSON API (backwards compatible) - Navigation:
GET /admin/api/navigation - Settings:
GET /admin/api/settings,POST /admin/api/settings - Workflows:
GET/POST /admin/api/workflows,PUT /admin/api/workflows/:id - Workflow Bindings:
GET/POST /admin/api/workflows/bindings,PUT/DELETE /admin/api/workflows/bindings/:id - Search:
GET /admin/api/search?query=...
For the complete search contract, including admin global search, public site
search, go-search adapters, and DataGrid search, see docs/GUIDE_SEARCH.md.
- Notifications:
GET /admin/api/notifications - Activity:
GET /admin/api/activity?limit=50&offset=0&channel=users(filters:user_id,actor_id,verb,object_type,object_id,channel/channels,channel_denylist,since,until,q; response includesentries,total,next_offset,has_more; defaults: limit 50, max 200, offset 0; ordered by most recent first). - Jobs:
GET /admin/api/jobs,POST /admin/api/jobs/:name/trigger - Users Panel:
GET /admin/api/users,POST /admin/api/users, etc. - Roles Panel:
GET /admin/api/roles,POST /admin/api/roles, etc. - User Actions:
/admin/api/users/:id/{activate,suspend,disable,archive,reset-password,invite}plus/admin/api/users/bulk/{activate,suspend,disable,archive,assign-role,unassign-role}. - Profile UI:
GET /admin/profile(canonical entry route; opens current authenticated user detail view) - Profile & Preferences:
GET/POST /admin/api/panels/profile,GET/POST /admin/api/panels/preferences - Onboarding:
POST /admin/api/onboarding/invite,POST /admin/api/onboarding/password/reset/request,POST /admin/api/onboarding/password/reset/confirm,POST /admin/api/onboarding/register(flagged) - Users CRUD API:
GET /admin/crud/users(go-crud JSON endpoints) - Tenants Panel:
GET /admin/api/tenants - Organizations Panel:
GET /admin/api/organizations - Session:
GET /admin/api/session(current authenticated user snapshot) - Permission Diagnostics:
GET /admin/api/debug/permissions(current user granted/required/missing permissions + hints) - Debug Sessions:
GET /admin/debug/api/sessions(canonical active-session registry); compatibility redirects are also registered forGET /api/sessionsandGET /admin/api/debug/sessions
Action UX QA
The web example is the workflow-oriented action UX reference for pages and posts.
Relevant checks:
- row and detail actions share the same
_action_statecontract - workflow and translation blocks keep actions visible but disabled with reasons
- stale action retries return structured envelopes and refresh server-authored state
Focused verification:
go test ./examples/web -run 'TestActionPhase8|TestContentActionContractsPhase5|TestContentBulkActionsPhase7'
When the debug module is enabled, the Debug dashboard includes an Actions
panel that lists recent disablements and structured failures captured by the
shared action pipeline.
Content channel query contract for admin/content routes:
- Canonical scope query key is
$channel(admin.ContentChannelScopeQueryParam). - For encoded URLs/curl, use
%24channel(example:/admin/api/v0/pages?page=1&per_page=10&%24channel=default). channelremains accepted as a compatibility input, but it can now be interpreted as a real record filter field.
Public Site Runtime (quickstart/site)
The web example now registers public routes through quickstart/site.RegisterSiteRoutes (catch-all delivery, menu projection, locale-aware routing, and optional search module).
Defaults:
LocalePrefixMode=non_default(/about,/es/about)- locale fallback enabled unless
site.allow_locale_fallback=false - canonical redirects enabled with locale strategy
requested_locale_sticky(fallback reads keep requested locale URL context) - request locale is persisted in
site_localecookie for locale continuity when a stale/non-prefixed link is followed - generated menu fallback disabled (
site.enable_generated_fallback=false) and intended only as a demo safety net - search routes enabled when the injected site search provider is available:
- page:
GET /search - API:
GET /api/v1/site/search - suggest:
GET /api/v1/site/search/suggest
- page:
See docs/GUIDE_SEARCH.md for the full site search request/response contract
and go-search bundle wiring.
Useful config toggles:
site.supported_locales(APP_SITE__SUPPORTED_LOCALES=en,es,fr)site.locale_prefix_mode(APP_SITE__LOCALE_PREFIX_MODE=non_default|always)site.allow_locale_fallback(APP_SITE__ALLOW_LOCALE_FALLBACK=true|false)site.enable_canonical_redirect(APP_SITE__ENABLE_CANONICAL_REDIRECT=true|false)site.canonical_redirect_mode(APP_SITE__CANONICAL_REDIRECT_MODE=requested_locale_sticky|resolved_locale_canonical)site.enable_generated_fallback(APP_SITE__ENABLE_GENERATED_FALLBACK=true|false)site.enable_search(APP_SITE__ENABLE_SEARCH=true|false)site.runtime_env(APP_SITE__RUNTIME_ENV=dev|staging|prod)site.content_channel(APP_SITE__CONTENT_CHANNEL=default|dev|staging|prod|<channel>)site.theme(APP_SITE__THEME=<theme-name>)site.theme_variant(APP_SITE__THEME_VARIANT=<variant>)site.allow_theme_name_override(APP_SITE__ALLOW_THEME_NAME_OVERRIDE=true|false)site.allow_theme_variant_override(APP_SITE__ALLOW_THEME_VARIANT_OVERRIDE=true|false)
Canonical redirect mode behavior:
requested_locale_sticky: when fallback content is served (for example ES request resolves EN record), keep the requested locale URL prefix to preserve navigation locale continuity.resolved_locale_canonical: canonical URL follows resolved content locale (for example fallback EN record redirects to EN canonical path).site_localecookie is used as locale fallback input for unprefixed requests, so stale/...links opened from an ES session are redirected back into/es/...when sticky mode is active.- in
LocalePrefixMode=non_default, locale switcher links to default locale include?locale=<default>(for example?locale=en) so explicit EN switches are not shadowed by a non-defaultsite_localecookie.
Theme override behavior:
- In runtime
dev|staging, request query overrides follow host policy. - The example app disables request-time theme-name swaps by default because its template overlays are mounted at startup from a single embedded package.
- The example app keeps request-time variant swaps enabled by default, so
?variant=<variant>still works indev|staging. - In runtime
prod, query overrides are ignored and configured defaults are used. - The public-site runtime now defaults to the embedded
go-admin-demo-sitepackage underexamples/web/site_themes/go-admin-demo-site. site.themeshould point to an embedded site theme package that the example app can mount at startup.- Admin theme resolution is attached separately from the public-site package, so site theme assets and partials do not leak into
/admin/*unless the host opts into sharing explicitly. - Theme static bundles are mounted at
/static/themes/go-admin-demo-site/static/*, matching the package manifest paths. - Swap variants with
APP_SITE__THEME_VARIANT=<variant>or use?variant=<variant>in non-production runtime modes. - The embedded package is a generated site-theme snapshot, including renderer-compatible base, navigation, search, and content templates.
- The example app uses the generated
site.search.pagetemplate while preserving the runtime search contract for suggestions, filters, pagination, and query state. - The general admin/public-site theme contract is documented in
../../docs/GUIDE_THEME.md.
Search UI state demos:
- Normal results:
/search?q=go - Zero results:
/search?q=zzzzzz - Error state (simulated):
/search?q=error
Routing/theme QA after migration:
GET /searchshould render the public-site theme bundles (/static/themes/go-admin-demo-site/static/*) and never pull admin error-page assets- an allowed unknown public content path such as
/resources/publishing-workflow-playbookshould resolve through the public-site fallback surface GET /missing-pageshould render the site 404 template and keep the public-site theme outputGET /admin/missingshould render the admin 404 page and keep/admin/assets/*ownershipGET /.well-known/app-infoshould return host-owned JSON, and unknown/.well-known/*paths should bypass site templates entirely- if
site.internal_opsis enabled,GET /healthzandGET /status(or configured replacements) should stay on the internal-ops surface
Routing ownership model in the example host:
quickstart.NewHostRouter(...)is the root registration boundary forsystem,internal_ops,admin_ui,admin_api,public_api,public_site, andstaticsite.fallback.modeuses the typedquicksite.SiteFallbackMode*constants; the example default ispublic_content_only- site fallback keeps
GETandHEADonly, reserves/admin,/api,/.well-known,/static,/assets, and extends that set with enabled internal-ops paths - startup diagnostics are the source of truth for ownership: inspect
adm.RoutingReport(),adm.RoutingPlanner().Manifest(), or quickstart doctor output instead of inferring behavior from registration order
Migration notes:
- the example host now uses
quickstart.NewHostRouter(...)for grouped route ownership instead of relying on registration order - site fallback is configured through the typed
quicksite.SiteFallbackPolicycontract exposed in config, not callback matchers or magic strings - admin and public-site themes are attached separately; old shared
WithGoTheme(...)usage migrates toadm.WithAdminTheme(...)plus the site theme provider/selector; see../../docs/GUIDE_THEME.md#migration-notes - internal ops routes (
/healthz,/status) stay host-owned and are added to the reserved-prefix set when enabled - the router surfaces and fallback policy are now the primary ownership boundary; handler-level guards remain a defensive fallback, not the ownership mechanism
Stage 1 quickstart helpers
If you want a minimal Stage 1 admin (login + dashboard only), the quickstart helpers provide a smaller wiring surface:
WithMinimalFeatures()to replace the complete default catalog with the minimal dashboard-and-CMS feature set.WithAdapterFlags(...)to drive adapter wiring explicitly from config.NewModuleRegistrarusesadm.FeatureGate()by default (passWithModuleFeatureGates(customGate)to override).WithGoAuth(...)to wire auth + authorizer in one call.WithDefaultDashboardRenderer(...)for a basic SSR dashboard (override templates viaWithDashboardTemplatesFS).
Preferences quickstart
adm, _, err := quickstart.NewAdmin(
cfg,
adapterHooks,
quickstart.WithGoUsersPreferencesRepository(deps.PreferenceRepo),
quickstart.EnablePreferences(),
)
if err != nil {
return err
}
_ = adm
If /admin/api/panels/preferences returns 403, grant admin.preferences.view and admin.preferences.edit.
Preferences UI extras in the web example:
admin.preferences.schema_path(APP_ADMIN__PREFERENCES__SCHEMA_PATH) sets a custom form schema path (file or directory containingschema.json).admin.preferences.json_strict(APP_ADMIN__PREFERENCES__JSON_STRICT=true) enables client-side JSON validation forraw_ui.
Error handling config toggles:
admin.errors.dev_mode(APP_ADMIN__ERRORS__DEV_MODE=true) enables dev-mode error output (stack traces + internal messages).admin.errors.include_stack_trace(APP_ADMIN__ERRORS__INCLUDE_STACK_TRACE=true) forces stack traces outside dev mode.admin.errors.expose_internal_errors(APP_ADMIN__ERRORS__EXPOSE_INTERNAL_ERRORS=true) exposes internal error messages in responses.
Developer Error Page
When running in dev mode (app.env=development), the example includes an enhanced error page with:
- Tabbed interface: Error, Stack Trace, Request, and App tabs
- Source code context: Shows code around the error location with syntax highlighting
- Enriched stack traces: Collapsible frames with app vs vendor distinction, VS Code links
- Request details: Headers, query params, form data, request body
- Environment info: Go version, app version, quick search links (Google, Stack Overflow)
Test Error Endpoint (dev mode only):
GET /admin/test-error
GET /admin/test-error?type=<type>
GET /admin/test-error/<type>
| Type | Description |
|---|---|
internal (default) |
500 Internal Server Error with metadata |
notfound / 404 |
404 Not Found |
forbidden / 403 |
403 Forbidden |
validation / 400 |
400 Validation Error with field errors |
template |
Template rendering error |
nested |
Wrapped/nested error chain |
panic |
Triggers a panic (for panic recovery testing) |
Example:
# Start in dev mode
APP_APP__ENV=development go run .
# Test different error types
curl http://localhost:8080/admin/test-error
curl http://localhost:8080/admin/test-error/validation
curl http://localhost:8080/admin/test-error?type=nested
Content UI (Pages, Posts, Media)
- HTML CRUD screens live at
/admin/content/pages,/admin/content/posts,/admin/content/media(aliases/admin/pagesand/admin/postsredirect; auth + permissions enforced:admin.pages.*,admin.posts.*,admin.media.*). - Actions include create/edit/delete plus workflow/publish actions for pages/posts; media supports add/edit/delete metadata.
- Sidebar
Contentincludes pages/posts/media plus CMS-driven entries (for example seeded content types likenews, and admin surfaces such as Content Types/Block Library) when the user has required permissions. - Navigation highlights the active item; search results link to the new views.
- Content entry filters now derive automatically from panel schema filters, and when missing they fall back to visible list/form fields by default.
- Workflow config fixture:
examples/web/workflow_config.yamldeclares:- a trait binding from
editorialtoeditorial.default - explicit workflow
editorial.newsused by the seedednewscontent type viaworkflow_id. The app loads this file automatically when present (override path withAPP_ADMIN__WORKFLOW_CONFIG_PATH), seeds persisted runtime workflows asactive, and seeds trait bindings under/admin/api/workflows/bindings.
- a trait binding from
Content persistence (SQLite + go-repository-bun)
- Pages/Posts are served via content entry APIs (
/admin/api/content) backed by go-cms content entries; media continues to use go-crud at/admin/crud/media, backed by SQLite via go-repository-bun. go-cms migrations from../go-cms/data/sql/migrationsare applied on startup with a light overlay that creates the demo tables for the example flows. - Workflow runtime migrations (
workflows,workflow_bindings,workflow_revisions) are also applied in persistent mode so/admin/api/workflows*survives restarts. - Configure DSNs with
APP_DATABASES__CONTENT_DSNandAPP_DATABASES__CMS_DSN; fixtures load fromexamples/web/data/sql/seedswhenseeds.enabledis true (APP_SEEDS__ENABLED=true). UseAPP_SEEDS__TRUNCATE=trueto reseed. - Seeds skip duplicate rows individually by default, preserve existing edits, and continue loading missing rows. Set
APP_SEEDS__IGNORE_DUPLICATES=falsefor strict duplicate errors. Other constraint failures always stop startup; failed loads can be retried. Truncation replaces existing fixture data. - Controllers use canonical go-crud routes by default; only legacy user-profile compatibility routes are kept (
/admin/crud/user-profiles,/admin/crud/user-profiles/:id,/admin/crud/user-profiles/batch) so existing DataGrid/HTML flows continue to work. - Smoke: create/edit/delete a page and post via the content entry UI, and a media item via
/admin/crud/media; restart the server and confirm the records persist and still filter/sort in the lists. - Entry navigation visibility is configured through
cfg.EntryNavigationinexamples/web/main.goand per-content-typecapabilities.navigationinexamples/web/setup/cms_seed.go. Pages, posts, and news exposesite.main/site.footeras seeded menu locations; the admin-level config layers view/edit permissions and the activity action label without template-specific policy. Successful non-noop saves emitcontent.navigation_visibility.updateon thecontentchannel.
Sidebar Navigation & Quickstart defaults
- Navigation can be seeded via
quickstart.SeedNavigationinexamples/web/setup/navigation.go; by default the app uses module menu contributions withquickstart.EnsureDefaultMenuParentsso grouped/collapsible nav renders without seeding. Menus are addressed by slug (cfg.NavMenuCode) for deterministic IDs; reset persistent menus withnavigation.reset_menu=true(APP_NAVIGATION__RESET_MENU=true) or delete the local DB when switching sources. - Startup reconciliation in
examples/web/main.gorunssetup.EnsureDashboardFirst(...)andsetup.EnsureContentParentPermissions(...)so persisted menus from older runs pick up new ordering and Content parent permission requirements without a full reset. - Permission-denied menu items are hidden by default. Set
navigation.permission_denied_mode=disable(APP_NAVIGATION__PERMISSION_DENIED_MODE=disable) to render denied admin and public-site CMS menu items as disabled diagnostics in local/staging. Keep the defaulthidemode for production. Public-site CMS menu permissions authorize against thenavigationresource. - Sidebar templates/assets come from quickstart embeds (collapse + submenu persistence); override by layering your own template/assets FS via
quickstart.NewViewEngineoptions inexamples/web/main.go. - Menu items include both
Label/LabelKeyandGroupTitleKey; modules can nest under seeded groups viaParentID. - Collapse state persists (
admin-sidebar-collapsed), submenu state persists per submenu key, and debug mode exposes ordered nav JSON in the sidebar (admin.debug.enabled=true). - Logo shrinks in collapsed mode; separators and group titles remain visible.
Template Functions
- The view engine uses
quickstart.DefaultTemplateFuncs, which includessingularize,pluralize,toJSON,dict,formatNumber,adminURL,navItemVisible,navItemDisabled, and widget title helpers. These are helpers (globals) in Pongo2, so call them like{{ singularize(resource_label|default:resource)|title }}. adminURLprefixes paths with the configured base path (wired inexamples/web/main.goviaWithTemplateBasePath(cfg.BasePath)).- Prefer
adminURL(...)for admin-relative links and admin-hosted assets; keep usingasset_base_pathonly where a template intentionally supports a separate asset host/CDN. - Example-specific widget title labels are configured in
examples/web/helpers/template_funcs.goviahelpers.TemplateFuncOptions(). - To add custom template functions while keeping defaults, use
quickstart.MergeTemplateFuncsand pass the result toquickstart.WithViewTemplateFuncsinexamples/web/main.go.
Sidebar Manual QA
- Toggle collapse/expand, refresh, and confirm state persists.
- Expand/collapse
ContentandMy Shop; child links persist across reloads. - Group titles and separators render once after permission filtering; no duplicate children on reseed.
- In collapsed mode, text hides while icons stay aligned; submenu chevrons hide until expanded.
- With debug enabled, verify nav JSON matches rendered order and payload logs once per build.
Session Widget & API
- Sidebar footer now renders the current session from go-auth (
helpers.BuildSessionUserviahelpers.WithNav), showing display name/email plus role/tenant when available; guest state falls back to “Not signed in.” - Authenticated snapshot is also available at
GET /admin/api/session(mirrors the footer payload: id/subject/email/username/role/tenant/org/resource_roles/scopes/issued_at/expires_at). - Sidebar logout is a POST form with
csrf_field;GET /admin/logoutis not registered by default and will surface as an adminroute_missunlessWithAuthUILogoutGET(true)is explicitly enabled for legacy compatibility. - QA: login as admin/editor/viewer and confirm the footer avatar initial and labels match the account; collapse the sidebar to hide text; submit Log out and expect a redirect to
/admin/login; call/admin/api/sessionwith the issued token and expect 200 with populated fields; without/expired token the auth middleware returns 401 and the footer shows Guest/Not signed in.
Adding a new CRUD resource (HTML)
Prefer the panel-backed CRUD path documented in docs/GUIDE_CRUD.md:
- Register an
admin.PanelBuilderwith repository, fields, filters, permissions, and actions. - Use canonical panel JSON routes under
/admin/api/panels/:panel. - Let quickstart/content-entry templates consume
datagrid_config,schema,records, and_action_state. - Use custom HTML handlers only for resource-specific surfaces that cannot use canonical panel UI routes.
The older resource-handler pattern in handlers/users.go and handlers/tenants.go remains useful for fully custom pages, but new list/datagrid resources should start from the panel contract.
User Detail Tabs (Example)
- Users detail renders a tab strip sourced from
schema.tabsin the admin detail payload. - Tabs are registered for the
userspanel inexamples/web/main.goviaquickstart.NewUsersModule(...WithUserPanelTabs(...))and include Activity + Profile. - The detail handler (
examples/web/handlers/users.go) calls the admin detail API and maps tabs to view links; the template renderstabs. - If you change the tab targets, ensure the routes exist (e.g.,
/admin/activity) or update the target to a panel/path that does. - Tab content is resolved by a host-app resolver (
helpers.TabContentResolver) and render mode selector (helpers.TabRenderModeSelector) to support SSR, hybrid, or client-only strategies. - Inline tabs (content kinds:
details,dashboard_area,cms_area,template) link back to the detail URL with?tab=<id>, while navigation tabs keep theirpanel/path/externaltargets. - The detail handler reads
?taband setsactive_tab(defaults todetails) for consistent deep linking. - Hybrid tabs fetch HTML from
/admin/users/:id/tabs/:tabon demand; client tabs fetch JSON from/admin/api/users/:id/tabs/:tab. - The detail template includes a lightweight loader that swaps the tab panel for
data-render-mode="hybrid|client"tabs and updates the URL to preserve deep links.
Authentication
go-auth Integration
The example uses production-ready go-auth integration via setup/auth.go:
func SetupAuth(adm *admin.Admin, dataStores *stores.DataStores) {
cfg := demoAuthConfig{signingKey: "web-demo-secret"}
provider := &demoIdentityProvider{users: dataStores.Users}
auther := auth.NewAuthenticator(provider, cfg)
routeAuth, err := auth.NewHTTPAuthenticator(auther, cfg)
// Wire go-auth to admin
adm.WithAuth(admin.NewGoAuthAuthenticator(routeAuth, cfg), &admin.AuthConfig{
LoginPath: "/admin/login",
LogoutPath: "/admin/logout",
RedirectPath: "/admin",
})
// Wire authorization
adm.WithAuthorizer(admin.NewGoAuthAuthorizer(admin.GoAuthAuthorizerConfig{
DefaultResource: "admin",
}))
}
Logout route details:
RegisterAuthUIRoutesregistersPOST /admin/logoutby default and validates it with the logout authenticator passed inexamples/web/main.go.- Render logout as a form that includes
{{ csrf_field|safe }}. A plain<a href="/admin/logout">issues a GET and will 404 in the default example. - Use
quickstart.WithAuthUILogoutGET(true)only when preserving a legacy GET logout link is required.
Demo Authentication Tokens
When the server starts, it logs JWT tokens for demo users:
demo Authorization tokens (use Authorization: Bearer <token>):
- admin (admin): eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
- editor (editor): eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
- viewer (guest): eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Use these tokens in the Authorization header:
curl -H "Authorization: Bearer <token>" http://localhost:8080/admin/api/dashboard
Browser note: cookie-backed admin pages now submit CSRF automatically through the shared templates. If you replace built-in browser forms or write custom same-origin fetch() calls, include X-CSRF-Token from meta[name="csrf-token"]. Bearer-based CLI calls to /admin/api/... remain unchanged.
User management (go-users + go-crud)
- Users/roles/profiles/preferences are backed by go-users on the shared SQLite DSN (
databases.cms_dsn); seeds create admin/editor/viewer/inactive with passwords<username>.pwd, and demo JWTs are printed from the DB on startup. - go-auth reads users from go-users and issues resource roles for
admin.users.*,admin.roles.*,admin.profile.*,admin.preferences.*(admin → owner, editor → member, viewer → none); profile/preferences remain self-service even when broader perms are absent. - Panels (
/admin/api/users|roles|profile|preferences), navigation, search, and/admin/crud/usersall run through the same scope guard and permissions; unauthorized tokens get 403s and the menu hides unless diagnostic disabled rendering is enabled. - Onboarding flags live in
main.go(users.invite✅,users.password_reset✅,users.signup❌ by default with allowlist mode). Override with:features.user_invites(APP_FEATURES__USER_INVITES)features.password_reset(APP_FEATURES__PASSWORD_RESET)features.self_registration(APP_FEATURES__SELF_REGISTRATION)registration.mode(APP_REGISTRATION__MODE, valuesopen|allowlist|closed)registration.allowlist(APP_REGISTRATION__ALLOWLIST) Endpoints sit under/admin/api/onboarding/*for invite, accept/verify, reset request/confirm, and optional self-registration.
- Lifecycle transitions, admin-triggered invites/password resets, and bulk role assign/unassign live under
/admin/api/users/*(also/admin/crud/users/*) and emituserschannel activity for the dashboard widget/search feeds. - Lifecycle actions (activate/suspend/disable/archive) and role assignment emit activity to the
userschannel and surface in the dashboard activity widget; preferences persist via go-usersPreferenceRepository. - Quick wiring/seed notes live in
docs/prds/EXAMPLE_USERS_TDD.md; the smoke checklist is indocs/prds/EXAMPLE_SMOKE.md.
Onboarding + Secure Links
- Securelink config keys:
securelink.signing_key,securelink.base_url,securelink.query_key,securelink.as_query,securelink.expiration(env:APP_SECURELINK__*). - The example falls back to a demo signing key when
securelink.signing_keyis unset (seeexamples/web/setup/securelink.go). - Default securelink paths:
/admin/invite,/admin/register,/admin/password-reset/confirm(base path mirrorsadmin.base_path). - UI routes:
/admin/password-reset(request) and/admin/password-reset/confirm(apply token). Securelink reset URLs land on the confirm page. - API endpoints remain under
/admin/api/onboarding/*; UI routes are registered inexamples/web/main.gowith custom view context for token parsing and policy hints. - Errors follow the go-errors response shape with
error.text_code(seedocs/GUIDE_ONBOARDING.mdfor the canonical list).
JSON CRUD Smoke (Users)
Use the demo tokens to exercise the go-crud JSON endpoints (snake_case payloads) backed by the same go-users SQLite store:
- List users:
curl -H "Authorization: Bearer <token>" http://localhost:8080/admin/crud/users - Create:
curl -X POST -H "Authorization: Bearer <token>" -H "Content-Type: application/json" -d '{"username":"api.user","email":"api.user@example.com","role":"editor","status":"active"}' http://localhost:8080/admin/crud/user - Update:
curl -X PUT -H "Authorization: Bearer <token>" -H "Content-Type: application/json" -d '{"status":"inactive"}' http://localhost:8080/admin/crud/user/<id> - Delete:
curl -X DELETE -H "Authorization: Bearer <token>" http://localhost:8080/admin/crud/user/<id>
If you exercise the same flows through browser sessions instead of Bearer auth, use the rendered UI or send the CSRF token alongside the cookie-backed request.
Swapping Identity Providers
The demoIdentityProvider can be replaced with any implementation of auth.IdentityProvider:
type IdentityProvider interface {
VerifyIdentity(ctx context.Context, identifier, password string) (Identity, error)
FindIdentityByIdentifier(ctx context.Context, identifier string) (Identity, error)
}
For production, replace with a database-backed provider:
// Example: PostgreSQL-backed provider
type PostgreSQLIdentityProvider struct {
db *sql.DB
}
func (p *PostgreSQLIdentityProvider) VerifyIdentity(ctx context.Context, identifier, password string) (auth.Identity, error) {
// Query database, verify password hash
// Return user identity
}
Dashboard Rendering (SSR + Hydration)
The example demonstrates hybrid server-side rendering with client-side hydration for optimal performance and user experience.
Architecture
- Server-Side Rendering (SSR): Complete HTML is rendered server-side with widgets and data
- Client Hydration: JavaScript attaches behaviors to existing DOM without re-rendering
- Zero API Fetches: Initial page load requires no additional API calls
- Canonical Contract: Widget providers return typed
WidgetPayloadview-models - Typed Renderer Boundary:
examples/web/setup.NewDashboardRenderer()returns a typedadmin.DashboardRendererand rendersadmin.AdminDashboardPagedirectly
How It Works
- Server renders complete HTML at
/admin/dashboardusing Go templates - Inline state is embedded as JSON in the page for hydration
- WidgetGrid client attaches drag-and-drop, resize, and visibility behaviors
- Layout changes persist via AJAX to
/admin/api/dashboard/preferences
Performance Benefits
- <200ms initial render (server-side, no API waterfall)
- Instant interactivity (behaviors attach to existing DOM)
- SEO-friendly (fully rendered HTML)
- Reduced bandwidth (single request vs. multiple API calls)
Implementation
The dashboard renderer is wired in main.go:
// Wire dashboard renderer for server-side rendering
dashboardRenderer, err := setup.NewDashboardRenderer()
if err != nil {
log.Printf("warning: failed to initialize dashboard renderer (falling back to JSON API): %v", err)
} else {
if dashboard := adm.Dashboard(); dashboard != nil {
dashboard.WithRenderer(dashboardRenderer)
log.Println("Dashboard SSR enabled")
}
}
Template: pkg/client/templates/dashboard_ssr.html
Renderer: quickstart/dashboard_renderer.go via examples/web/setup/dashboard_renderer.go
Typed page wrapper: admin.AdminDashboardPage (dashboard.Page + admin chrome state)
Hydration: assets/src/dashboard/widget-grid.ts
Routes
- SSR:
GET /admin/dashboard- Full HTML page with inline state - JSON API:
GET /admin/api/dashboard- JSON payload (backwards compatible) - Preferences:
GET/POST /admin/api/dashboard/preferences- Layout persistence
Canonical Widget Provider Contract
Dashboard providers now return admin.WidgetPayload with struct roots only:
type TranslationSummaryPayload struct {
Pending int `json:"pending"`
}
dash.RegisterProvider(admin.DashboardProviderSpec{
Code: admin.WidgetTranslationProgress,
Handler: func(ctx admin.AdminContext, cfg map[string]any) (admin.WidgetPayload, error) {
_ = ctx
_ = cfg
return admin.WidgetPayloadOf(TranslationSummaryPayload{Pending: 1}), nil
},
})
Notes:
- Root
map[string]anypayloads are rejected. - Unsafe keys/content (
chart_html, full-document/script blobs) are sanitized centrally. - Both SSR and client hydration consume the same canonical payload shape.
- The example-web renderer path no longer uses
DashboardLayoutor payload-shape normalization adapters.
Customizing Templates
The renderer uses Go's html/template with custom functions:
toJSON- Serialize data for inline stategetWidgetTitle- Human-readable widget titlesformatNumber- Locale-aware number formattingsafeHTML- Render trusted HTML
Example widget template:
{{define "widgetContent"}}
{{if eq .definition "admin.widget.user_stats"}}
<div class="metrics">
{{$values := index .data "values"}}
{{range $key, $value := $values}}
<div class="metric">
<small>{{$key}}</small>
<span>{{formatNumber $value}}</span>
</div>
{{end}}
</div>
{{end}}
{{end}}
Disabling SSR
To revert to JSON-only rendering, simply don't wire the renderer:
// Comment out renderer wiring in main.go
// dashboardRenderer, err := setup.NewDashboardRenderer()
The dashboard will automatically fall back to the JSON API endpoint.
Dashboard Persistence
The dashboard uses goliatone/go-dashboard with CMS-backed persistent storage. Widgets and user layout preferences are stored in the database and survive server restarts.
Prerequisites:
- CMS persistence is enabled (required for the dashboard)
- Dashboard feature enabled (
Features.Dashboard = true, default)
Run with persistent dashboard:
go run .
Expected behavior:
- Dashboard widgets persist across server restarts
- Layout preferences saved to database via go-options or go-users
- Widget data fetched from CMS widget store
- Activity hooks integrated with dashboard events
- Console log shows:
Dashboard: go-dashboard (persistent, requires CMS)
Verify integration:
# Start server
go run . &
# Test JSON API
curl -s http://localhost:8080/admin/api/dashboard | jq '.areas[0].widgets[0].id'
# Test HTML SSR
curl -s http://localhost:8080/admin/dashboard | grep "dashboard-state"
# Test preferences persistence
curl -s -X POST http://localhost:8080/admin/api/dashboard/preferences \
-H "Content-Type: application/json" \
-d '{"areas":{"admin.dashboard.main":[{"id":"widget-1","span":6}]}}'
Activity Tracking
Activity Hooks Pattern
The example demonstrates the activity hooks pattern with bidirectional flow:
- Admin Activity Feed → Dashboard Hooks
- Command Execution → Dashboard Hooks
- Panel CRUD Operations → Activity Sink
See main.go for the composite activity sink setup; config flags can swap the primary sink:
features.go_users_activity=true(APP_FEATURES__GO_USERS_ACTIVITY=true) enables the go-users sink when available; otherwise falls back to in-memory.features.go_options=true(APP_FEATURES__GO_OPTIONS=true) swaps the settings backend to go-options; falls back to in-memory settings.
dashboardHooks := dashboardactivity.Hooks{
dashboardactivity.HookFunc(func(ctx context.Context, event dashboardactivity.Event) error {
log.Printf("[Dashboard Activity] %s %s %s:%s",
event.ActorID, event.Verb, event.ObjectType, event.ObjectID)
return nil
}),
}
dashboardCfg := dashboardactivity.Config{
Enabled: true,
Channel: "admin",
}
compositeActivitySink := quickstart.NewCompositeActivitySink(
adm.ActivityFeed(),
dashboardHooks,
dashboardCfg,
)
adm.WithActivitySink(compositeActivitySink)
Activity Events
Commands emit activity events via the hooks pattern:
type UserActivateCommand struct {
store *stores.UserStore
activityHooks activity.ActivityHookSlice
}
func (c *UserActivateCommand) Execute(ctx admin.AdminContext) error {
// ... perform activation ...
c.activityHooks.Notify(ctx.Context, activity.Event{
Channel: "users",
Verb: "activated",
ObjectType: "user",
ObjectID: userID,
Data: map[string]any{
"email": user.Email,
"name": user.Name,
},
})
return nil
}
Activity backends & smoke
- Flag:
features.go_users_activity=true(APP_FEATURES__GO_USERS_ACTIVITY=true) swaps the activity sink to the go-users adapter (examples/web/setup/activity.go) while keeping dashboard hooks and the in-memory fallback buffer. - Smoke (in-memory): start normally, create a page (
POST /admin/api/content), update a post (PUT /admin/api/content/<id>), delete a media item (DELETE /admin/api/media/<id>), thenGET /admin/api/activity?limit=10and confirmentriesshowpage:<id>,post:<id>,media:<id>with actor + metadata. - Smoke (go-users): restart with the flag, repeat the same create/update/delete calls, and verify
/admin/api/activitystill returns the new events inentrieswith intact IDs/actors; toggle the flag off again to confirm the feed keeps working.
Persistent CMS (go-cms + Bun/SQLite)
- Default: the example boots with go-cms persistence enabled. The Bun storage adapter lives in
examples/web/setup/cms_persistent.go. Configure DSN viadatabases.cms_dsn(APP_DATABASES__CMS_DSN). - Migrations: applied automatically at startup via go-persistence-bun using the embedded go-cms SQL migrations (no manual migration step needed).
- Requirements: SQLite driver (sqliteshim is bundled; no additional install) and write access to the DSN path.
- Test log noise control (for suites using
SetupPersistentCMS):- Default under
go test: runtime CMS mutation logs are off. - Enable for a specific run:
go test ./examples/web -args -cms-test-logs - Force on/off via config:
APP_CMS__RUNTIME_LOGS=true|false
- Default under
- Smoke:
go run ./examples/web→ logs showCMS backend: go-cms (sqlite).- Call
GET /admin/api/navigationand confirm menus load; stop the server, restart with the same DSN, and confirm the menu payload still resolves (data persisted). - Optional: point
APP_DATABASES__CMS_DSNto a different file path and confirm a fresh database is created with the same migrations applied.
CMS bootstrap hooks for host-defined content types
If the host app needs to create or repair content types like news or quote
before admin routes are finalized, register that work with
adm.AddCMSBootstrapHook(...) and then call adm.Initialize(...). The admin
runtime now reconciles dynamic CMS panels during startup, so later
quickstart.RegisterContentEntryUIRoutes(...) calls see canonical routes like
/admin/news and /admin/quotes without extra host-side boot ordering.
adm.AddCMSBootstrapHook(func(ctx context.Context, adm *admin.Admin) error {
_, err := adm.ContentTypeService().CreateContentType(ctx, admin.CMSContentType{
Name: "Quote",
Slug: "quote",
Status: "active",
Schema: map[string]any{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": map[string]any{"title": map[string]any{"type": "string"}},
},
Capabilities: map[string]any{"panel_slug": "quotes"},
})
return err
})
if err := adm.Initialize(server.Router()); err != nil {
return err
}
Content (Pages + Posts)
- Pages and Posts panels use the go-cms backend by default (container from
examples/web/setup/cms_persistent.go). - Navigation seeds a
Contentparent with canonical quickstart permissions:admin.pages.view,admin.posts.view,admin.media.view,admin.content_types.view,admin.block_definitions.view. - Existing persisted menus are reconciled on startup (
setup.EnsureContentParentPermissions) so those permissions are merged in-place without forcingAPP_NAVIGATION__RESET_MENU=true. - CMS-backed stores emit
page:<id>/post:<id>activity (actor + slug/status metadata) and drive search adapters from go-cms data, keeping search and activity aligned with the persistent backend. - Smoke:
APP_DATABASES__CMS_DSN=file:/tmp/go-admin-cms.db?cache=shared&_fk=1 go run ./examples/web, create/edit/delete a page and a post via/admin/content/pagesand/admin/content/posts, confirm/admin/api/search?query=<slug>returns them and/admin/api/activity?limit=5shows create/update/delete inentrieswith correct actors.
User & Role Management
The example includes a complete user management module (admin/users.go) with:
UserManagementService- orchestrates CRUD and role assignmentsUserRepositoryandRoleRepositoryinterfacesInMemoryUserStore- default in-memory implementationGoUsersUserRepositoryandGoUsersRoleRepository- go-users adapters
Using In-Memory Store (Default)
service := admin.NewUserManagementService(nil, nil)
// Uses InMemoryUserStore for both users and roles
Quickstart Wiring (Example)
The example uses the quickstart helper to wire go-users repositories and the profile store. ScopeResolver can be omitted for the standard quickstart path; when omitted, quickstart uses quickstart.ScopeBuilder(cfg) so single-tenant default scope still applies:
scopeResolver := quickstart.ScopeBuilder(cfg)
adm, _, err := quickstart.NewAdmin(
cfg,
adapterHooks,
quickstart.WithGoUsersUserManagement(quickstart.GoUsersUserManagementConfig{
AuthRepo: usersDeps.AuthRepo,
InventoryRepo: usersDeps.InventoryRepo,
RoleRegistry: usersDeps.RoleRegistry,
ProfileRepo: usersDeps.ProfileRepo,
ScopeResolver: scopeResolver, // optional; omitted uses quickstart.ScopeBuilder(cfg)
}),
)
if err != nil {
return err
}
For manual wiring (outside quickstart), build repositories with the same config-aware resolver:
authRepo := // go-users AuthRepository
inventory := // go-users UserInventoryRepository
roleRegistry := // go-users RoleRegistry
userRepo := admin.NewGoUsersUserRepository(authRepo, inventory, scopeResolver)
roleRepo := admin.NewGoUsersRoleRepository(roleRegistry, scopeResolver)
service := admin.NewUserManagementService(userRepo, roleRepo)
service.WithActivitySink(adm.ActivityFeed())
Multi-Tenant Support
The example seeds demo tenants and organizations:
if svc := adm.TenantService(); svc != nil {
tenant, _ := svc.SaveTenant(ctx, admin.TenantRecord{
Name: "Acme Corp",
Slug: "acme",
Status: "active",
Domain: "acme.local",
Members: []admin.TenantMember{
{UserID: "user-1", Role: "owner"},
},
})
}
Panels are registered for tenants and organizations:
/admin/api/tenants- Tenant CRUD/admin/api/organizations- Organization CRUD
Settings Management
Dynamic settings with hierarchical scopes (system/site/user):
- Default in-memory backend:
setup.SetupSettings(adm)viasetup/settings.go - go-options backend: set
features.go_options=true(APP_FEATURES__GO_OPTIONS=true) to route throughsetup.SetupSettingsWithOptions(adm)insetup/settings_options.go(adds go-options scope metadata + snapshot IDs, seeds a site override forfeatures.release_channel)
Endpoints:
GET /admin/api/settings/form- Form schema with current values + go-options scopesPOST /admin/api/settings- Update settings with validation
Go-options flag smoke checklist:
APP_FEATURES__GO_OPTIONS=true go run ./examples/webthen hit/admin/api/settings/form→scopesincludesource: go-optionsand snapshot IDs;features.release_channelshowsscope: site- POST
/admin/api/settingswith{ "values": { "performance.cache_ttl": -1 }, "scope": "site" }→400withfields.performance.cache_ttlerror andmetadata.scopein response - POST
/admin/api/settingswith{ "values": { "features.release_channel": "stable" }, "scope": "site" }→ subsequent GET returnsprovenance: siteand the new value
Modules
The example demonstrates the module pattern:
Registering Modules
usersModule := quickstart.NewUsersModule(
admin.WithUserMenuParent(setup.NavigationGroupMain),
admin.WithUserProfilesPanel(),
admin.WithUserPanelTabs(admin.PanelTab{
ID: "activity",
Label: "Activity",
Scope: admin.PanelTabScopeDetail,
Target: admin.PanelTabTarget{Type: "path", Path: "/admin/activity"},
}),
)
modules := []admin.Module{usersModule}
Built-in Modules
- Users Module - user management panel with activate/deactivate commands
- Pages Module - CMS pages with publish/unpublish
- Posts Module - blog posts with categories
- Media Module - file uploads with metadata
- Tenants Module - multi-tenant management
- Organizations Module - organization hierarchy
- Preferences Module - user preferences
- Profile Module - user profile management
Command Pattern
Commands are first-class operations triggered via:
- Panel actions (single record)
- Bulk actions (multiple records)
- Jobs (scheduled/manual)
- CLI (if implementing
CommandWithCLI)
Implementing Commands
type UserActivateCommand struct {
store *stores.UserStore
activityHooks activity.ActivityHookSlice
}
func (c *UserActivateCommand) Name() string {
return "users:activate"
}
func (c *UserActivateCommand) Execute(ctx admin.AdminContext) error {
userID := ctx.Context.Value("user_id").(string)
// ... perform activation ...
c.activityHooks.Notify(ctx.Context, activity.Event{...})
return nil
}
// Optional: CLI metadata
func (c *UserActivateCommand) CLIMetadata() admin.CLIMetadata {
return admin.CLIMetadata{
Path: "users:activate",
Description: "Activate a user account",
Flags: []admin.CLIFlag{
{Name: "user-id", Type: "string", Required: true},
},
}
}
Registering Commands
activateCmd := commands.NewUserActivateCommand(m.store).
WithActivityHooks(activityAdapter)
ctx.Admin.Commands().Register(activateCmd)
Extending the Example
Adding a New Module
- Create module struct implementing
admin.Module - Implement
Manifest()andRegister(ctx admin.ModuleContext) - Register panels, commands, search providers in
Register() - Wire activity hooks if needed
- Register module in
main.go
Adding a New Panel
builder := ctx.Admin.Panel("my-resource").
WithRepository(myRepo).
ListFields(
admin.Field{Name: "name", Label: "Name", Type: "text"},
admin.Field{Name: "status", Label: "Status", Type: "select"},
).
FormFields(
admin.Field{Name: "name", Label: "Name", Type: "text", Required: true},
).
WithFilters(
admin.Filter{Field: "status", Label: "Status", Type: "select", Options: statusOptions},
).
WithActions(
admin.Action{
Name: "my-action",
Label: "My Action",
Command: "my-resource:my-action",
},
)
Swapping Adapters
The example uses in-memory implementations by default. Swap with production adapters:
Authentication: Replace demoIdentityProvider with database-backed provider
Settings: Wire go-options registry (Phase 18 adapter available)
Activity: Set APP_FEATURES__GO_USERS_ACTIVITY=true to use the go-users ActivityLogger adapter (Phase 17) defined in setup/activity.go. The example wires the read path with admin.Dependencies{ActivityRepository: usersDeps.ActivityRepo, ActivityAccessPolicy: activity.NewDefaultAccessPolicy()} in examples/web/main.go so /admin/api/activity uses the go-users policy.
CMS: Wire go-cms persistent container (Phase 20 adapter available)
Architecture
Directory Structure
examples/web/
├── main.go # Application entry point
├── modules.go # Module registration
├── commands/ # Command implementations
│ ├── user_commands.go
│ ├── page_commands.go
│ ├── post_commands.go
│ └── media_commands.go
├── handlers/ # HTTP handlers
├── helpers/ # Utility functions
├── jobs/ # Job definitions
├── pkg/activity/ # Activity hooks package
├── search/ # Search providers
├── setup/ # Setup functions
│ ├── auth.go # go-auth integration
│ ├── dashboard.go # Dashboard widgets
│ └── settings.go # Settings definitions
├── stores/ # Data stores (in-memory)
├── pkg/client/templates/ # HTML templates
└── openapi/ # OpenAPI specs
pkg/client/assets/ # Admin client assets (source + dist)
Integration Points
- Authenticator:
admin.WithAuth(authenticator, authConfig) - Authorizer:
admin.WithAuthorizer(authorizer) - Activity Sink:
admin.WithActivitySink(sink) - Theme Provider:
admin.WithThemeProvider(provider)for custom admin providers, oradm.WithAdminTheme(selector)forgo-theme; see../../docs/GUIDE_THEME.md - CMS Container:
admin.Config.CMS.Container
Production Considerations
Security
- Replace demo signing key: Use environment variable for JWT secret
- HTTPS: Enable TLS in production
- CORS: Configure allowed origins
- Rate Limiting: Implement per-user rate limits
- Session Storage: Use Redis/PostgreSQL instead of in-memory
Database
- Persistent Storage: Replace in-memory stores with PostgreSQL/MySQL
- Migrations: Use go-migrate or similar
- Connection Pooling: Configure appropriate pool sizes
Monitoring
- Activity Logging: Ship activity events to log aggregator
- Metrics: Export Prometheus metrics
- Tracing: Integrate OpenTelemetry
Deployment
- Environment Variables: Externalize configuration
- Health Checks: Monitor
/admin/healthendpoint - Graceful Shutdown: Handle SIGTERM signals
- Horizontal Scaling: Ensure session state is shared
References
- go-admin Core Documentation
- Archived ADMIN_TDD.md - Historical architecture decisions
- ADMIN_TSK.md - Implementation tasks
- DASH_TDD.md - Dashboard SSR technical design
- DASH_TSK.md - Dashboard SSR implementation plan
- go-auth - Authentication library
- go-users - User management
- go-options - Settings management
- go-dashboard - Dashboard widgets
License
See root LICENSE file
External activity producers
This example's existing hooks and read UI remain unchanged. Hosts needing durable external business events can opt into the command/service ingestion bundle after explicit migrations and producer policy setup. See the Activity guide for the shared scope configuration, named URL override, released dependency requirements and full feed/widget read wiring. Memory or best-effort hooks alone do not provide transactional ingestion or persistent replay protection.
Documentation
¶
There is no documentation for this package.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package datamodule exposes the shared synthetic dataset reference adapter for the kitchen-sink application.
|
Package datamodule exposes the shared synthetic dataset reference adapter for the kitchen-sink application. |
|
internal
|
|
|
passwordhash
Package passwordhash owns password hashing policy for the example application.
|
Package passwordhash owns password hashing policy for the example application. |
|
pkg
|
|
|
activity
Package activity implements the activity-hooks pattern for event emission.
|
Package activity implements the activity-hooks pattern for event emission. |