hazo_admin 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGE_LOG.md +11 -0
  2. package/README.md +86 -0
  3. package/SETUP_CHECKLIST.md +36 -0
  4. package/dist/api/env_migrate_handler.d.ts +19 -0
  5. package/dist/api/env_migrate_handler.d.ts.map +1 -0
  6. package/dist/api/env_migrate_handler.js +44 -0
  7. package/dist/api/index.d.ts +46 -0
  8. package/dist/api/index.d.ts.map +1 -0
  9. package/dist/api/index.js +257 -0
  10. package/dist/components/admin_app.d.ts +10 -0
  11. package/dist/components/admin_app.d.ts.map +1 -0
  12. package/dist/components/admin_app.js +32 -0
  13. package/dist/components/admin_layout.d.ts +15 -0
  14. package/dist/components/admin_layout.d.ts.map +1 -0
  15. package/dist/components/admin_layout.js +37 -0
  16. package/dist/components/admin_nav.d.ts +31 -0
  17. package/dist/components/admin_nav.d.ts.map +1 -0
  18. package/dist/components/admin_nav.js +73 -0
  19. package/dist/components/env_migration_panel.d.ts +6 -0
  20. package/dist/components/env_migration_panel.d.ts.map +1 -0
  21. package/dist/components/env_migration_panel.js +218 -0
  22. package/dist/components/health_panel.d.ts +5 -0
  23. package/dist/components/health_panel.d.ts.map +1 -0
  24. package/dist/components/health_panel.js +23 -0
  25. package/dist/components/jobs_panel.d.ts +6 -0
  26. package/dist/components/jobs_panel.d.ts.map +1 -0
  27. package/dist/components/jobs_panel.js +21 -0
  28. package/dist/components/logs_panel.d.ts +6 -0
  29. package/dist/components/logs_panel.d.ts.map +1 -0
  30. package/dist/components/logs_panel.js +17 -0
  31. package/dist/components/users_panel.d.ts +7 -0
  32. package/dist/components/users_panel.d.ts.map +1 -0
  33. package/dist/components/users_panel.js +21 -0
  34. package/dist/index.client.d.ts +8 -0
  35. package/dist/index.client.d.ts.map +1 -0
  36. package/dist/index.client.js +5 -0
  37. package/dist/index.d.ts +4 -0
  38. package/dist/index.d.ts.map +1 -0
  39. package/dist/index.js +2 -0
  40. package/dist/index.ui.d.ts +17 -0
  41. package/dist/index.ui.d.ts.map +1 -0
  42. package/dist/index.ui.js +9 -0
  43. package/dist/lib/admin_gate.server.d.ts +37 -0
  44. package/dist/lib/admin_gate.server.d.ts.map +1 -0
  45. package/dist/lib/admin_gate.server.js +58 -0
  46. package/dist/lib/index.d.ts +2 -0
  47. package/dist/lib/index.d.ts.map +1 -0
  48. package/dist/lib/index.js +1 -0
  49. package/dist/preset/index.d.ts +3 -0
  50. package/dist/preset/index.d.ts.map +1 -0
  51. package/dist/preset/index.js +1 -0
  52. package/dist/preset/page_factory.d.ts +12 -0
  53. package/dist/preset/page_factory.d.ts.map +1 -0
  54. package/dist/preset/page_factory.js +57 -0
  55. package/package.json +139 -0
package/CHANGE_LOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # hazo_admin Changelog
2
+
3
+ ## 0.1.0 — 2026-06-09
4
+
5
+ ### Added
6
+ - Package scaffold (Phase 0): standard hazo_* package structure
7
+ - `AdminGate`: `adminGate()` decision fn + `withAdminGate()` route HOF
8
+ - `HAZO_ADMIN_PERMISSIONS` constants (`admin_system`, `env.migrate`, `env.mask.manage`)
9
+ - `createAdminPresetRoutes` stub (empty, placeholder for future preset routes)
10
+ - test-app with sidebar, Tailwind, `/autotest` route
11
+ - DB-integration jest suite covering all gate branches
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # hazo_admin
2
+
3
+ Standard site-admin package for hazo apps — auth-gated admin shell + panel kit + drop-in `/admin` preset.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install hazo_admin
9
+ ```
10
+
11
+ Peer deps (required): `hazo_core`, `hazo_ui`, `react`, `react-dom`, `next`, `lucide-react`
12
+ Peer deps (optional, each feature-gated): `hazo_auth`, `hazo_connect`, `hazo_logs`, and other hazo_* packages
13
+
14
+ ## Setup
15
+
16
+ See [SETUP_CHECKLIST.md](./SETUP_CHECKLIST.md) for step-by-step setup.
17
+
18
+ ## Tailwind v4 Setup (Required for consuming apps using Tailwind v4)
19
+
20
+ 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:
21
+
22
+ ```css
23
+ @import "tailwindcss";
24
+
25
+ /* Required: Enable Tailwind to scan hazo_admin's Tailwind classes */
26
+ @source "../node_modules/hazo_admin/dist";
27
+ ```
28
+
29
+ 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.
30
+
31
+ **Note:** If you are using Tailwind v3, you do not need to add `@source`.
32
+
33
+ ## AdminGate
34
+
35
+ The `AdminGate` is the auth/permission/scope gate that protects all admin routes.
36
+
37
+ ### `adminGate(request, opts)`
38
+
39
+ Non-throwing decision function. Returns an `AdminGateResult`:
40
+ - `{ allowed: true, user, permissions, scope_ok? }` — access granted
41
+ - `{ allowed: false, status: 403, reason: 'unauthenticated' | 'forbidden' | 'scope_denied', missing_permissions? }` — access denied
42
+
43
+ ```ts
44
+ import { adminGate } from 'hazo_admin';
45
+
46
+ const result = await adminGate(request, {
47
+ required_permissions: ['admin_system'],
48
+ scope_id: params.orgId, // optional — for HRBAC scope isolation
49
+ });
50
+ ```
51
+
52
+ ### `withAdminGate(handler, opts)`
53
+
54
+ Route HOF. On denial returns a 403 JSON response; on allow calls the handler.
55
+
56
+ ```ts
57
+ import { withAdminGate } from 'hazo_admin';
58
+
59
+ export const GET = withAdminGate(
60
+ async (request, gate) => {
61
+ // gate.user and gate.permissions are available here
62
+ return Response.json({ ok: true });
63
+ },
64
+ { required_permissions: ['admin_system'] }
65
+ );
66
+ ```
67
+
68
+ ## Permission Constants
69
+
70
+ ```ts
71
+ import { HAZO_ADMIN_PERMISSIONS } from 'hazo_admin/client';
72
+
73
+ // HAZO_ADMIN_PERMISSIONS.ADMIN_SYSTEM = 'admin_system'
74
+ // HAZO_ADMIN_PERMISSIONS.ENV_MIGRATE = 'env.migrate'
75
+ // HAZO_ADMIN_PERMISSIONS.ENV_MASK_MANAGE = 'env.mask.manage'
76
+ ```
77
+
78
+ **These must exist as rows in the consuming app's `hazo_permissions` table.** The consuming app is responsible for seeding them.
79
+
80
+ ## Entries
81
+
82
+ | Import path | Contents | Server-safe |
83
+ |---|---|---|
84
+ | `hazo_admin` | `adminGate`, `withAdminGate`, `HAZO_ADMIN_PERMISSIONS`, `AdminGateResult` | Server only |
85
+ | `hazo_admin/client` | `HAZO_ADMIN_PERMISSIONS`, types | Yes |
86
+ | `hazo_admin/api` | `createAdminPresetRoutes` stub | Server only |
@@ -0,0 +1,36 @@
1
+ # hazo_admin Setup Checklist
2
+
3
+ ## 1. Install
4
+
5
+ ```bash
6
+ npm install hazo_admin hazo_auth hazo_core hazo_ui
7
+ ```
8
+
9
+ ## 2. Seed permission rows
10
+
11
+ In your app's database migration, seed the permission strings that `hazo_admin` depends on:
12
+
13
+ ```sql
14
+ INSERT INTO hazo_permissions (id, name) VALUES
15
+ (gen_random_uuid(), 'admin_system'),
16
+ (gen_random_uuid(), 'env.migrate'),
17
+ (gen_random_uuid(), 'env.mask.manage')
18
+ ON CONFLICT (name) DO NOTHING;
19
+ ```
20
+
21
+ ## 3. Protect your admin routes
22
+
23
+ ```ts
24
+ // app/admin/api/route.ts
25
+ import { withAdminGate } from 'hazo_admin';
26
+ import { HAZO_ADMIN_PERMISSIONS } from 'hazo_admin/client';
27
+
28
+ export const GET = withAdminGate(
29
+ async (req, gate) => Response.json({ user: gate.user.email_address }),
30
+ { required_permissions: [HAZO_ADMIN_PERMISSIONS.ADMIN_SYSTEM] }
31
+ );
32
+ ```
33
+
34
+ ## 4. Configure (optional)
35
+
36
+ Copy `config/hazo_admin_config.ini.sample` to `hazo_admin_config.ini` and adjust as needed.
@@ -0,0 +1,19 @@
1
+ import 'server-only';
2
+ export declare const ENV_MIGRATE_JOB_TYPE: "env.migrate";
3
+ /**
4
+ * Job handler for env.migrate jobs. Calls hazo_env's runMigration with the
5
+ * job payload plus jobId + progressDir so progress is written to the filesystem.
6
+ */
7
+ export declare function envMigrateJobHandler(job: {
8
+ id: string;
9
+ payload: unknown;
10
+ }, ctx: {
11
+ log: (...args: any[]) => void;
12
+ }): Promise<unknown>;
13
+ /**
14
+ * Register the env.migrate job type with a hazo_jobs worker.
15
+ */
16
+ export declare function registerEnvJobs(worker: {
17
+ register: (type: string, handler: (job: any, ctx: any) => Promise<unknown>) => void;
18
+ }): void;
19
+ //# sourceMappingURL=env_migrate_handler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"env_migrate_handler.d.ts","sourceRoot":"","sources":["../../src/api/env_migrate_handler.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAC;AAGrB,eAAO,MAAM,oBAAoB,EAAG,aAAsB,CAAC;AAE3D;;;GAGG;AACH,wBAAsB,oBAAoB,CACxC,GAAG,EAAE;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,EACrC,GAAG,EAAE;IAAE,GAAG,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;CAAE,GACrC,OAAO,CAAC,OAAO,CAAC,CAkClB;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE;IACtC,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,KAAK,OAAO,CAAC,OAAO,CAAC,KAAK,IAAI,CAAC;CACrF,GAAG,IAAI,CAEP"}
@@ -0,0 +1,44 @@
1
+ import 'server-only';
2
+ import path from 'node:path';
3
+ export const ENV_MIGRATE_JOB_TYPE = 'env.migrate';
4
+ /**
5
+ * Job handler for env.migrate jobs. Calls hazo_env's runMigration with the
6
+ * job payload plus jobId + progressDir so progress is written to the filesystem.
7
+ */
8
+ export async function envMigrateJobHandler(job, ctx) {
9
+ let runMigration;
10
+ let resolveFilesConfig;
11
+ try {
12
+ const hazoEnv = await import('hazo_env');
13
+ runMigration = hazoEnv.runMigration;
14
+ resolveFilesConfig = hazoEnv.resolveFilesConfig;
15
+ }
16
+ catch (err) {
17
+ ctx.log('[env_migrate_handler] hazo_env is not installed:', err);
18
+ throw err;
19
+ }
20
+ const progressDir = path.join(resolveFilesConfig().local.basePath, 'migrations', 'progress');
21
+ const raw = job.payload;
22
+ const payload = typeof raw === 'string' ? JSON.parse(raw)
23
+ : (raw && typeof raw === 'object' ? raw : {});
24
+ const { includeDb, includeFiles, ...rest } = payload;
25
+ try {
26
+ const result = await runMigration({
27
+ ...rest,
28
+ include: { db: includeDb !== false, files: includeFiles !== false },
29
+ jobId: job.id,
30
+ progressDir,
31
+ });
32
+ return result;
33
+ }
34
+ catch (err) {
35
+ ctx.log(`[env_migrate_handler] job ${job.id} failed:`, err);
36
+ throw err;
37
+ }
38
+ }
39
+ /**
40
+ * Register the env.migrate job type with a hazo_jobs worker.
41
+ */
42
+ export function registerEnvJobs(worker) {
43
+ worker.register(ENV_MIGRATE_JOB_TYPE, envMigrateJobHandler);
44
+ }
@@ -0,0 +1,46 @@
1
+ import 'server-only';
2
+ import type { AdminManifest } from '../components/admin_nav.js';
3
+ export type AdminPresetRoutesConfig = {
4
+ /**
5
+ * Returns the DB adapter for hazo_jobs. Must expose a `raw(sql, values)` method
6
+ * (the hazo_jobs adapter contract) — e.g. hazo_connect's `pg` adapter or a
7
+ * better-sqlite3 wrapper. See hazo_jobs README "Quick start".
8
+ */
9
+ getHazoConnect: () => Promise<any> | any;
10
+ logDirectory?: string;
11
+ jobs?: {
12
+ supervisorUrl?: string;
13
+ supervisorToken?: string;
14
+ /** SQL dialect for the jobs adapter. Defaults to 'pg'; set 'sqlite' for a SQLite DB. */
15
+ dialect?: 'pg' | 'sqlite';
16
+ };
17
+ basePath?: string;
18
+ env?: {
19
+ /** Override default progress dir. Defaults to <dataRoot>/migrations/progress */
20
+ progressDir?: string;
21
+ };
22
+ };
23
+ export declare function createAdminPresetRoutes(manifest: AdminManifest, cfg: AdminPresetRoutesConfig): {
24
+ GET: (request: Request, ctx: {
25
+ params: Promise<{
26
+ path: string[];
27
+ }>;
28
+ }) => Promise<Response>;
29
+ POST: (request: Request, ctx: {
30
+ params: Promise<{
31
+ path: string[];
32
+ }>;
33
+ }) => Promise<Response>;
34
+ PUT: (request: Request, ctx: {
35
+ params: Promise<{
36
+ path: string[];
37
+ }>;
38
+ }) => Promise<Response>;
39
+ DELETE: (request: Request, ctx: {
40
+ params: Promise<{
41
+ path: string[];
42
+ }>;
43
+ }) => Promise<Response>;
44
+ };
45
+ export { envMigrateJobHandler, ENV_MIGRATE_JOB_TYPE, registerEnvJobs, } from './env_migrate_handler.js';
46
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/api/index.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAC;AAIrB,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAEhE,MAAM,MAAM,uBAAuB,GAAG;IACpC;;;;OAIG;IACH,cAAc,EAAE,MAAM,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC;IACzC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE;QACL,aAAa,CAAC,EAAE,MAAM,CAAC;QACvB,eAAe,CAAC,EAAE,MAAM,CAAC;QACzB,wFAAwF;QACxF,OAAO,CAAC,EAAE,IAAI,GAAG,QAAQ,CAAC;KAC3B,CAAC;IACF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,GAAG,CAAC,EAAE;QACJ,gFAAgF;QAChF,WAAW,CAAC,EAAE,MAAM,CAAC;KACtB,CAAC;CACH,CAAC;AAuBF,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,aAAa,EAAE,GAAG,EAAE,uBAAuB;mBA+MlE,OAAO,OAAO;QAAE,MAAM,EAAE,OAAO,CAAC;YAAE,IAAI,EAAE,MAAM,EAAE,CAAA;SAAE,CAAC,CAAA;KAAE,KAAG,OAAO,CAAC,QAAQ,CAAC;oBAAzE,OAAO,OAAO;QAAE,MAAM,EAAE,OAAO,CAAC;YAAE,IAAI,EAAE,MAAM,EAAE,CAAA;SAAE,CAAC,CAAA;KAAE,KAAG,OAAO,CAAC,QAAQ,CAAC;mBAAzE,OAAO,OAAO;QAAE,MAAM,EAAE,OAAO,CAAC;YAAE,IAAI,EAAE,MAAM,EAAE,CAAA;SAAE,CAAC,CAAA;KAAE,KAAG,OAAO,CAAC,QAAQ,CAAC;sBAAzE,OAAO,OAAO;QAAE,MAAM,EAAE,OAAO,CAAC;YAAE,IAAI,EAAE,MAAM,EAAE,CAAA;SAAE,CAAC,CAAA;KAAE,KAAG,OAAO,CAAC,QAAQ,CAAC;EAsCnG;AAGD,OAAO,EACL,oBAAoB,EACpB,oBAAoB,EACpB,eAAe,GAChB,MAAM,0BAA0B,CAAC"}
@@ -0,0 +1,257 @@
1
+ import 'server-only';
2
+ import path from 'node:path';
3
+ import fs from 'node:fs';
4
+ import { withAdminGate } from '../lib/admin_gate.server.js';
5
+ function notFound() {
6
+ return new Response(JSON.stringify({ error: { reason: 'not_found' } }), {
7
+ status: 404,
8
+ headers: { 'Content-Type': 'application/json' },
9
+ });
10
+ }
11
+ function notInstalled(pkg) {
12
+ return new Response(JSON.stringify({ error: { reason: `${pkg} not installed` } }), {
13
+ status: 503,
14
+ headers: { 'Content-Type': 'application/json' },
15
+ });
16
+ }
17
+ function jsonToResponse(r) {
18
+ return new Response(JSON.stringify(r.body), {
19
+ status: r.status,
20
+ headers: { 'Content-Type': 'application/json' },
21
+ });
22
+ }
23
+ export function createAdminPresetRoutes(manifest, cfg) {
24
+ const jobsSection = manifest.sections.find((s) => s.kind === 'jobs');
25
+ const jobsPermission = jobsSection?.permission ?? 'admin_system';
26
+ const logsSection = manifest.sections.find((s) => s.kind === 'logs');
27
+ const logsPermission = logsSection?.permission ?? 'admin_system';
28
+ const envSection = manifest.sections.find((s) => s.kind === 'env_migration');
29
+ const envPermission = envSection?.permission ?? 'env.migrate';
30
+ async function handleJobs(request, method, segments) {
31
+ const jobsMod = await import('hazo_jobs/server').catch(() => null);
32
+ if (!jobsMod)
33
+ return notInstalled('hazo_jobs');
34
+ const connect = await cfg.getHazoConnect();
35
+ const jobsClient = jobsMod.createJobsClient({
36
+ connect: { adapter: connect },
37
+ dialect: cfg.jobs?.dialect ?? 'pg',
38
+ });
39
+ const h = jobsMod.createRouteHandlers(jobsClient, {
40
+ supervisorUrl: cfg.jobs?.supervisorUrl,
41
+ supervisorToken: cfg.jobs?.supervisorToken,
42
+ });
43
+ const searchParams = new URL(request.url).searchParams;
44
+ const s = segments;
45
+ if (method === 'GET' && s.length === 0)
46
+ return jsonToResponse(await h.list(searchParams));
47
+ if (method === 'GET' && s[0] === 'types')
48
+ return jsonToResponse(await h.getJobTypes());
49
+ if (method === 'GET' && s[0] === 'pool' && s.length === 1)
50
+ return jsonToResponse(await h.getPool());
51
+ if (method === 'GET' && s[0] === 'pool' && s[1] === 'health')
52
+ return jsonToResponse(await h.getPoolHealth());
53
+ if (method === 'POST' && s[0] === 'pool' && s[1] === 'size') {
54
+ const body = await request.json();
55
+ return jsonToResponse(await h.setPoolSize(Number(body.size)));
56
+ }
57
+ if (method === 'POST' && s[0] === 'bulk-retry') {
58
+ return jsonToResponse(await h.bulkRetry(await request.json()));
59
+ }
60
+ if (method === 'GET' && s[0] === 'schedules' && s.length === 1)
61
+ return jsonToResponse(await h.listSchedules());
62
+ if (method === 'POST' && s[0] === 'schedules' && s.length === 1) {
63
+ return jsonToResponse(await h.createSchedule(await request.json()));
64
+ }
65
+ if (method === 'GET' && s[0] === 'schedules' && s[1] && s.length === 2)
66
+ return jsonToResponse(await h.getSchedule(s[1]));
67
+ if (method === 'PUT' && s[0] === 'schedules' && s[1] && s.length === 2) {
68
+ return jsonToResponse(await h.updateSchedule(s[1], await request.json()));
69
+ }
70
+ if (method === 'DELETE' && s[0] === 'schedules' && s[1] && s.length === 2)
71
+ return jsonToResponse(await h.deleteSchedule(s[1]));
72
+ if (method === 'POST' && s[0] === 'schedules' && s[1] && s[2] === 'fire')
73
+ return jsonToResponse(await h.fireSchedule(s[1]));
74
+ // Job-level routes (s[0] is a job id, not a keyword)
75
+ if (method === 'GET' && s[0] && s.length === 1 && !['types', 'pool', 'schedules', 'bulk-retry'].includes(s[0])) {
76
+ return jsonToResponse(await h.get(s[0]));
77
+ }
78
+ if (method === 'GET' && s[0] && s[1] === 'logs')
79
+ return jsonToResponse(await h.getJobLogs(s[0], searchParams));
80
+ if (method === 'POST' && s[0] && s[1] === 'cancel')
81
+ return jsonToResponse(await h.cancel(s[0]));
82
+ if (method === 'POST' && s[0] && s[1] === 'retry')
83
+ return jsonToResponse(await h.retry(s[0]));
84
+ if (method === 'POST' && s[0] && s[1] === 'trigger')
85
+ return jsonToResponse(await h.trigger(s[0]));
86
+ if (method === 'DELETE' && s[0] && s.length === 1 && !['schedules'].includes(s[0])) {
87
+ return jsonToResponse(await h.delete(s[0]));
88
+ }
89
+ return notFound();
90
+ }
91
+ async function handleLogs(request, method) {
92
+ const logsMod = await import('hazo_logs/ui/server').catch(() => null);
93
+ if (!logsMod)
94
+ return notInstalled('hazo_logs');
95
+ const logsHandler = logsMod.createLogApiHandler({ logDirectory: cfg.logDirectory });
96
+ if (method === 'GET')
97
+ return logsHandler.GET(request);
98
+ return logsHandler.POST(request);
99
+ }
100
+ async function handleEnv(request, method, segments) {
101
+ const hazoEnv = await import('hazo_env').catch(() => null);
102
+ if (!hazoEnv)
103
+ return notInstalled('hazo_env');
104
+ const s = segments;
105
+ // Resolve progress dir
106
+ let progressDir;
107
+ if (cfg.env?.progressDir) {
108
+ progressDir = cfg.env.progressDir;
109
+ }
110
+ else {
111
+ const filesConfig = hazoEnv.resolveFilesConfig();
112
+ progressDir = path.join(filesConfig.local.basePath, 'migrations', 'progress');
113
+ }
114
+ // GET /env/list
115
+ if (method === 'GET' && s[0] === 'list' && s.length === 1) {
116
+ const envs = hazoEnv.listEnvs();
117
+ return new Response(JSON.stringify(envs), {
118
+ status: 200,
119
+ headers: { 'Content-Type': 'application/json' },
120
+ });
121
+ }
122
+ // GET /env/health
123
+ if (method === 'GET' && s[0] === 'health' && s.length === 1) {
124
+ const report = await hazoEnv.doctor();
125
+ return new Response(JSON.stringify(report), {
126
+ status: 200,
127
+ headers: { 'Content-Type': 'application/json' },
128
+ });
129
+ }
130
+ // POST /env/migrate — submit migration job
131
+ if (method === 'POST' && s[0] === 'migrate' && s.length === 1) {
132
+ const jobsMod = await import('hazo_jobs/server').catch(() => null);
133
+ if (!jobsMod)
134
+ return notInstalled('hazo_jobs');
135
+ const body = await request.json();
136
+ const connect = await cfg.getHazoConnect();
137
+ const jobsClient = jobsMod.createJobsClient({
138
+ connect: { adapter: connect },
139
+ dialect: cfg.jobs?.dialect ?? 'pg',
140
+ });
141
+ const { jobId } = await jobsClient.submit({
142
+ type: 'env.migrate',
143
+ description: `env-migrate:${body.from}→${body.to}${body.dryRun ? ' (dry-run)' : ''}`,
144
+ payload: body,
145
+ });
146
+ return new Response(JSON.stringify({ jobId }), {
147
+ status: 200,
148
+ headers: { 'Content-Type': 'application/json' },
149
+ });
150
+ }
151
+ // GET /env/migrate/:jobId
152
+ if (method === 'GET' && s[0] === 'migrate' && s[1] && s.length === 2) {
153
+ const jobsMod = await import('hazo_jobs/server').catch(() => null);
154
+ if (!jobsMod)
155
+ return notInstalled('hazo_jobs');
156
+ const connect = await cfg.getHazoConnect();
157
+ const jobsClient = jobsMod.createJobsClient({
158
+ connect: { adapter: connect },
159
+ dialect: cfg.jobs?.dialect ?? 'pg',
160
+ });
161
+ const job = await jobsClient.get(s[1]);
162
+ return new Response(JSON.stringify(job), {
163
+ status: 200,
164
+ headers: { 'Content-Type': 'application/json' },
165
+ });
166
+ }
167
+ // GET /env/migrate/:jobId/progress
168
+ if (method === 'GET' && s[0] === 'migrate' && s[1] && s[2] === 'progress' && s.length === 3) {
169
+ const progress = hazoEnv.readMigrationProgress(progressDir, s[1]);
170
+ return new Response(JSON.stringify(progress), {
171
+ status: 200,
172
+ headers: { 'Content-Type': 'application/json' },
173
+ });
174
+ }
175
+ // GET /env/snapshots?to=<env>
176
+ if (method === 'GET' && s[0] === 'snapshots' && s.length === 1) {
177
+ const url = new URL(request.url);
178
+ const toEnv = url.searchParams.get('to');
179
+ if (!toEnv) {
180
+ return new Response(JSON.stringify({ error: { reason: 'missing ?to= param' } }), {
181
+ status: 400,
182
+ headers: { 'Content-Type': 'application/json' },
183
+ });
184
+ }
185
+ let snapshots = [];
186
+ try {
187
+ const dbConfig = hazoEnv.resolveConnectConfig({ env: toEnv, allowOtherEnv: true });
188
+ if (dbConfig.type === 'sqlite' && dbConfig.sqlite?.database_path) {
189
+ const snapshotsDir = path.join(path.dirname(dbConfig.sqlite.database_path), 'snapshots');
190
+ if (fs.existsSync(snapshotsDir)) {
191
+ const files = fs.readdirSync(snapshotsDir);
192
+ snapshots = files.map((f) => {
193
+ const absPath = path.join(snapshotsDir, f);
194
+ return {
195
+ snapshotId: absPath,
196
+ fileName: f,
197
+ createdAt: fs.statSync(absPath).mtime.toISOString(),
198
+ };
199
+ });
200
+ }
201
+ }
202
+ }
203
+ catch {
204
+ // If config resolution fails, return empty array
205
+ snapshots = [];
206
+ }
207
+ return new Response(JSON.stringify(snapshots), {
208
+ status: 200,
209
+ headers: { 'Content-Type': 'application/json' },
210
+ });
211
+ }
212
+ // POST /env/snapshots/restore
213
+ if (method === 'POST' && s[0] === 'snapshots' && s[1] === 'restore' && s.length === 2) {
214
+ const body = await request.json();
215
+ if (!body.to || !body.snapshotId) {
216
+ return new Response(JSON.stringify({ error: { reason: 'body must include to and snapshotId' } }), {
217
+ status: 400,
218
+ headers: { 'Content-Type': 'application/json' },
219
+ });
220
+ }
221
+ const toDbConfig = hazoEnv.resolveConnectConfig({ env: body.to, allowOtherEnv: true });
222
+ hazoEnv.restoreSnapshot(toDbConfig, body.snapshotId);
223
+ return new Response(JSON.stringify({ ok: true }), {
224
+ status: 200,
225
+ headers: { 'Content-Type': 'application/json' },
226
+ });
227
+ }
228
+ return notFound();
229
+ }
230
+ function makeHandler(method) {
231
+ return async (request, ctx) => {
232
+ const { path: routePath } = await ctx.params;
233
+ const [group, ...rest] = routePath;
234
+ if (group === 'jobs') {
235
+ const gated = withAdminGate((_req, _gate) => handleJobs(_req, method, rest), { required_permissions: [jobsPermission] });
236
+ return gated(request);
237
+ }
238
+ if (group === 'logs') {
239
+ const gated = withAdminGate((_req, _gate) => handleLogs(_req, method), { required_permissions: [logsPermission] });
240
+ return gated(request);
241
+ }
242
+ if (group === 'env') {
243
+ const gated = withAdminGate((_req, _gate) => handleEnv(_req, method, rest), { required_permissions: [envPermission] });
244
+ return gated(request);
245
+ }
246
+ return notFound();
247
+ };
248
+ }
249
+ return {
250
+ GET: makeHandler('GET'),
251
+ POST: makeHandler('POST'),
252
+ PUT: makeHandler('PUT'),
253
+ DELETE: makeHandler('DELETE'),
254
+ };
255
+ }
256
+ // Re-export env job handler utilities
257
+ export { envMigrateJobHandler, ENV_MIGRATE_JOB_TYPE, registerEnvJobs, } from './env_migrate_handler.js';
@@ -0,0 +1,10 @@
1
+ import { type AdminLayoutProps } from './admin_layout.js';
2
+ import type { AdminManifest } from './admin_nav.js';
3
+ export interface AdminAppProps {
4
+ manifest: AdminManifest;
5
+ user: AdminLayoutProps['user'];
6
+ permissions: string[];
7
+ correlationId?: string;
8
+ }
9
+ export declare function AdminApp({ manifest, user, permissions, correlationId }: AdminAppProps): import("react").JSX.Element;
10
+ //# sourceMappingURL=admin_app.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admin_app.d.ts","sourceRoot":"","sources":["../../src/components/admin_app.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAe,KAAK,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAOvE,OAAO,KAAK,EAAE,aAAa,EAAgB,MAAM,gBAAgB,CAAC;AAElE,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,aAAa,CAAC;IACxB,IAAI,EAAE,gBAAgB,CAAC,MAAM,CAAC,CAAC;IAC/B,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAYD,wBAAgB,QAAQ,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,aAAa,EAAE,EAAE,aAAa,+BAkBrF"}
@@ -0,0 +1,32 @@
1
+ 'use client';
2
+ import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { usePathname } from 'next/navigation';
4
+ import { AdminLayout } from './admin_layout.js';
5
+ import { UsersPanel } from './users_panel.js';
6
+ import { JobsPanel } from './jobs_panel.js';
7
+ import { LogsPanel } from './logs_panel.js';
8
+ import { EnvMigrationPanel } from './env_migration_panel.js';
9
+ import { HealthPanel } from './health_panel.js';
10
+ import { resolveNav } from './admin_nav.js';
11
+ function renderPanel(item) {
12
+ if (item.kind === 'users')
13
+ return _jsx(UsersPanel, { apiBasePath: item.basePath });
14
+ if (item.kind === 'jobs')
15
+ return _jsx(JobsPanel, { basePath: item.basePath });
16
+ if (item.kind === 'logs')
17
+ return _jsx(LogsPanel, { apiBasePath: item.basePath });
18
+ if (item.kind === 'env_migration')
19
+ return _jsx(EnvMigrationPanel, { basePath: item.basePath });
20
+ if (item.kind === 'health')
21
+ return _jsx(HealthPanel, { basePath: item.basePath });
22
+ if (item.kind === 'custom' && item.component)
23
+ return _jsx(_Fragment, { children: item.component });
24
+ return _jsx("div", { className: "p-6 text-sm text-gray-500", children: "Panel not found." });
25
+ }
26
+ export function AdminApp({ manifest, user, permissions, correlationId }) {
27
+ const pathname = usePathname();
28
+ const navItems = resolveNav(manifest, permissions);
29
+ // Find the active item by best-match path prefix
30
+ const active = navItems.find((item) => item.path === '/' ? pathname === item.path : pathname?.startsWith(item.path)) ?? navItems[0] ?? null;
31
+ return (_jsx(AdminLayout, { manifest: manifest, user: user, permissions: permissions, correlationId: correlationId, children: active ? renderPanel(active) : (_jsx("div", { className: "p-6 text-sm text-gray-500", children: navItems.length === 0 ? 'No admin sections configured.' : 'Select a section from the sidebar.' })) }));
32
+ }
@@ -0,0 +1,15 @@
1
+ import React from 'react';
2
+ import type { AdminManifest } from './admin_nav.js';
3
+ export interface AdminLayoutProps {
4
+ manifest: AdminManifest;
5
+ user: {
6
+ id: string;
7
+ email_address: string;
8
+ name?: string | null;
9
+ } | null;
10
+ permissions: string[];
11
+ children: React.ReactNode;
12
+ correlationId?: string;
13
+ }
14
+ export declare function AdminLayout({ manifest, user, permissions, children, correlationId }: AdminLayoutProps): React.JSX.Element;
15
+ //# sourceMappingURL=admin_layout.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admin_layout.d.ts","sourceRoot":"","sources":["../../src/components/admin_layout.tsx"],"names":[],"mappings":"AACA,OAAO,KAAmB,MAAM,OAAO,CAAC;AAKxC,OAAO,KAAK,EAAE,aAAa,EAAgB,MAAM,gBAAgB,CAAC;AAGlE,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,aAAa,CAAC;IACxB,IAAI,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GAAG,IAAI,CAAC;IACzE,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;IAC1B,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAYD,wBAAgB,WAAW,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,QAAQ,EAAE,aAAa,EAAE,EAAE,gBAAgB,qBAwErG"}
@@ -0,0 +1,37 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useState } from 'react';
4
+ import Link from 'next/link';
5
+ import { usePathname } from 'next/navigation';
6
+ import { ChevronLeft, ChevronRight, Users, Briefcase, FileText, LayoutDashboard, ArrowLeftRight, Activity } from 'lucide-react';
7
+ import { HazoContextProvider, cn } from 'hazo_ui';
8
+ import { resolveNav } from './admin_nav.js';
9
+ // Default icon per kind
10
+ function defaultIcon(kind) {
11
+ if (kind === 'users')
12
+ return _jsx(Users, { size: 16 });
13
+ if (kind === 'jobs')
14
+ return _jsx(Briefcase, { size: 16 });
15
+ if (kind === 'logs')
16
+ return _jsx(FileText, { size: 16 });
17
+ if (kind === 'env_migration')
18
+ return _jsx(ArrowLeftRight, { size: 16 });
19
+ if (kind === 'health')
20
+ return _jsx(Activity, { size: 16 });
21
+ return _jsx(LayoutDashboard, { size: 16 });
22
+ }
23
+ export function AdminLayout({ manifest, user, permissions, children, correlationId }) {
24
+ const [collapsed, setCollapsed] = useState(false);
25
+ const pathname = usePathname();
26
+ const navItems = resolveNav(manifest, permissions);
27
+ function isActive(item) {
28
+ return item.path === '/' ? pathname === item.path : pathname?.startsWith(item.path);
29
+ }
30
+ return (_jsx(HazoContextProvider, { correlationId: correlationId, userId: user?.id, children: _jsxs("div", { className: "flex min-h-screen", children: [_jsxs("aside", { className: cn('flex flex-col border-r border-gray-200 bg-white transition-all duration-200', collapsed ? 'w-14' : 'w-56'), children: [_jsxs("div", { className: "flex items-center justify-between border-b border-gray-200 px-3 py-3", children: [!collapsed && (_jsx("span", { className: "truncate text-sm font-semibold text-gray-800", children: manifest.title ?? 'Admin' })), _jsx("button", { onClick: () => setCollapsed((c) => !c), className: "ml-auto flex h-6 w-6 items-center justify-center rounded text-gray-400 hover:bg-gray-100 hover:text-gray-600", "aria-label": collapsed ? 'Expand sidebar' : 'Collapse sidebar', children: collapsed ? _jsx(ChevronRight, { size: 14 }) : _jsx(ChevronLeft, { size: 14 }) })] }), _jsx("nav", { className: "flex flex-col gap-1 p-2 flex-1", children: navItems.map((item) => {
31
+ const active = isActive(item);
32
+ const icon = item.icon ?? defaultIcon(item.kind);
33
+ return (_jsxs(Link, { href: item.path, className: cn('flex items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors', collapsed ? 'justify-center' : '', active
34
+ ? 'bg-blue-50 text-blue-700 font-medium'
35
+ : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'), title: collapsed ? item.label : undefined, children: [_jsx("span", { className: "shrink-0", children: icon }), !collapsed && _jsx("span", { className: "truncate", children: item.label })] }, item.path));
36
+ }) }), !collapsed && user && (_jsx("div", { className: "border-t border-gray-200 px-3 py-2 text-xs text-gray-400 truncate", children: user.name ?? user.email_address }))] }), _jsx("main", { className: "flex-1 overflow-auto", children: children })] }) }));
37
+ }
@@ -0,0 +1,31 @@
1
+ import type React from 'react';
2
+ export type AdminNavSection = {
3
+ kind: 'users' | 'jobs' | 'logs' | 'env_migration' | 'health';
4
+ label?: string;
5
+ icon?: React.ReactNode;
6
+ permission?: string;
7
+ basePath?: string;
8
+ } | {
9
+ kind: 'custom';
10
+ label: string;
11
+ icon?: React.ReactNode;
12
+ permission?: string;
13
+ path: string;
14
+ component: React.ReactNode;
15
+ };
16
+ export type AdminManifest = {
17
+ sections: AdminNavSection[];
18
+ title?: string;
19
+ };
20
+ export type AdminNavItem = {
21
+ kind: 'users' | 'jobs' | 'logs' | 'env_migration' | 'health' | 'custom';
22
+ label: string;
23
+ path: string;
24
+ basePath: string;
25
+ icon?: React.ReactNode;
26
+ permission: string;
27
+ component?: React.ReactNode;
28
+ };
29
+ export declare function defaultPermissionForKind(kind: string): string;
30
+ export declare function resolveNav(manifest: AdminManifest, permissions: string[]): AdminNavItem[];
31
+ //# sourceMappingURL=admin_nav.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admin_nav.d.ts","sourceRoot":"","sources":["../../src/components/admin_nav.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAI/B,MAAM,MAAM,eAAe,GACvB;IACE,IAAI,EAAE,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,eAAe,GAAG,QAAQ,CAAC;IAC7D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,GACD;IACE,IAAI,EAAE,QAAQ,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,KAAK,CAAC,SAAS,CAAC;CAC5B,CAAC;AAEN,MAAM,MAAM,aAAa,GAAG;IAC1B,QAAQ,EAAE,eAAe,EAAE,CAAC;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAGF,MAAM,MAAM,YAAY,GAAG;IACzB,IAAI,EAAE,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,eAAe,GAAG,QAAQ,GAAG,QAAQ,CAAC;IACxE,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;CAC7B,CAAC;AAEF,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7D;AAED,wBAAgB,UAAU,CAAC,QAAQ,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,EAAE,GAAG,YAAY,EAAE,CAkEzF"}