Vizier Admin Panels
Vizier turns model definitions into an administration panel: you describe a
model once as an AdminResource, and Vizier renders its index, create, edit
and show pages, manages its relationships, runs your row and bulk actions, and
enforces your authorization hooks on every request.
Every page is server-rendered HTML behind Anubis sessions. There is no build step, no npm, and no client-side API: the CSS and JavaScript ship inside the package, and Alpine only upgrades links that already work without it.
The developer path
1. Dependencies
dependencies: maat: ^0.1.0 vizier: ^0.1.0 anubis: ^0.1.0 # web sessions, CSRF, the signed-in user khnum_maat: ^0.1.0 # the view layer the panel renders through seshat_maat: ^0.1.0 # models, relationships, migrations2. Providers
The panel resolves the anubis.session and anubis.csrf middleware aliases
when it is registered, so the provider that registers your panel must boot
after the providers that publish them:
providers: [ DatabaseServiceProvider.new, ViewServiceProvider.new, AnubisServiceProvider.new, VizierServiceProvider.new, AdminServiceProvider.new, // yours, last],Register the commands in the executable that runs migrations and generators:
commands: [ ...databaseCommands(migrations: migrations, seeders: [DatabaseSeeder()]), ...anubisCommands(), ...vizierCommands(),],3. Migrations
Vizier stores nothing of its own. It needs Anubis’s session table and your application’s tables:
final migrations = <Migration>[...anubisMigrations, CreateApplicationTables()];dart run bin/maat.dart migrate4. maat vizier:install
dart run bin/maat.dart vizier:installThis publishes, once:
| Path | Contents |
|---|---|
config/vizier.dart |
Panel defaults (path). |
lib/app/vizier/resources.dart |
The registry make:admin-resource writes into. |
resources/views/vendor/vizier/ |
The Khnum templates: layout, guest layout, login, dashboard, index, form, show, relation, action. |
public/vendor/vizier/ |
vizier.css, vizier.js, alpine.min.js, and Alpine’s license. |
A normal install never overwrites a file your application already has, so
customized templates survive an upgrade. To take newer package defaults, diff
your overrides first and then run vizier:install --force.
5. maat make:admin-resource User
dart run bin/maat.dart make:admin-resource Userdart run bin/maat.dart make:admin-resource Post --model=BlogPostThe generator writes lib/app/vizier/resources/user_resource.dart and adds
the import and the list entry to lib/app/vizier/resources.dart at its two
markers. Pass --force to overwrite an existing resource file. There is no
reflection and no model-source parsing: the generated resource is ordinary
Dart you then fill in.
6. Register the panel
class AdminServiceProvider extends ServiceProvider { AdminServiceProvider(super.app);
@override void boot() { Anubis.provider('users', (id) => User.query().find(int.parse(id)));
Vizier.panel( Panel( title: 'Booking Admin', brandMark: 'B', userProvider: 'users', authenticate: (email, password) async { final user = await User.query().where('email', email).first(); final valid = await Hash.checkAsync( password, user?.authPassword ?? await _dummyHash, ); return user != null && valid && user.isStaff ? user : null; }, resources: vizierResources, ), ); }}authenticate returns the user to sign in, or null to reject; compare
against a dummy hash when no user matches so a wrong address and a wrong
password cost the same time. userProvider names the Anubis provider that
reloads the user on later requests. The panel mounts at /admin; pass path
to move it.
7. Resource form and table
class SlotAdminResource extends AdminResource<Slot> { @override ModelDefinition<Slot> get model => Slot.def;
@override String get slug => 'slots';
@override String get singularLabel => 'Slot';
@override String get pluralLabel => 'Slots';
@override String get titleAttribute => 'starts_at';
@override AdminForm form(AdminForm form) => form.fields([ NumberField('resource_id', label: 'Resource id').integer().required(), DateTimeField('starts_at').required(), DateTimeField('ends_at').required(), SelectField( 'status', options: const {'open': 'Open', 'held': 'Held'}, ).required(), ]);
@override AdminTable<Slot> table(AdminTable<Slot> table) => table .columns([ DateTimeColumn<Slot>('starts_at').sortable(), DateTimeColumn<Slot>('ends_at'), BooleanColumn<Slot>('confirmed'), ]) .defaultSort('starts_at') .perPageOptions([25, 50]);}Fields: TextField, EmailField, PasswordField, TextareaField,
NumberField, BooleanField, SelectField, DateField, DateTimeField,
HiddenField. Columns: TextColumn, BooleanColumn, DateColumn,
DateTimeColumn, ComputedColumn.
Only declared members reach the database. A submitted sort, search or field
name that is not registered is ignored, and a write persists only validated
declared fields — which then still pass through Seshat’s fillable/guarded
checks. Password values are never redisplayed, logged, or flashed.
Override query() to scope what the panel can ever see, and
beforeCreate/afterCreate/beforeUpdate/afterUpdate to hook writes.
8. Authorization: safe by default
Every mutation hook returns false until you override it. Reading is the only
default-open operation, and canViewAny/canView still gate it:
@overridebool canViewAny(Request request) => request.user<User>()?.isStaff ?? false;
@overridebool canView(Request request, Slot record) => canViewAny(request);
@overridebool canCreate(Request request) => _isAdmin(request);
@overridebool canUpdate(Request request, Slot record) => _isAdmin(request);
@overridebool canDelete(Request request, Slot record) => _isAdmin(request);
@overridebool canRunAction(Request request, AdminAction<Slot> action, Slot record) => _isAdmin(request);Hooks receive the current Request, so the rule that guards your API guards
the panel. Every mutation is a POST behind the session and CSRF middleware,
reloads its records from the database, and authorizes on the server — a hidden
form field can never widen what a request may touch.
9. Row and bulk actions
@overrideList<AdminAction<Booking>> actions() => [ AdminAction( name: 'mark-no-show', label: 'Mark no-show', localizedLabels: const {'ar': 'تسجيل عدم الحضور'}, icon: VizierIcon.check, requiresConfirmation: true, form: (form) => form.fields([TextareaField('note').required()]), handler: (request, records, values) async { for (final record in records) { await bookings.markNoShow(record, note: values['note'] as String); } return ActionResult(message: '${records.length} bookings updated.'); }, ), AdminAction( name: 'export', label: 'Export', bulk: true, handler: (request, records, values) async => const ActionResult(message: 'Export queued.'), ),];A row action is a link on the index and show pages; a bulk action is a submit
button under the index table, and the rows it runs on are the checked ones.
Both open a real page first — a bulk action at
/admin/{resource}/actions/{name}, a row action at
/admin/{resource}/{id}/actions/{name} — so an action with a form collects
its input without JavaScript, and one with requiresConfirmation: true states
in writing that it cannot be undone before the button appears.
ActionResult.redirectTo must stay inside the panel; anything else falls back
to the resource index.
10. Relation managers
A resource lists the relations that may be managed from its pages. Every relation operation is denied until the subclass opens it:
class PostsRelation extends AdminRelation<User, Post> { PostsRelation(AdminResource<Post> posts) : super(name: 'posts', relatedResource: posts);
@override bool canCreate(Request request, User owner) => _isAdmin(request);
@override bool canAssociate(Request request, User owner, Post related) => _isAdmin(request);
@override bool canDissociate(Request request, User owner, Post related) => _isAdmin(request);}
@overrideList<AdminRelation<User, dynamic>> relations() => [PostsRelation(posts)];name is the relation on the owner’s ModelDefinition and the URL segment:
/admin/{resource}/{id}/relations/{name}. The related resource supplies the
table columns, the form, the record title and the read policy for every
related row.
Direct relations
| Seshat relation | Page offers | Hooks |
|---|---|---|
belongsTo |
The current parent, a selection page, associate and dissociate. | canAssociate, canDissociate |
hasOne |
The single child: create or associate an existing row while empty, edit, delete, dissociate. | canCreate, canAssociate, canDissociate, canUpdate, canDelete |
hasMany |
The scoped list, create, edit, delete, associate an existing row, dissociate. | canCreate, canAssociate, canDissociate, canUpdate, canDelete |
belongsToMany |
Members, a selection page with checkboxes, attach, detach, and a sync form that replaces the set. |
canAttach, canDetach |
belongsToMany pivot columns are collected by pivotForm, validated like any
other form, and written only for the rows being attached:
class RolesRelation extends AdminRelation<User, Role> { RolesRelation(AdminResource<Role> roles) : super( name: 'roles', relatedResource: roles, pivotForm: _pivot, );
static AdminForm _pivot(AdminForm form) => form.fields([TextField('granted_by').required()]);}Polymorphic relations
| Seshat relation | Page offers | Hooks |
|---|---|---|
morphOne |
The single related record: create or associate an existing row while empty, edit, delete, dissociate. | canCreate, canAssociate, canDissociate, canUpdate, canDelete |
morphMany |
The scoped list, create, edit, delete, associate an existing row, dissociate. | canCreate, canAssociate, canDissociate, canUpdate, canDelete |
morphTo |
A type chooser, then a selection page for that type, associate and dissociate. | canPointAt |
morphToMany / morphedByMany |
Members, checkbox selection, attach, detach, sync, and pivot fields. |
canAttach, canDetach |
A morphTo relation declares its own allowlist. The submitted
related_type is checked against morphTargets — never against the global
MorphMap — and each target names the resource that loads and authorizes its
records:
CommentableRelation() : super( name: 'commentable', relatedResource: posts, // label and chooser columns only morphTargets: [ MorphTarget(alias: 'posts', resource: posts), MorphTarget(alias: 'videos', resource: videos), ], );
@overridebool canPointAt(Request request, Comment owner, Model related, String alias) => _isAdmin(request);Selecting without JavaScript
Every relation page has a full-page selector at
/admin/{resource}/{id}/relations/{name}/select. It is searchable and
paginated, each candidate row carries its own attach/associate form, and a
many-to-many relation additionally renders one checkbox per row plus a sync
form. Members that fall outside the current page ride along as hidden inputs,
so submitting page one never detaches the rest. Pivot values submitted with
sync are validated, and a rejected submission re-renders the page with the
error and the selection intact.
11. Custom pages
class ReportsPage extends VizierPage { @override String get slug => 'reports';
@override String get title => 'Reports';
@override Map<String, String> get localizedTitles => const {'ar': 'التقارير'};
@override String get view => 'admin.reports';
@override bool canView(Request request) => request.user<User>()?.isStaff ?? false;
@override Future<Map<String, Object?>> data(Request request) async => { 'published': await Post.query().where('published', true).count(), };}Register with pages: [ReportsPage()]. The page is served at
/admin/pages/{slug} behind the same session, authentication and CSRF
middleware as the rest of the panel; canView runs before data, so a denied
page never reads anything. The view receives your data map nested under
data, alongside the panel context (user, locale, direction,
translations, navigation, csrfField) — a caller’s key can never
overwrite the context. Extend vendor.vizier.layout for the panel shell, or
render your own document to get a standalone page.
12. Dashboard and widgets
class ContentOverviewWidget extends VizierWidget { @override String get view => 'admin.widgets.content_overview';
@override bool canView(Request request) => request.user<User>()?.isStaff ?? false;
@override Future<Map<String, Object?>> data(Request request) async => { 'posts': await Post.query().count(), };}
Panel( // … dashboard: DashboardPage( widgets: [ContentOverviewWidget(), RecentPostsWidget()], ),);Widgets render in declaration order; a widget whose canView returns false
is skipped before its data runs. Passing your own VizierPage as
dashboard replaces the packaged dashboard entirely.
13. Locale resolution and localized labels
Panel( defaultLocale: 'en', supportedLocales: const {'en', 'ar'}, fallbackLocale: 'en', rtlLocales: const {'ar'}, localeResolver: (request) => request.session.get('locale') as String? ?? 'en', translations: const { 'ar': {'reports.heading': 'التقارير'}, },);localeResolver reads the language; a resolved locale outside
supportedLocales falls back to defaultLocale. Vizier ships English and
Arabic catalogs, merges extension catalogs, then your translations on top,
and looks a missing key up in fallbackLocale. Arabic sets dir="rtl" on the
document, and the stylesheet is written in logical properties, so the layout
mirrors without a second sheet.
Labels follow the same resolution: singularLabelFor/pluralLabelFor on a
resource, localizedTitles on a page, and localizedLabels on an action and
on a column. Vizier owns no route that writes a locale — the application
decides where the choice is stored; the example ships a small POST /admin/locale route that writes it to the session.
14. VizierExtension
A package contributes a whole section of the panel under one name:
VizierExtension( name: 'audit_trail', resources: [AuditEntryResource()], pages: [AuditTimelinePage()], dashboardWidgets: [RecentAuditWidget()], translations: const { 'en': {'audit.title': 'Audit trail'}, 'ar': {'audit.title': 'سجل التدقيق'}, },);
Panel(resources: [...], extensions: [auditTrailExtension()]);Extension contributions are registered exactly like the application’s own:
same slug uniqueness, same navigation ordering, same authorization hooks. Two
extensions may not claim the same translation key — registration fails and
names the packages — while the application’s own translations always win. See
Community extensions.
15. Appearance
The panel wears Maat’s own identity rather than a stock framework palette. The
values come from brand/README.md:
| Token | Hex | Where it lands |
|---|---|---|
| Nile | #0B1628 |
Navigation rail in both themes, ink on light, solid buttons on light |
| Limestone | #FFF9EA |
Rail text, and the label on a solid button |
| Solar Gold | #D6A84B |
The brand tile, the active navigation marker, and the solid button in dark mode |
| Faience | #27B7A8 |
Success and flash messages |
| Oxide | #C7543D |
Delete affordances and the danger zone |
Gold never carries body text: on the Nile rail it reads at 8.25:1, on a light
surface it is decoration only. The solid action inverts between themes — Nile
with Limestone text on the light canvas, Solar Gold with Nile text on the dark
one — so the same pair keeps its contrast either way.
Everything the panel paints resolves through one layer of semantic custom
properties, so an application restyles the panel without touching a component
rule. The stylesheet is yours once vizier:install publishes it, so add the
override at the top of public/vendor/vizier/vizier.css:
:root { --vizier-canvas: #f6f6f7; --vizier-rail-bg: #101828; /* the navigation column */ --vizier-accent: #2563eb; /* brand tile, active navigation marker */ --vizier-solid-bg: #2563eb; /* primary buttons */ --vizier-solid-fg: #ffffff;}
.dark { --vizier-rail-bg: #0b101b;}The full set is --vizier-canvas, --vizier-surface, --vizier-elevated,
--vizier-border, --vizier-hairline, --vizier-foreground,
--vizier-muted, --vizier-accent, --vizier-success, --vizier-warning,
--vizier-danger, --vizier-focus, the rail’s --vizier-rail-bg,
--vizier-rail-fg, --vizier-rail-muted, --vizier-rail-border,
--vizier-rail-hover, and the solid action’s --vizier-solid-bg,
--vizier-solid-fg, --vizier-solid-hover.
Dark mode is a dark class on <html>, written before first paint by an
inline script so there is no flash, remembered in localStorage, and defaulted
from prefers-color-scheme. The toggle lives in the top bar.
The layout is written in logical properties throughout, so Arabic mirrors from
dir="rtl" alone. Below 48rem the rail becomes a drawer, every control grows
to a 2.75rem touch target, and a wide table scrolls inside its own card
instead of the document. Focus is a 3px ring at a 3px offset on every
interactive element, and prefers-reduced-motion disables transitions.
16. Precompiled assets
packages/vizier/resources/css/vizier.css and resources/js/vizier.js are
compiled into generated Dart constants in one file
(lib/src/generated/vizier_assets.g.dart), so the runtime never reads a file
from the package directory and an AOT-compiled binary can still publish them.
After editing either source:
cd packages/vizierdart run tool/build_vizier_assets.dart # regeneratedart run tool/build_vizier_assets.dart --check # CI: fail if staleAlpine is vendored at a pinned version with its license, and its bytes are verified against a checksum fixture in the test suite. Nothing is fetched from a CDN at runtime.
17. Deployment
dart compile exe bin/server.dart -o build/adminAPP_BASE_PATH=/srv/admin ./build/adminAn application whose dependencies include a package with native build hooks —
sqlite3, for instance — is built with dart build instead, which emits the
same AOT executable plus the native libraries beside it:
dart build cli --target bin/server.dart -o build/aotAPP_BASE_PATH=/srv/admin ./build/aot/bundle/bin/serverThe binary needs the published resources/views/ and public/ directories
next to it — set APP_BASE_PATH when it runs from elsewhere. Serve public/
(the example does it with the PublicFiles middleware), keep APP_DEBUG
false, set APP_KEY, and run migrations before the first boot. Sessions are
cookies over the Anubis session table, so several instances share one database
without sticky sessions.
Scope
Vizier’s alpha deliberately stops here:
- One panel. A single panel is registered per application, mounted at
/adminby default. There is no multi-panel infrastructure. - No reflection or source parsing. Every slug, field, column, relation, morph target and action comes from a registered definition before request data can select it.
- No npm, SPA state store, or client-side CRUD API. Main CRUD and every relation operation keep a normal page route that works with JavaScript disabled.
- A fixed field list: text, email, password, textarea, number, boolean, select, date, datetime, hidden.
Out of scope for the alpha: file uploads, charts, global search, arbitrary filters, soft-delete management, multi-tenancy, MFA, OAuth, runtime themes, and marketplace or plugin discovery.
Related
- Anubis Web Sessions — the session, CSRF and authentication layer the panel sits on.
- Seshat ORM — models and the relationships the relation managers drive.
- Community extensions — packaging a section of the panel for other applications.
examples/vizier— an end-to-end panel with every relation family, a custom page, two dashboard widgets and an Arabic locale switch.