Skip to content
ONIWC
iwc 0.1.0

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" }
}
  • template arranges slots as CSS grid areas: the header across the top, the sidebar and content below.
  • The sidebar is an inline nav element; "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 box has a single-mode DATABASE source with aggregate: the database counts the rows (under the same access rules) and the box binds total into the stat component with {"bind": "total"}. The second adds "filter": {"done": true}.
  • Chart. aggregate with groupBy: ["priority"] returns one record per priority with a tasks count. The chart element 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 test
Output
  ✓ admin › members cannot open the dashboard
  ✓ admin › admins see stats, the chart and everyone's tasks
  …
13 passed, 0 failed

Going 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.export style 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

ProblemFix
Stats show only the admin's own tasksThe schema's read rule doesn't include "admin".
The member count is 1The 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.

Next: Build an e-commerce application.

Source: apps/marketing/content/docs/tutorials/admin-dashboard.md