Tutorial 10: Create an admin dashboard
Objective: add an admins-only dashboard to the to-do app: totals computed by the database, a bar chart, a live table of everyone's tasks, and a dashboard layout with a sidebar.
Prerequisites: Tutorial 8.
Step 1: Who is an admin
The seeded admin@example.com has "role": "admin". Roles are set by your data — seeds, an admin action, the database — never by a request. Admins can read every task because the task schema says "read": ["admin", "owner"], and every user because the user schema says "read": ["admin", "self"].
Step 2: A dashboard layout
layouts/admin.layout.iwc
{
"name": "AdminLayout",
"className": "admin-shell",
"slots": {
"header": "header.component.iwc",
"sidebar": {
"type": "nav",
"className": "side-nav",
"items": [
{ "label": "Overview", "target": "/admin" },
{ "label": "My tasks", "target": "/tasks" }
]
},
"content": "PAGE_CONTENT"
},
"template": ["header header", "sidebar content"],
"structure": {
"header": { "position": "sticky" },
"sidebar": { "width": "220px", "mobile": "hidden" }
},
"style": { "gridTemplateColumns": "220px 1fr", "minHeight": "100vh" }
}templatearranges slots as CSS grid areas: the header across the top, the sidebar and content below.- The sidebar is an inline
navelement;"mobile": "hidden"hides it on phones.
Step 3: A stat component
components/stat.component.iwc
{
"name": "Stat",
"props": {
"label": { "type": "string", "required": true },
"value": { "type": "number", "required": true }
},
"root": {
"type": "column",
"className": "stat",
"layout": { "direction": "column", "gap": "4px" },
"elements": [
{ "type": "text", "variant": "caption", "bind": "props.label" },
{ "type": "text", "variant": "h2", "bind": "props.value", "format": "number" }
]
}
}Step 4: The dashboard screen
ui/admin.ui.iwc
{
"name": "Admin",
"route": "/admin",
"layout": "admin.layout.iwc",
"auth": "required",
"roles": ["admin"],
"title": "Admin dashboard",
"sections": [
{
"type": "section",
"className": "dash",
"layout": { "direction": "column", "gap": "28px" },
"elements": [
{ "type": "text", "variant": "h1", "content": "Dashboard" },
{
"type": "grid",
"columns": 3,
"responsiveColumns": { "mobile": 1 },
"gap": "16px",
"elements": [
{
"type": "box",
"data": { "source": "DATABASE.tasks", "mode": "single", "aggregate": { "measures": { "total": { "count": true } } } },
"elements": [{ "type": "component", "use": "stat.component.iwc", "props": { "label": "Tasks", "value": { "bind": "total" } } }]
},
{
"type": "box",
"data": { "source": "DATABASE.tasks", "mode": "single", "filter": { "done": true }, "aggregate": { "measures": { "total": { "count": true } } } },
"elements": [{ "type": "component", "use": "stat.component.iwc", "props": { "label": "Completed", "value": { "bind": "total" } } }]
},
{
"type": "box",
"data": { "source": "DATABASE.users", "mode": "single", "aggregate": { "measures": { "total": { "count": true } } } },
"elements": [{ "type": "component", "use": "stat.component.iwc", "props": { "label": "Members", "value": { "bind": "total" } } }]
}
]
},
{
"type": "column",
"className": "panel",
"elements": [
{ "type": "text", "variant": "h3", "content": "Open tasks by priority" },
{
"type": "chart",
"chart": { "kind": "bar", "x": "priority", "y": "tasks", "height": 200 },
"data": {
"source": "DATABASE.tasks",
"filter": { "done": false },
"aggregate": { "groupBy": ["priority"], "measures": { "tasks": { "count": true } } },
"sort": { "field": "priority" }
}
}
]
},
{
"type": "column",
"className": "panel",
"elements": [
{ "type": "text", "variant": "h3", "content": "Latest tasks" },
{
"type": "table",
"data": { "source": "DATABASE.tasks", "columns": ["title", "priority", "done", "createdAt"], "sort": { "field": "createdAt", "order": "desc" }, "limit": 20, "live": true },
"cells": [
{ "field": "title", "label": "Task" },
{ "field": "priority", "label": "Priority" },
{ "field": "done", "label": "Done" },
{ "field": "createdAt", "label": "Created", "format": "date" }
],
"empty": { "type": "text", "content": "No tasks yet." }
}
]
}
]
}
]
}What each block does:
- Stats. Each
boxhas a single-modeDATABASEsource withaggregate: the database counts the rows (under the same access rules) and the box bindstotalinto the stat component with{"bind": "total"}. The second adds"filter": {"done": true}. - Chart.
aggregatewithgroupBy: ["priority"]returns one record per priority with ataskscount. Thechartelement draws it as an inline SVG bar chart on the server — no chart library is sent to the browser. - Table. The 20 newest tasks, with formatted dates, re-rendered in open browsers when a task changes (
live: true). - Protection.
"auth": "required"plus"roles": ["admin"]on the screen. The header shows the Admin link only to admins with"visible": {"roles": ["admin"]}.
Add the dashboard's styles:
styles/main.css
.admin-shell { display: grid; }
.side-nav { display: flex; flex-direction: column; gap: 4px; padding: 24px 16px; border-right: 1px solid var(--color-border); }
.side-nav a { padding: 8px 12px; border-radius: 8px; color: inherit; text-decoration: none; }
.side-nav a[aria-current="page"] { background: var(--color-surface); font-weight: 600; }
.dash { padding: 32px 5vw; }
.stat { padding: 16px; border-radius: var(--radius); background: var(--color-surface); }
.stat h2 { margin: 0; }Step 5: Test it
tests/admin.test.iwc
{
"name": "admin",
"cases": [
{ "name": "members cannot open the dashboard", "as": { "email": "max@example.com", "password": "member-pass-123" }, "request": { "path": "/admin" }, "expect": { "status": 403 } },
{ "name": "admins see stats, the chart and everyone's tasks", "as": { "email": "admin@example.com", "password": "admin-pass-123" }, "request": { "path": "/admin" }, "expect": { "status": 200, "contains": ["Dashboard", "Completed", "Members", "<svg", "Review the budget", "Book the venue"] } }
]
}Terminal
iwc testOutput
✓ admin › members cannot open the dashboard
✓ admin › admins see stats, the chart and everyone's tasks
…
13 passed, 0 failedGoing further
- Workflow buttons: an API with
"workflow"and"transition"moves a record between states ("Mark shipped") and answers 409 from the wrong state. The e-commerce tutorial uses one. - Permissions instead of roles:
perm:tasks.exportstyle permissions and record scopes are in Access control. - A separate admin app: large projects split the admin into its own app with its own sign-in; see Multi-app projects.
Troubleshooting
| Problem | Fix |
|---|---|
| Stats show only the admin's own tasks | The schema's read rule doesn't include "admin". |
| The member count is 1 | The user schema has no access, so even admins read only themselves; use "read": ["admin", "self"]. |
chart elements need "chart" and "data" | Give the chart kind, x, y and a data source. |
Source: apps/marketing/content/docs/tutorials/admin-dashboard.md