Add an admin page
What you'll build: a Units list page at /admin/units in your app, and the staff-only API route it reads. The
page has a sidebar entry under Manage, a heading with an action, a table in a card and an empty state.
Prerequisites
- The workspace starter, or another host app with Admin and Authorization.
- A signed-in user whose role holds
admin:access(theadminrole does): Roles and the first admin. - Pages, for the patterns this page follows.
- Protect a route, for
authorizeand permissions.
The paths below are for the starter's apps/web. In another host app, leave out the apps/web/ prefix.
admin.settings.permission get into the admin area. It defaults to
admin:access, which Admin registers and grants to the admin role. A role that should see the admin area at all
needs it too.1. Register the permission and add the API route
The page reads its rows from /api/admin/units, so that route comes first. It needs a permission of its own. Declare
it with defineAccess, in a file the server and the pages can import too:
import { defineAccess } from '@frigear-nu/auth/access'
export const unitsAccess = defineAccess('units', {
permissions: {
read: { label: 'View units', grantTo: ['admin'] }, // units:read
manage: { label: 'Manage units', grantTo: ['admin'] }, // units:manage
},
})
A host app registers it in a local module (Nuxt loads every file in apps/web/modules/). A module does the same in its
setup(), with the declaration in src/runtime/access.ts:
import { defineNuxtModule } from 'nuxt/kit'
import { registerAccess } from '@frigear-nu/auth/utils'
import { unitsAccess } from '../shared/access/units'
export default defineNuxtModule({
meta: { name: 'units' },
setup(_options, nuxt) {
registerAccess(nuxt, unitsAccess)
},
})
After the next nuxi prepare, units:read and units:manage are part of the generated Permission type, so a typo
in authorize, <Can>, definePageMeta({ authorize }) or a nav item's permission fails typecheck.
/admin/**: it guards the page, not the API
routes behind it. A route that never calls authorize answers every caller.Check the permission with authorize, before anything else. It answers 401 without a credential and 403 without
the permission. forbidden() and friends are for errors your own code raises after that.
export default defineEventHandler(async (event) => {
await authorize(event, 'units:read') // 401 / 403 first: a user who may not list units learns nothing else
const items: { id: number, name: string, code: string }[] = [] // load your units here
return { items, total: items.length }
})
authorizereads the user's roles from the database on every request, so a demoted admin is refused at once.- A route rule is not a substitute for the check. A rule
'/api/admin/units': { permission: 'units:read' }innuxt.config.tsis a coarse first gate that Auth enforces on the path. It guards one pattern, not the handler, so keep the check in the handler. - Staff routes live under
/api/admin/.... An action that changes who a user is, or moves money, gets its ownsessionOnlypermission. More on Protect a route. - One permission per kind of action (
units:read,units:manage), not one per route. Name the staff permission after the resource, and mark ones that expose personal data:pii. - The response is
{ items, total }, the shape the page in step 2 reads.
2. Add the page
Pages under /admin get the admin layout automatically, so your page renders only its own body. Create
apps/web/app/pages/admin/units.vue. Its root is a plain <div class="flex flex-col gap-4">. It renders its own
heading (PageHeading), which is the page's only <h1>.
<script setup lang="ts">
import type { TableColumn } from '@nuxt/ui'
interface Unit { id: number, name: string, code: string }
const t = useModuleT('units')
const { data, loading } = await useCachedFetch<{ items: Unit[], total: number }>('/api/admin/units', { key: 'admin:units' })
const units = computed(() => data.value?.items ?? [])
const TableIdentity = resolveComponent('TableIdentity')
const columns: TableColumn<Unit>[] = [
{
accessorKey: 'name',
header: t('admin.name', 'Name'),
cell: ({ row }) => h(TableIdentity, { title: row.original.name, description: row.original.code }),
},
]
</script>
<template>
<div class="flex flex-col gap-4">
<PageHeading :title="t('admin.title', 'Units')">
<template #actions>
<Can permission="units:manage">
<UButton icon="i-lucide-plus" :label="t('admin.create', 'Add unit')" />
</Can>
</template>
</PageHeading>
<TableCard :loading="loading">
<UTable :data="units" :columns="columns" :ui="tableUi()">
<template #empty>
<UEmpty variant="naked" icon="i-lucide-building" :title="t('admin.empty', 'No units yet')">
<template #actions>
<Can permission="units:manage">
<UButton :label="t('admin.create', 'Add unit')" />
</Can>
</template>
</UEmpty>
</template>
</UTable>
</TableCard>
</div>
</template>
The page follows the list pattern:
- Heading and action. A list is a
PageHeadingwith the primary action, and aUTablestyled withtableUi()inside aTableCard. Past about eight rows, add aPageToolbarfor search. - Loading.
useCachedFetch(from UI) returnsloading, which istrueonly while nothing is on screen yet.TableCard :loadingshows skeleton rows for it, and a later refresh keeps the rows that are already there. It also keeps the list when you navigate away and back. - Empty state. An empty list is a
UEmptywith exactly one action. It is never an empty table, and it never shows while the list is still loading. - Permissions.
<Can permission="units:manage">renders its content only for a visitor who holds the permission (<Cannot>is the opposite). It decides what is shown, not what is allowed: the create route checksunits:manageitself. - Strings. Every string goes through
useModuleTwith an English default. Buttons name the object: "Add unit", not "Add". - Row menu. A list with actions adds a row menu (
RowMenu) in the last column. It comes from ause<Entity>Actions()composable that owns create, edit and remove. See Forms and actions.
3. Add the sidebar entry
The page needs an entry in the admin sidebar, or nobody finds it. The sidebar has three groups:
| Group | Menu path | Argument |
|---|---|---|
| Overview | admin.nav.overview | 'overview' |
| Manage | admin.nav | 'manage', the default |
| Settings | admin.nav.settings | 'settings' |
Put a new feature in an existing group. Add the entry at build time with extendAdminNav from
@frigear-nu/admin/utils. It runs in a Nuxt module's setup():
Add the call to the units module from step 1, or to a module of its own in apps/web/modules/, as the starter's
libsql-binding.ts shows.
import { defineNuxtModule } from 'nuxt/kit'
import { adminRoute, extendAdminNav } from '@frigear-nu/admin/utils'
export default defineNuxtModule({
meta: { name: 'units-nav' },
setup(_options, nuxt) {
extendAdminNav(nuxt, { label: 'Units', to: adminRoute(nuxt, 'units'), icon: 'i-lucide-building', order: 20, permission: 'units:read' })
},
})
A module adds the entry in its own setup():
import { adminRoute, extendAdminNav } from '@frigear-nu/admin/utils'
extendAdminNav(nuxt, { label: 'Units', to: adminRoute(nuxt, 'units'), icon: 'i-lucide-building', order: 20, permission: 'units:read' })
// A configuration page goes into the Settings group:
// extendAdminNav(nuxt, { label: 'Integrations', to: adminRoute(nuxt, 'integrations') }, 'settings')
adminRoute(nuxt, 'units')is/admin/units, below the configuredbase.permissionhides the entry from visitors who do not hold it. It only hides: the route behind it still checks the permission, and a page rule ordefinePageMeta({ authorize: 'units:read' })keeps others out of the page itself.- A configuration page goes into the Settings group: pass
'settings'as the third argument. - A module can instead register a runtime menu source with
extendMenuItem('admin.nav', ...). That works whether or not Admin is installed.
pnpm dev, Units in the Manage group of the admin sidebar. It
opens /admin/units, with the Units heading and the Add unit button. A signed-in user whose roles lack
units:read sees no entry, and GET /api/admin/units answers 403.Next steps
- Forms and actions – make Add unit open a create form, and add the row menu.
- Admin – add a widget to the admin dashboard.
- UI – look up
PageHeading,TableCard,RowMenu,SectionCardandFormSlideover.
Protect a route
Declare a permission with defineAccess, check it on a server route with authorize, keep money and credential actions to cookie sessions, and guard pages with route rules.
Sell event tickets
Sell paid and free event tickets with Events, Payments and Stripe, and run code when a ticket is paid.