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.
- package/CHANGE_LOG.md +26 -0
- package/README.md +115 -2
- package/SETUP_CHECKLIST.md +4 -1
- package/config/hazo_admin_config.ini.sample +11 -0
- package/dist/api/index.d.ts +4 -0
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js +174 -0
- package/dist/components/admin_app.d.ts.map +1 -1
- package/dist/components/admin_app.js +3 -0
- package/dist/components/admin_kinds.d.ts.map +1 -1
- package/dist/components/admin_kinds.js +9 -1
- package/dist/components/admin_nav.d.ts +2 -2
- package/dist/components/admin_nav.d.ts.map +1 -1
- package/dist/components/admin_nav.js +12 -0
- package/dist/components/issues_panel/index.d.ts +6 -0
- package/dist/components/issues_panel/index.d.ts.map +1 -0
- package/dist/components/issues_panel/index.js +130 -0
- package/dist/index.client.d.ts +4 -0
- package/dist/index.client.d.ts.map +1 -1
- package/dist/index.client.js +4 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/issues/archive_handler.d.ts +29 -0
- package/dist/issues/archive_handler.d.ts.map +1 -0
- package/dist/issues/archive_handler.js +35 -0
- package/dist/issues/index.d.ts +8 -0
- package/dist/issues/index.d.ts.map +1 -0
- package/dist/issues/index.js +4 -0
- package/dist/issues/registry.client.d.ts +32 -0
- package/dist/issues/registry.client.d.ts.map +1 -0
- package/dist/issues/registry.client.js +15 -0
- package/dist/issues/registry.d.ts +45 -0
- package/dist/issues/registry.d.ts.map +1 -0
- package/dist/issues/registry.js +11 -0
- package/dist/issues/routing.d.ts +17 -0
- package/dist/issues/routing.d.ts.map +1 -0
- package/dist/issues/routing.js +43 -0
- package/dist/issues/store.d.ts +61 -0
- package/dist/issues/store.d.ts.map +1 -0
- package/dist/issues/store.js +227 -0
- 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 |
|
package/SETUP_CHECKLIST.md
CHANGED
|
@@ -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
|
package/dist/api/index.d.ts
CHANGED
|
@@ -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: {
|
package/dist/api/index.d.ts.map
CHANGED
|
@@ -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;
|
|
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;
|
|
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,
|
|
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;
|
|
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 @@
|
|
|
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"}
|