Skip to content

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, migrations

2. 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()];
Terminal window
dart run bin/maat.dart migrate

4. maat vizier:install

Terminal window
dart run bin/maat.dart vizier:install

This 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

Terminal window
dart run bin/maat.dart make:admin-resource User
dart run bin/maat.dart make:admin-resource Post --model=BlogPost

The 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:

@override
bool canViewAny(Request request) => request.user<User>()?.isStaff ?? false;
@override
bool canView(Request request, Slot record) => canViewAny(request);
@override
bool canCreate(Request request) => _isAdmin(request);
@override
bool canUpdate(Request request, Slot record) => _isAdmin(request);
@override
bool canDelete(Request request, Slot record) => _isAdmin(request);
@override
bool 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

@override
List<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);
}
@override
List<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),
],
);
@override
bool 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:

Terminal window
cd packages/vizier
dart run tool/build_vizier_assets.dart # regenerate
dart run tool/build_vizier_assets.dart --check # CI: fail if stale

Alpine 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

Terminal window
dart compile exe bin/server.dart -o build/admin
APP_BASE_PATH=/srv/admin ./build/admin

An 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:

Terminal window
dart build cli --target bin/server.dart -o build/aot
APP_BASE_PATH=/srv/admin ./build/aot/bundle/bin/server

The 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 /admin by 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.

  • 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.