hazo_admin
v0.11.1
Published
Standard site-admin package — auth-gated admin shell + panel kit + drop-in /admin preset
Maintainers
Readme
hazo_admin
Standard site-admin package for hazo apps — auth-gated admin shell + panel kit + drop-in /admin preset.
Install
npm install hazo_adminPeer deps (required): hazo_core, hazo_ui, react, react-dom, next, lucide-react
Peer deps (optional, each feature-gated): hazo_auth, hazo_connect, hazo_logs, and other hazo_* packages
Setup
See SETUP_CHECKLIST.md for step-by-step setup.
Tailwind v4 Setup (Required for consuming apps using Tailwind v4)
If your consuming app uses Tailwind CSS v4, you must configure @source to scan this package's Tailwind classes. Add the following to your app's globals.css (or main CSS file) AFTER the tailwindcss import:
@import "tailwindcss";
/* Required: Enable Tailwind to scan hazo_admin's Tailwind classes */
@source "../node_modules/hazo_admin/dist";Without this directive, Tailwind v4's JIT compiler will not generate CSS for utility classes used in hazo_admin (colors, spacing, hover states, etc.), resulting in unstyled or broken layouts.
Note: If you are using Tailwind v3, you do not need to add @source.
AdminGate
The AdminGate is the auth/permission/scope gate that protects all admin routes.
adminGate(request, opts)
Non-throwing decision function. Returns an AdminGateResult:
{ allowed: true, user, permissions, scope_ok? }— access granted{ allowed: false, status: 403, reason: 'unauthenticated' | 'forbidden' | 'scope_denied', missing_permissions? }— access denied
import { adminGate } from 'hazo_admin';
const result = await adminGate(request, {
required_permissions: ['admin_system'],
scope_id: params.orgId, // optional — for HRBAC scope isolation
});withAdminGate(handler, opts)
Route HOF. On denial returns a 403 JSON response; on allow calls the handler.
import { withAdminGate } from 'hazo_admin';
export const GET = withAdminGate(
async (request, gate) => {
// gate.user and gate.permissions are available here
return Response.json({ ok: true });
},
{ required_permissions: ['admin_system'] }
);Permission Constants
import { HAZO_ADMIN_PERMISSIONS } from 'hazo_admin/client';
// Core
// HAZO_ADMIN_PERMISSIONS.ADMIN_SYSTEM = 'admin_system' — full admin access (default gate)
// HAZO_ADMIN_PERMISSIONS.ENV_MIGRATE = 'env.migrate' — run env migrations
// HAZO_ADMIN_PERMISSIONS.ENV_MASK_MANAGE = 'env.mask.manage' — edit masking ruleset
// Extended (v0.2.0)
// HAZO_ADMIN_PERMISSIONS.AUDIT_READ = 'audit.read' — view audit trail
// HAZO_ADMIN_PERMISSIONS.SETTINGS_MANAGE = 'admin.settings.manage' — edit app config settings
// HAZO_ADMIN_PERMISSIONS.APIKEYS_MANAGE = 'admin.apikeys.manage' — manage API keys
// HAZO_ADMIN_PERMISSIONS.BLOG_MANAGE = 'admin.blog.manage' — manage blog posts
// HAZO_ADMIN_PERMISSIONS.FILES_MANAGE = 'admin.files.manage' — browse / manage data files
// HAZO_ADMIN_PERMISSIONS.FEEDBACK_REVIEW = 'admin.feedback.review' — review user feedback
// HAZO_ADMIN_PERMISSIONS.METRICS_VIEW = 'metrics.view' — view metrics / analytics panel
// Issues (v0.5.0)
// HAZO_ADMIN_PERMISSIONS.ADMIN_ISSUE_TRIAGE = 'admin_issue_triage' — triage / resolve issues queue
// HAZO_ADMIN_PERMISSIONS.ADMIN_USER_SCOPE_ASSIGNMENT = 'admin_user_scope_assignment' — grant scope role assignmentsThese must exist as rows in the consuming app's hazo_permissions table. The consuming app is responsible for seeding them.
Nav sections / manifest kinds
All nav kind metadata is centralized in src/lib/admin_kinds.ts. Each kind maps to a panel, a composed package, and a default permission. Contributors adding a new kind should add it to the registry there first.
| Kind | Default permission | Composed package | Status |
|---|---|---|---|
| users | admin_system | hazo_auth | done |
| jobs | admin_system | hazo_jobs | done |
| logs | admin_system | hazo_logs | done |
| env | env.migrate | hazo_env | done |
| masking | env.mask.manage | hazo_env | done |
| health | admin_system | hazo_env | done |
| audit | audit.read | hazo_audit | stub (Phase 4) |
| settings | settings.manage | hazo_config | stub (Phase 4) |
| api_keys | apikeys.manage | hazo_api | stub (Phase 4) |
| blog | blog.manage | hazo_blog | stub (Phase 4) |
| files | files.manage | hazo_files | stub (Phase 4) |
| feedback | feedback.review | hazo_feedback | stub (Phase 4) |
| issues | admin_issue_triage | hazo_admin (built-in) | done (v0.5.0) |
| metrics | metrics.view | consumer-supplied component (e.g. hazo_umetrics) | done (consumer-component) |
metrics kind — consumer component pattern
The metrics kind has no hazo_admin dependency on hazo_umetrics. Instead, pass your metrics panel as a component prop on the section:
import { MetricsPanel } from 'hazo_umetrics';
const MANIFEST = {
sections: [
{ kind: 'metrics' as const, component: <MetricsPanel /> },
],
};If no component is supplied, the panel renders a placeholder message. This keeps hazo_admin dependency-free from any metrics package.
Issues system (v0.5.0)
The issues nav kind provides a built-in queue for actionable admin events — e.g. a user lacking a required permission triggers an issue that the nearest-scope admin can grant or deny.
Registering the issues section
const MANIFEST = {
sections: [
{ kind: 'issues' as const },
// custom permission if needed:
// { kind: 'issues' as const, permission: 'my_custom_triage_perm' },
],
};The default gate permission is admin_issue_triage. Add it to your hazo_permissions table and assign it to admin roles.
Issue types — consumer-defined
hazo_admin ships with zero built-in issue types. Consumers register types at boot:
import { registerIssueType } from 'hazo_admin';
registerIssueType({
key: 'auth_permission',
label: 'Permission Request',
actions: [
{
key: 'grant',
label: 'Grant',
async run(issue, params, ctx) {
// call hazo_auth to assign role or permission
const connect = await ctx.getHazoConnect();
// ... grant logic ...
return { data: { roleId: params.roleId } };
},
},
{
key: 'deny',
label: 'Deny',
async run(issue, params, ctx) { /* optional deny side-effects */ },
},
],
buildResolutionNotice(issue, actionKey, result) {
return {
subject: actionKey === 'grant' ? 'Access granted' : 'Access denied',
body: 'Your permission request has been reviewed.',
deep_link: '/dashboard',
};
},
});Custom card renderers (client)
Override how a type's issue card looks in the IssuesPanel:
import { registerIssueCardRenderer } from 'hazo_admin/client';
registerIssueCardRenderer('auth_permission', ({ issue, onTransition }) => (
<div>
<h3>{issue.title}</h3>
<button onClick={() => onTransition('wip')}>Take it</button>
</div>
));If no renderer is registered for a type, DefaultIssueCard is used.
Issue routing config
Add to hazo_admin_config.ini:
[issue_routing]
; Map issue types to recipient strategies.
; Supported strategies: nearest_scope_admin
auth_permission = nearest_scope_adminAPI routes (via createAdminPresetRoutes)
| Method | Path | Description |
|---|---|---|
| GET | /issues | List issues (filtered by admin's scope) |
| GET | /issues/:id | Get single issue |
| POST | /issues/:id/transition | Transition status — free-form: any known status → any other known status |
| POST | /issues/:id/assign | Assign to a user |
| POST | /issues/:id/assign-to-me | Assign to the authenticated actor (derived from the admin gate, not client input) |
| POST | /issues/:id/resolve | Run an action + close the issue |
| POST | /issues/archive-sweep | Move closed issues older than the configured cutoff to archived — global admins only (403 otherwise) |
| GET | /issues/assignees | List all users assignable to issues ({ user_id, label, avatarUrl }, for the reassign picker) |
Scoped admins only see issues within their assigned scopes. Global admins (admin_system) see all.
Statuses: new / wip / on_hold / closed / archived. on_hold renders as a parked lane after Closed on the board. Transitions are unrestricted (only an unknown target status is rejected); → wip still auto-assigns the actor.
Drop-in IssuesPanel (v0.10.0, filter bar + owner picker updated in v0.11.0)
Mount the Issues triage board on its own, without the full AdminApp shell:
import { IssuesPanel } from 'hazo_admin/ui';
export default function AdminIssuesPage() {
return <IssuesPanel basePath="/api/admin" currentUserId={currentUserId} />;
}basePath defaults to /api/admin. The board has four columns (New → WIP → Closed → On Hold). Filter controls live in a horizontal top bar above the board — search inline, plus Type, Severity, and Age (Under 1 day / 1–7 days / 7–30 days / Over 30 days) as dropdown chips with active-count badges, a Clear · N button, and Manage types. Status isn't a facet here since it's already the board's columns.
Each card shows per-card type and owner controls. The owner control uses UserPickerSelect from hazo_auth/client (≥ 10.5.0) — avatar + name/email dropdown — populated from GET {basePath}/issues/assignees (now every user, not just resolvable admins); selecting one POSTs {basePath}/issues/:id/assign. A compact Me button next to it POSTs {basePath}/issues/:id/assign-to-me to self-claim.
The panel header also has an Archive old button that POSTs {basePath}/issues/archive-sweep (global admins only) and refreshes both boards.
Archive job
Register the built-in archive job to sweep old closed issues (default cutoff is now 1 month, was 3):
import { registerIssueArchiveJob, ADMIN_ISSUE_ARCHIVE_JOB_TYPE } from 'hazo_admin';
import { createJobQueue } from 'hazo_jobs';
registerIssueArchiveJob(jobQueue, { getHazoConnect, archiveAfterMonths: 1 });The Archive old button in IssuesPanel covers the same sweep on demand, without needing the job scheduled.
createIssueStoreFromConnect(adapter) — PostgREST-native store (v0.6.0)
Use this factory when the consuming app connects via a PostgREST adapter (no direct SQL access). It builds the same IssueStore interface using createCrudService + adapter.claimRows so no raw SQL is issued.
import { createIssueStoreFromConnect } from 'hazo_admin';
// In your route handler or service bootstrap:
const store = await createIssueStoreFromConnect(adapter);
// store implements the full IssueStore interface
const { issue, isNew } = await store.createOrBumpIssue({ ... });Capability sniff: if the adapter exposes a .raw() method (i.e. it is a direct-DB adapter), createIssueStoreFromConnect transparently delegates to createIssueStore instead. This lets callers use a single factory regardless of adapter type.
Async: returns a Promise<IssueStore> (lazy hazo_connect/server import on the CRUD path).
Raising issues (v0.8.0)
raiseIssue(connect, input, opts?) is the single documented ingest protocol for
creating an issue from server-side code — a trusted-by-construction seam (no
HTTP ingest endpoint). See design/issue_raising_protocol.md
for the full contract.
import { raiseIssue } from 'hazo_admin';
const { issue, isNew } = await raiseIssue(connect, {
type: 'auth_permission', // must be a registered IssueTypeDef.typeKey
scope_id: orgId, // omit/null for a global (super-admin) issue
severity: 'high',
source: 'hazo_files',
payload: { requestedPermission: 'files.manage' },
});scope_id presence infers routing: present ⇒ scoped issue (scope admins),
omitted/null ⇒ global issue (super admins only). title/summary are
auto-derived from the type's buildDescriptor(payload) when not supplied.
Notify-on-raise is optional and async — only newly-created issues enqueue
a hazo_admin.issue.notify job, and only if hazo_jobs + hazo_notify are
installed and wired. Register the worker handler once at boot:
import { registerIssueNotifyJob } from 'hazo_admin';
registerIssueNotifyJob(worker, { getHazoConnect });Without this wiring, issues still raise and show up in the Kanban — only the notification is skipped.
v0.2.0 Breaking changes
GET /env/list returns {name, role}[] instead of string[]
Prior to v0.2.0, GET /env/list returned a plain string array of env names. As of v0.2.0 it returns an array of objects:
// Before (v0.1.x)
// GET /env/list → string[]
// e.g. ['dev', 'prod']
// After (v0.2.0+)
// GET /env/list → { name: string; role: HazoEnvRole }[]
// e.g. [{ name: 'dev', role: 'development' }, { name: 'prod', role: 'production' }]Consumers of this endpoint (e.g. EnvMigrationPanel) must read the role field from the response rather than re-deriving it from the env name string. This makes the panel robust to arbitrarily-named production environments.
Entries
| Import path | Contents | Server-safe |
|---|---|---|
| hazo_admin | adminGate, withAdminGate, HAZO_ADMIN_PERMISSIONS, AdminGateResult, createIssueStore, createIssueStoreFromConnect, registerIssueType, getIssueType, listIssueTypes, loadIssueRoutingConfig, registerIssueArchiveJob, ADMIN_ISSUE_ARCHIVE_JOB_TYPE, raiseIssue, registerIssueNotifyJob, issueNotifyJobHandler, ADMIN_ISSUE_NOTIFY_JOB_TYPE, resolveGlobalAdmins, resolveScopeAdmins | Server only |
| hazo_admin/client | HAZO_ADMIN_PERMISSIONS, registerIssueCardRenderer, getIssueCardRenderer, listIssueCardRenderers, DefaultIssueCard, types | Yes |
| hazo_admin/api | createAdminPresetRoutes | Server only |
| hazo_admin/jobs | ENV_MIGRATE_JOB_TYPE, envMigrateJobHandler | Server only (no React) |
| hazo_admin/ui | All panel components + resolveNav | Client |
| hazo_admin/ui/<component> | Individual panel components (avoids barrel server-only leak) | Client |
| hazo_admin/preset | AdminPresetPage (drop-in Next.js page) | Server/RSC |
