hazo_admin 0.3.1 → 0.5.0

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 (42) hide show
  1. package/CHANGE_LOG.md +26 -0
  2. package/README.md +115 -2
  3. package/SETUP_CHECKLIST.md +4 -1
  4. package/config/hazo_admin_config.ini.sample +11 -0
  5. package/dist/api/index.d.ts +4 -0
  6. package/dist/api/index.d.ts.map +1 -1
  7. package/dist/api/index.js +174 -0
  8. package/dist/components/admin_app.d.ts.map +1 -1
  9. package/dist/components/admin_app.js +3 -0
  10. package/dist/components/admin_kinds.d.ts.map +1 -1
  11. package/dist/components/admin_kinds.js +9 -1
  12. package/dist/components/admin_nav.d.ts +2 -2
  13. package/dist/components/admin_nav.d.ts.map +1 -1
  14. package/dist/components/admin_nav.js +12 -0
  15. package/dist/components/issues_panel/index.d.ts +6 -0
  16. package/dist/components/issues_panel/index.d.ts.map +1 -0
  17. package/dist/components/issues_panel/index.js +130 -0
  18. package/dist/index.client.d.ts +4 -0
  19. package/dist/index.client.d.ts.map +1 -1
  20. package/dist/index.client.js +4 -0
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1 -0
  24. package/dist/issues/archive_handler.d.ts +29 -0
  25. package/dist/issues/archive_handler.d.ts.map +1 -0
  26. package/dist/issues/archive_handler.js +35 -0
  27. package/dist/issues/index.d.ts +8 -0
  28. package/dist/issues/index.d.ts.map +1 -0
  29. package/dist/issues/index.js +4 -0
  30. package/dist/issues/registry.client.d.ts +32 -0
  31. package/dist/issues/registry.client.d.ts.map +1 -0
  32. package/dist/issues/registry.client.js +15 -0
  33. package/dist/issues/registry.d.ts +45 -0
  34. package/dist/issues/registry.d.ts.map +1 -0
  35. package/dist/issues/registry.js +11 -0
  36. package/dist/issues/routing.d.ts +17 -0
  37. package/dist/issues/routing.d.ts.map +1 -0
  38. package/dist/issues/routing.js +43 -0
  39. package/dist/issues/store.d.ts +61 -0
  40. package/dist/issues/store.d.ts.map +1 -0
  41. package/dist/issues/store.js +227 -0
  42. package/package.json +15 -10
package/CHANGE_LOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # hazo_admin Changelog
2
2
 
3
+ ## [0.5.0] - 2026-06-19
4
+
5
+ ### Added
6
+ - **Issues system** — actionable admin event queue with scoped visibility, status transitions, and pluggable action dispatch
7
+ - `createIssueStore(connect)` — CRUD + deduplicated upsert + status transitions (`new`→`wip`→`closed`) + resolution + archive sweep
8
+ - `registerIssueType` / `getIssueType` / `listIssueTypes` — consumer-defined issue type registry with actions + notification hooks
9
+ - `loadIssueRoutingConfig` / `getRecipientStrategy` — INI-driven routing (e.g. `nearest_scope_admin`)
10
+ - `issueArchiveJobHandler` / `registerIssueArchiveJob` / `ADMIN_ISSUE_ARCHIVE_JOB_TYPE` — background job sweeping closed issues older than configurable threshold (default 3 months)
11
+ - `handleIssues` API group in `createAdminPresetRoutes`: `GET /issues`, `GET /issues/:id`, `POST /issues/:id/transition`, `POST /issues/:id/assign`, `POST /issues/:id/resolve`
12
+ - `IssuesPanel` client component wired into `AdminApp` for `kind: 'issues'` sections
13
+ - `issues` kind in `ADMIN_KINDS` registry (group `operate`, default permission `admin_issue_triage`, `AlertCircle` icon)
14
+ - `registerIssueCardRenderer` / `getIssueCardRenderer` / `listIssueCardRenderers` / `DefaultIssueCard` exported from `hazo_admin/client` — consumers override card rendering per issue type
15
+ - Two new permission constants: `ADMIN_ISSUE_TRIAGE` (`'admin_issue_triage'`) and `ADMIN_USER_SCOPE_ASSIGNMENT` (`'admin_user_scope_assignment'`)
16
+ - `[issue_routing]` INI config section in `hazo_admin_config.ini.sample`
17
+ - Autotest scenarios: permissions, kind registration, store (list/create_or_bump/transition/resolve/archive), client card registry
18
+ - Seed: 4 sample issues in test-app SQLite DB for `/admin/issues` demo
19
+
20
+ ## [0.3.2] - 2026-06-12
21
+ ### Changed
22
+ - admin_gate.test.ts: deleted 110-line mock of hazo_auth/server-lib; now calls real hazo_get_auth via set_hazo_connect_instance injector (hazo_connect FR-001 full close).
23
+
24
+ ### Tooling / test-app (no published-surface change)
25
+ - test-app/tsconfig.json: added a `paths` override forcing every `next`/`next/*` specifier to the single `../node_modules/next` install. hazo_auth ships its own nested `next@16`, so its re-exported route handlers' `NextRequest` was nominally distinct from the test-app's, failing `next build`'s route type-check on `/api/auth/[...nextauth]`.
26
+ - test-app: new `/api/admin-preset-imports` route + autotest scenarios that re-import the exact dynamic-import targets of `createAdminPresetRoutes` (the `hazo_config` barrel + `hazo_admin/api`). Runtime regression guard for the missing-`'use client'` RSC build break (fixed in hazo_config 2.1.11).
27
+ - test-app autotests enriched: precise env_migration/health permission isolation (was mis-documented as `admin_system`; actually `env.migrate`), resolveNav edge cases (unknown kind, custom default permission, ordering/partial visibility, group/order/component pass-through, masking defaults), and an `admin_kinds` registry metadata group.
28
+
3
29
  ## 0.2.0 — 2026-06-10
4
30
 
5
31
  ### Added
package/README.md CHANGED
@@ -83,6 +83,10 @@ import { HAZO_ADMIN_PERMISSIONS } from 'hazo_admin/client';
83
83
  // HAZO_ADMIN_PERMISSIONS.FILES_MANAGE = 'admin.files.manage' — browse / manage data files
84
84
  // HAZO_ADMIN_PERMISSIONS.FEEDBACK_REVIEW = 'admin.feedback.review' — review user feedback
85
85
  // HAZO_ADMIN_PERMISSIONS.METRICS_VIEW = 'metrics.view' — view metrics / analytics panel
86
+
87
+ // Issues (v0.5.0)
88
+ // HAZO_ADMIN_PERMISSIONS.ADMIN_ISSUE_TRIAGE = 'admin_issue_triage' — triage / resolve issues queue
89
+ // HAZO_ADMIN_PERMISSIONS.ADMIN_USER_SCOPE_ASSIGNMENT = 'admin_user_scope_assignment' — grant scope role assignments
86
90
  ```
87
91
 
88
92
  **These must exist as rows in the consuming app's `hazo_permissions` table.** The consuming app is responsible for seeding them.
@@ -105,6 +109,7 @@ All nav kind metadata is centralized in `src/lib/admin_kinds.ts`. Each kind maps
105
109
  | `blog` | `blog.manage` | `hazo_blog` | stub (Phase 4) |
106
110
  | `files` | `files.manage` | `hazo_files` | stub (Phase 4) |
107
111
  | `feedback` | `feedback.review` | `hazo_feedback` | stub (Phase 4) |
112
+ | `issues` | `admin_issue_triage` | `hazo_admin` (built-in) | done (v0.5.0) |
108
113
  | `metrics` | `metrics.view` | consumer-supplied `component` (e.g. `hazo_umetrics`) | done (consumer-component) |
109
114
 
110
115
  ### `metrics` kind — consumer component pattern
@@ -123,6 +128,114 @@ const MANIFEST = {
123
128
 
124
129
  If no `component` is supplied, the panel renders a placeholder message. This keeps `hazo_admin` dependency-free from any metrics package.
125
130
 
131
+ ## Issues system (v0.5.0)
132
+
133
+ 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.
134
+
135
+ ### Registering the issues section
136
+
137
+ ```ts
138
+ const MANIFEST = {
139
+ sections: [
140
+ { kind: 'issues' as const },
141
+ // custom permission if needed:
142
+ // { kind: 'issues' as const, permission: 'my_custom_triage_perm' },
143
+ ],
144
+ };
145
+ ```
146
+
147
+ The default gate permission is `admin_issue_triage`. Add it to your `hazo_permissions` table and assign it to admin roles.
148
+
149
+ ### Issue types — consumer-defined
150
+
151
+ `hazo_admin` ships with zero built-in issue types. Consumers register types at boot:
152
+
153
+ ```ts
154
+ import { registerIssueType } from 'hazo_admin';
155
+
156
+ registerIssueType({
157
+ key: 'auth_permission',
158
+ label: 'Permission Request',
159
+ actions: [
160
+ {
161
+ key: 'grant',
162
+ label: 'Grant',
163
+ async run(issue, params, ctx) {
164
+ // call hazo_auth to assign role or permission
165
+ const connect = await ctx.getHazoConnect();
166
+ // ... grant logic ...
167
+ return { data: { roleId: params.roleId } };
168
+ },
169
+ },
170
+ {
171
+ key: 'deny',
172
+ label: 'Deny',
173
+ async run(issue, params, ctx) { /* optional deny side-effects */ },
174
+ },
175
+ ],
176
+ buildResolutionNotice(issue, actionKey, result) {
177
+ return {
178
+ subject: actionKey === 'grant' ? 'Access granted' : 'Access denied',
179
+ body: 'Your permission request has been reviewed.',
180
+ deep_link: '/dashboard',
181
+ };
182
+ },
183
+ });
184
+ ```
185
+
186
+ ### Custom card renderers (client)
187
+
188
+ Override how a type's issue card looks in the `IssuesPanel`:
189
+
190
+ ```ts
191
+ import { registerIssueCardRenderer } from 'hazo_admin/client';
192
+
193
+ registerIssueCardRenderer('auth_permission', ({ issue, onTransition }) => (
194
+ <div>
195
+ <h3>{issue.title}</h3>
196
+ <button onClick={() => onTransition('wip')}>Take it</button>
197
+ </div>
198
+ ));
199
+ ```
200
+
201
+ If no renderer is registered for a type, `DefaultIssueCard` is used.
202
+
203
+ ### Issue routing config
204
+
205
+ Add to `hazo_admin_config.ini`:
206
+
207
+ ```ini
208
+ [issue_routing]
209
+ ; Map issue types to recipient strategies.
210
+ ; Supported strategies: nearest_scope_admin
211
+ auth_permission = nearest_scope_admin
212
+ ```
213
+
214
+ ### API routes (via `createAdminPresetRoutes`)
215
+
216
+ | Method | Path | Description |
217
+ |---|---|---|
218
+ | `GET` | `/issues` | List issues (filtered by admin's scope) |
219
+ | `GET` | `/issues/:id` | Get single issue |
220
+ | `POST` | `/issues/:id/transition` | Transition status (`new`→`wip`→`closed`) |
221
+ | `POST` | `/issues/:id/assign` | Assign to a user |
222
+ | `POST` | `/issues/:id/resolve` | Run an action + close the issue |
223
+
224
+ Scoped admins only see issues within their assigned scopes. Global admins (`admin_system`) see all.
225
+
226
+ ### Archive job
227
+
228
+ Register the built-in archive job to sweep old closed issues:
229
+
230
+ ```ts
231
+ import { registerIssueArchiveJob, ADMIN_ISSUE_ARCHIVE_JOB_TYPE } from 'hazo_admin';
232
+ import { createJobQueue } from 'hazo_jobs';
233
+
234
+ registerIssueArchiveJob(jobQueue, { getHazoConnect, archiveAfterMonths: 3 });
235
+ ```
236
+
237
+ ---
238
+
126
239
  ## v0.2.0 Breaking changes
127
240
 
128
241
  ### `GET /env/list` returns `{name, role}[]` instead of `string[]`
@@ -145,8 +258,8 @@ Consumers of this endpoint (e.g. `EnvMigrationPanel`) must read the `role` field
145
258
 
146
259
  | Import path | Contents | Server-safe |
147
260
  |---|---|---|
148
- | `hazo_admin` | `adminGate`, `withAdminGate`, `HAZO_ADMIN_PERMISSIONS`, `AdminGateResult` | Server only |
149
- | `hazo_admin/client` | `HAZO_ADMIN_PERMISSIONS`, types | Yes |
261
+ | `hazo_admin` | `adminGate`, `withAdminGate`, `HAZO_ADMIN_PERMISSIONS`, `AdminGateResult`, `createIssueStore`, `registerIssueType`, `getIssueType`, `listIssueTypes`, `loadIssueRoutingConfig`, `registerIssueArchiveJob`, `ADMIN_ISSUE_ARCHIVE_JOB_TYPE` | Server only |
262
+ | `hazo_admin/client` | `HAZO_ADMIN_PERMISSIONS`, `registerIssueCardRenderer`, `getIssueCardRenderer`, `listIssueCardRenderers`, `DefaultIssueCard`, types | Yes |
150
263
  | `hazo_admin/api` | `createAdminPresetRoutes` | Server only |
151
264
  | `hazo_admin/jobs` | `ENV_MIGRATE_JOB_TYPE`, `envMigrateJobHandler` | Server only (no React) |
152
265
  | `hazo_admin/ui` | All panel components + `resolveNav` | Client |
@@ -22,7 +22,10 @@ INSERT INTO hazo_permissions (id, name) VALUES
22
22
  (gen_random_uuid(), 'admin.blog.manage'),
23
23
  (gen_random_uuid(), 'admin.files.manage'),
24
24
  (gen_random_uuid(), 'admin.feedback.review'),
25
- (gen_random_uuid(), 'metrics.view')
25
+ (gen_random_uuid(), 'metrics.view'),
26
+ -- Issues system (add if using the issues kind)
27
+ (gen_random_uuid(), 'admin_issue_triage'),
28
+ (gen_random_uuid(), 'admin_user_scope_assignment')
26
29
  ON CONFLICT (name) DO NOTHING;
27
30
  ```
28
31
 
@@ -0,0 +1,11 @@
1
+ ; hazo_admin configuration
2
+ ; Copy to hazo_admin_config.ini and adjust for your environment.
3
+
4
+ [env]
5
+ ; Override the environment identifier (default: HAZO_ENV ?? NODE_ENV ?? 'development')
6
+ ; name = production
7
+
8
+ [issue_routing]
9
+ ; Map issue types to recipient strategies.
10
+ ; Supported strategies: nearest_scope_admin
11
+ auth_permission = nearest_scope_admin
@@ -19,6 +19,10 @@ export type AdminPresetRoutesConfig = {
19
19
  /** Override default progress dir. Defaults to <dataRoot>/migrations/progress */
20
20
  progressDir?: string;
21
21
  };
22
+ issues?: {
23
+ /** Months before closed issues are archived. Default 3. */
24
+ archiveAfterMonths?: number;
25
+ };
22
26
  };
23
27
  export declare function createAdminPresetRoutes(manifest: AdminManifest, cfg: AdminPresetRoutesConfig): {
24
28
  GET: (request: Request, ctx: {
@@ -1 +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+RlE,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;EA8FnG;AAGD,OAAO,EACL,oBAAoB,EACpB,oBAAoB,EACpB,eAAe,GAChB,MAAM,0BAA0B,CAAC"}
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;IACF,MAAM,CAAC,EAAE;QACP,2DAA2D;QAC3D,kBAAkB,CAAC,EAAE,MAAM,CAAC;KAC7B,CAAC;CACH,CAAC;AAuBF,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,aAAa,EAAE,GAAG,EAAE,uBAAuB;mBAudlE,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;EAuGnG;AAGD,OAAO,EACL,oBAAoB,EACpB,oBAAoB,EACpB,eAAe,GAChB,MAAM,0BAA0B,CAAC"}
package/dist/api/index.js CHANGED
@@ -41,6 +41,8 @@ export function createAdminPresetRoutes(manifest, cfg) {
41
41
  const filesPermission = filesSection?.permission ?? 'admin.files.manage';
42
42
  const feedbackSection = manifest.sections.find((s) => s.kind === 'feedback');
43
43
  const feedbackPermission = feedbackSection?.permission ?? 'admin.feedback.review';
44
+ const issuesSection = manifest.sections.find((s) => s.kind === 'issues');
45
+ const issuesPermission = issuesSection?.permission ?? 'admin_issue_triage';
44
46
  async function handleJobs(request, method, segments) {
45
47
  const jobsMod = await import('hazo_jobs/server').catch(() => null);
46
48
  if (!jobsMod)
@@ -303,6 +305,173 @@ export function createAdminPresetRoutes(manifest, cfg) {
303
305
  // Placeholder stub — TODO: implement real sub-routes
304
306
  return Response.json({ placeholder: true, panel: 'feedback' });
305
307
  }
308
+ async function handleIssues(request, method, segments, gateResult) {
309
+ const { createIssueStore, getIssueType } = await import('../issues/index.js');
310
+ const connect = await cfg.getHazoConnect();
311
+ const store = createIssueStore(connect);
312
+ // Derive actor identity from the authenticated gate result (never from the client).
313
+ const actorUserId = gateResult?.user?.id ?? gateResult?.user?.user_id ?? '';
314
+ // Derive admin scope from the gate result — never trust client-supplied params.
315
+ const isGlobalAdmin = (gateResult?.permissions ?? []).includes('admin_system');
316
+ let adminScopeIds = [];
317
+ if (!isGlobalAdmin) {
318
+ // Try to get user scopes from hazo_auth.
319
+ const authLib = await import('hazo_auth/server-lib').catch(() => null);
320
+ if (authLib?.get_user_scope_assignments) {
321
+ const scopeAssignments = await authLib.get_user_scope_assignments(actorUserId, connect).catch(() => []);
322
+ adminScopeIds = scopeAssignments.map((a) => a.scope_id).filter(Boolean);
323
+ // Also include descendants if the helper is available.
324
+ if (authLib.get_scope_descendants && adminScopeIds.length > 0) {
325
+ const descendants = await authLib.get_scope_descendants(adminScopeIds, connect).catch(() => []);
326
+ adminScopeIds = [...new Set([...adminScopeIds, ...descendants.map((d) => d.id ?? d.scope_id)])];
327
+ }
328
+ }
329
+ // If hazo_auth not available OR user has no scope assignments → adminScopeIds stays [].
330
+ }
331
+ /** Returns a 403 forbidden response. */
332
+ function forbidden() {
333
+ return new Response(JSON.stringify({ error: { reason: 'forbidden' } }), {
334
+ status: 403,
335
+ headers: { 'Content-Type': 'application/json' },
336
+ });
337
+ }
338
+ /**
339
+ * Returns true when the current admin is allowed to access the given issue.
340
+ * Global admins can see everything. Scoped admins must have the issue's
341
+ * scope_id (or recipient_scope_id) in their allowed list.
342
+ */
343
+ function canAccessIssue(issue) {
344
+ if (isGlobalAdmin)
345
+ return true;
346
+ if (adminScopeIds.length === 0)
347
+ return false;
348
+ return (adminScopeIds.includes(issue.scope_id) ||
349
+ adminScopeIds.includes(issue.recipient_scope_id ?? ''));
350
+ }
351
+ // GET /issues — list issues
352
+ if (method === 'GET' && segments.length === 0) {
353
+ const url = new URL(request.url);
354
+ const status = url.searchParams.get('status');
355
+ const assignedTo = url.searchParams.get('assignedTo');
356
+ const type = url.searchParams.get('type');
357
+ const statusFilter = status ? status.split(',') : ['new', 'wip', 'closed'];
358
+ const issues = await store.listIssues({
359
+ adminScopeIds: isGlobalAdmin ? undefined : adminScopeIds,
360
+ isGlobalAdmin,
361
+ status: statusFilter,
362
+ assignedTo: assignedTo ?? undefined,
363
+ type: type ?? undefined,
364
+ });
365
+ return Response.json({ issues });
366
+ }
367
+ // GET /issues/:id
368
+ if (method === 'GET' && segments.length === 1) {
369
+ const issue = await store.getIssue(segments[0]);
370
+ if (!issue)
371
+ return notFound();
372
+ if (!canAccessIssue(issue))
373
+ return forbidden();
374
+ return Response.json({ issue });
375
+ }
376
+ // POST /issues/:id/transition
377
+ if (method === 'POST' && segments.length === 2 && segments[1] === 'transition') {
378
+ const body = await request.json().catch(() => ({}));
379
+ const { status } = body;
380
+ if (!status) {
381
+ return Response.json({ error: { reason: 'status required' } }, { status: 400 });
382
+ }
383
+ const issue = await store.getIssue(segments[0]);
384
+ if (!issue)
385
+ return notFound();
386
+ if (!canAccessIssue(issue))
387
+ return forbidden();
388
+ try {
389
+ const updated = await store.transitionStatus(segments[0], status, actorUserId);
390
+ return Response.json({ issue: updated });
391
+ }
392
+ catch (err) {
393
+ return Response.json({ error: { reason: err.message } }, { status: 409 });
394
+ }
395
+ }
396
+ // POST /issues/:id/assign
397
+ if (method === 'POST' && segments.length === 2 && segments[1] === 'assign') {
398
+ const body = await request.json().catch(() => ({}));
399
+ const issue = await store.getIssue(segments[0]);
400
+ if (!issue)
401
+ return notFound();
402
+ if (!canAccessIssue(issue))
403
+ return forbidden();
404
+ const updated = await store.setAssignee(segments[0], body.userId ?? null);
405
+ return Response.json({ issue: updated });
406
+ }
407
+ // POST /issues/:id/resolve
408
+ if (method === 'POST' && segments.length === 2 && segments[1] === 'resolve') {
409
+ const body = await request.json().catch(() => ({}));
410
+ const { actionKey, params = {} } = body;
411
+ if (!actionKey) {
412
+ return Response.json({ error: { reason: 'actionKey required' } }, { status: 400 });
413
+ }
414
+ const issue = await store.getIssue(segments[0]);
415
+ if (!issue)
416
+ return notFound();
417
+ if (!canAccessIssue(issue))
418
+ return forbidden();
419
+ const typeDef = getIssueType(issue.type);
420
+ if (!typeDef) {
421
+ return Response.json({ error: { reason: `Unknown issue type: ${issue.type}` } }, { status: 422 });
422
+ }
423
+ const action = typeDef.actions.find((a) => a.key === actionKey);
424
+ if (!action) {
425
+ return Response.json({ error: { reason: `Unknown action: ${actionKey}` } }, { status: 422 });
426
+ }
427
+ const ctx = {
428
+ getHazoConnect: cfg.getHazoConnect,
429
+ actorUserId,
430
+ actorPermissions: gateResult?.permissions ?? [],
431
+ };
432
+ try {
433
+ const result = await action.run(issue, params, ctx);
434
+ // If action succeeded, resolve the issue
435
+ const resolution = actionKey === 'grant' ? 'granted' : 'denied';
436
+ const resolved = await store.resolveIssue(segments[0], {
437
+ resolution,
438
+ role_id: result?.data?.roleId ?? params.roleId,
439
+ reason: params.reason,
440
+ resolved_by: actorUserId,
441
+ });
442
+ // Best-effort audit write
443
+ const auditMod = await import('hazo_audit').catch(() => null);
444
+ if (auditMod) {
445
+ const auditStore = auditMod.createAuditStore ? auditMod.createAuditStore(await cfg.getHazoConnect()) : null;
446
+ if (auditStore) {
447
+ auditStore.log({
448
+ actor_id: actorUserId,
449
+ action: `admin_issue.${actionKey}`,
450
+ resource_type: 'admin_issue',
451
+ resource_id: segments[0],
452
+ payload: { resolution, ...params },
453
+ }).catch(() => { });
454
+ }
455
+ }
456
+ // Best-effort notify the subject user
457
+ const notifyMod = await import('hazo_notify/dispatcher').catch(() => null);
458
+ if (notifyMod && typeDef.buildResolutionNotice) {
459
+ const notice = typeDef.buildResolutionNotice(resolved, actionKey, result);
460
+ notifyMod.dispatch({
461
+ recipient_user_ids: [resolved.subject_user_id],
462
+ payload: { subject: notice.subject, body: notice.body },
463
+ surfaces: ['inbox', 'email'],
464
+ deep_link: notice.deep_link,
465
+ }).catch(() => { });
466
+ }
467
+ return Response.json({ issue: resolved, actionResult: result });
468
+ }
469
+ catch (err) {
470
+ return Response.json({ error: { reason: err.message } }, { status: 500 });
471
+ }
472
+ }
473
+ return notFound();
474
+ }
306
475
  function makeHandler(method) {
307
476
  return async (request, ctx) => {
308
477
  const { path: routePath } = await ctx.params;
@@ -347,6 +516,11 @@ export function createAdminPresetRoutes(manifest, cfg) {
347
516
  const gated = withAdminGate((_req, _gate) => handleFeedback(_req, method, rest), { required_permissions: [feedbackPermission] });
348
517
  return gated(request);
349
518
  }
519
+ if (group === 'issues') {
520
+ // Resolve endpoint gated by admin_issue_triage; individual actions enforce their own perms server-side
521
+ const gated = withAdminGate((_req, _gate) => handleIssues(_req, method, rest, _gate), { required_permissions: [issuesPermission] });
522
+ return gated(request);
523
+ }
350
524
  return notFound();
351
525
  };
352
526
  }
@@ -1 +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;AAcvE,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;AAwBD,wBAAgB,QAAQ,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,aAAa,EAAE,EAAE,aAAa,+BAkBrF"}
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;AAevE,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;AAyBD,wBAAgB,QAAQ,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,aAAa,EAAE,EAAE,aAAa,+BAkBrF"}
@@ -14,6 +14,7 @@ import { ApiKeysPanel } from './api_keys_panel.js';
14
14
  import { BlogPanel } from './blog_panel.js';
15
15
  import { FilesPanel } from './files_panel.js';
16
16
  import { FeedbackPanel } from './feedback_panel.js';
17
+ import { IssuesPanel } from './issues_panel/index.js';
17
18
  import { resolveNav } from './admin_nav.js';
18
19
  function renderPanel(item) {
19
20
  if (item.kind === 'users')
@@ -40,6 +41,8 @@ function renderPanel(item) {
40
41
  return _jsx(FilesPanel, { basePath: item.basePath });
41
42
  if (item.kind === 'feedback')
42
43
  return _jsx(FeedbackPanel, { basePath: item.basePath });
44
+ if (item.kind === 'issues')
45
+ return _jsx(IssuesPanel, { basePath: item.basePath });
43
46
  if (item.kind === 'metrics') {
44
47
  return item.component
45
48
  ? _jsx(_Fragment, { children: item.component })
@@ -1 +1 @@
1
- {"version":3,"file":"admin_kinds.d.ts","sourceRoot":"","sources":["../../src/components/admin_kinds.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAO1B,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,IAAI,EAAE,MAAM,KAAK,CAAC,SAAS,CAAC;IAC5B,KAAK,EAAE,MAAM,CAAC;CACf;AAED,eAAO,MAAM,WAAW,EAAE,YAAY,EA0GrC,CAAC;AAEF,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS,CAEjE;AAED,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAErE;AAED,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,KAAK,CAAC,SAAS,GAAG,IAAI,CAGnE"}
1
+ {"version":3,"file":"admin_kinds.d.ts","sourceRoot":"","sources":["../../src/components/admin_kinds.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAO1B,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,IAAI,EAAE,MAAM,KAAK,CAAC,SAAS,CAAC;IAC5B,KAAK,EAAE,MAAM,CAAC;CACf;AAED,eAAO,MAAM,WAAW,EAAE,YAAY,EAkHrC,CAAC;AAEF,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS,CAEjE;AAED,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAErE;AAED,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,KAAK,CAAC,SAAS,GAAG,IAAI,CAGnE"}
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { Users, Briefcase, FileText, RefreshCw, Activity, ShieldCheck, History, Settings, Key, BookOpen, FolderOpen, MessageSquare, BarChart3 } from 'lucide-react';
2
+ import { Users, Briefcase, FileText, RefreshCw, Activity, ShieldCheck, History, Settings, Key, BookOpen, FolderOpen, MessageSquare, BarChart3, AlertCircle } from 'lucide-react';
3
3
  import { HAZO_ADMIN_PERMISSIONS } from '../index.client.js';
4
4
  export const ADMIN_KINDS = [
5
5
  {
@@ -98,6 +98,14 @@ export const ADMIN_KINDS = [
98
98
  group: 'content',
99
99
  icon: () => React.createElement(MessageSquare, { size: 16 }),
100
100
  },
101
+ {
102
+ kind: 'issues',
103
+ slug: 'issues',
104
+ defaultLabel: 'Issues',
105
+ defaultPermission: 'admin_issue_triage',
106
+ group: 'operate',
107
+ icon: () => React.createElement(AlertCircle, { size: 16 }),
108
+ },
101
109
  {
102
110
  kind: 'metrics',
103
111
  slug: 'analytics',
@@ -1,6 +1,6 @@
1
1
  import type React from 'react';
2
2
  export type AdminNavSection = {
3
- kind: 'users' | 'jobs' | 'logs' | 'env_migration' | 'health' | 'masking' | 'audit' | 'settings' | 'api_keys' | 'blog' | 'files' | 'feedback';
3
+ kind: 'users' | 'jobs' | 'logs' | 'env_migration' | 'health' | 'masking' | 'audit' | 'settings' | 'api_keys' | 'blog' | 'files' | 'feedback' | 'issues';
4
4
  label?: string;
5
5
  icon?: React.ReactNode;
6
6
  permission?: string;
@@ -31,7 +31,7 @@ export type AdminManifest = {
31
31
  title?: string;
32
32
  };
33
33
  export type AdminNavItem = {
34
- kind: 'users' | 'jobs' | 'logs' | 'env_migration' | 'health' | 'masking' | 'audit' | 'settings' | 'api_keys' | 'blog' | 'files' | 'feedback' | 'metrics' | 'custom';
34
+ kind: 'users' | 'jobs' | 'logs' | 'env_migration' | 'health' | 'masking' | 'audit' | 'settings' | 'api_keys' | 'blog' | 'files' | 'feedback' | 'issues' | 'metrics' | 'custom';
35
35
  label: string;
36
36
  path: string;
37
37
  basePath: string;
@@ -1 +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;AAK/B,MAAM,MAAM,eAAe,GACvB;IACE,IAAI,EAAE,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,eAAe,GAAG,QAAQ,GAAG,SAAS,GAAG,OAAO,GAAG,UAAU,GAAG,UAAU,GAAG,MAAM,GAAG,OAAO,GAAG,UAAU,CAAC;IAC7I,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;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,GACD;IACE,IAAI,EAAE,SAAS,CAAC;IAChB,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;IAClB,SAAS,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,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;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,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,SAAS,GAAG,OAAO,GAAG,UAAU,GAAG,UAAU,GAAG,MAAM,GAAG,OAAO,GAAG,UAAU,GAAG,SAAS,GAAG,QAAQ,CAAC;IACpK,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;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,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,CAuKzF"}
1
+ {"version":3,"file":"admin_nav.d.ts","sourceRoot":"","sources":["../../src/components/admin_nav.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAK/B,MAAM,MAAM,eAAe,GACvB;IACE,IAAI,EAAE,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,eAAe,GAAG,QAAQ,GAAG,SAAS,GAAG,OAAO,GAAG,UAAU,GAAG,UAAU,GAAG,MAAM,GAAG,OAAO,GAAG,UAAU,GAAG,QAAQ,CAAC;IACxJ,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;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,GACD;IACE,IAAI,EAAE,SAAS,CAAC;IAChB,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;IAClB,SAAS,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,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;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,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,SAAS,GAAG,OAAO,GAAG,UAAU,GAAG,UAAU,GAAG,MAAM,GAAG,OAAO,GAAG,UAAU,GAAG,QAAQ,GAAG,SAAS,GAAG,QAAQ,CAAC;IAC/K,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;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,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,CAkLzF"}
@@ -152,6 +152,18 @@ export function resolveNav(manifest, permissions) {
152
152
  order: section.order,
153
153
  });
154
154
  }
155
+ else if (section.kind === 'issues') {
156
+ items.push({
157
+ kind: 'issues',
158
+ label: section.label ?? 'Issues',
159
+ path: '/admin/issues',
160
+ basePath: section.basePath ?? '/api/admin',
161
+ icon: section.icon,
162
+ permission,
163
+ group: section.group,
164
+ order: section.order,
165
+ });
166
+ }
155
167
  else if (section.kind === 'metrics') {
156
168
  items.push({
157
169
  kind: 'metrics',
@@ -0,0 +1,6 @@
1
+ export interface IssuesPanelProps {
2
+ basePath?: string;
3
+ currentUserId?: string;
4
+ }
5
+ export declare function IssuesPanel({ basePath, currentUserId }: IssuesPanelProps): import("react").JSX.Element;
6
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/issues_panel/index.tsx"],"names":[],"mappings":"AAMA,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AA8BD,wBAAgB,WAAW,CAAC,EAAE,QAAuB,EAAE,aAAa,EAAE,EAAE,gBAAgB,+BA6MvF"}