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.
- package/CHANGE_LOG.md +11 -0
- package/README.md +86 -0
- package/SETUP_CHECKLIST.md +36 -0
- package/dist/api/env_migrate_handler.d.ts +19 -0
- package/dist/api/env_migrate_handler.d.ts.map +1 -0
- package/dist/api/env_migrate_handler.js +44 -0
- package/dist/api/index.d.ts +46 -0
- package/dist/api/index.d.ts.map +1 -0
- package/dist/api/index.js +257 -0
- package/dist/components/admin_app.d.ts +10 -0
- package/dist/components/admin_app.d.ts.map +1 -0
- package/dist/components/admin_app.js +32 -0
- package/dist/components/admin_layout.d.ts +15 -0
- package/dist/components/admin_layout.d.ts.map +1 -0
- package/dist/components/admin_layout.js +37 -0
- package/dist/components/admin_nav.d.ts +31 -0
- package/dist/components/admin_nav.d.ts.map +1 -0
- package/dist/components/admin_nav.js +73 -0
- package/dist/components/env_migration_panel.d.ts +6 -0
- package/dist/components/env_migration_panel.d.ts.map +1 -0
- package/dist/components/env_migration_panel.js +218 -0
- package/dist/components/health_panel.d.ts +5 -0
- package/dist/components/health_panel.d.ts.map +1 -0
- package/dist/components/health_panel.js +23 -0
- package/dist/components/jobs_panel.d.ts +6 -0
- package/dist/components/jobs_panel.d.ts.map +1 -0
- package/dist/components/jobs_panel.js +21 -0
- package/dist/components/logs_panel.d.ts +6 -0
- package/dist/components/logs_panel.d.ts.map +1 -0
- package/dist/components/logs_panel.js +17 -0
- package/dist/components/users_panel.d.ts +7 -0
- package/dist/components/users_panel.d.ts.map +1 -0
- package/dist/components/users_panel.js +21 -0
- package/dist/index.client.d.ts +8 -0
- package/dist/index.client.d.ts.map +1 -0
- package/dist/index.client.js +5 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.ui.d.ts +17 -0
- package/dist/index.ui.d.ts.map +1 -0
- package/dist/index.ui.js +9 -0
- package/dist/lib/admin_gate.server.d.ts +37 -0
- package/dist/lib/admin_gate.server.d.ts.map +1 -0
- package/dist/lib/admin_gate.server.js +58 -0
- package/dist/lib/index.d.ts +2 -0
- package/dist/lib/index.d.ts.map +1 -0
- package/dist/lib/index.js +1 -0
- package/dist/preset/index.d.ts +3 -0
- package/dist/preset/index.d.ts.map +1 -0
- package/dist/preset/index.js +1 -0
- package/dist/preset/page_factory.d.ts +12 -0
- package/dist/preset/page_factory.d.ts.map +1 -0
- package/dist/preset/page_factory.js +57 -0
- package/package.json +139 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export function defaultPermissionForKind(kind) {
|
|
2
|
+
return kind === 'env_migration' || kind === 'health' ? 'env.migrate' : 'admin_system';
|
|
3
|
+
}
|
|
4
|
+
export function resolveNav(manifest, permissions) {
|
|
5
|
+
const items = [];
|
|
6
|
+
for (const section of manifest.sections) {
|
|
7
|
+
const permission = section.permission ?? defaultPermissionForKind(section.kind);
|
|
8
|
+
if (!permissions.includes(permission))
|
|
9
|
+
continue;
|
|
10
|
+
if (section.kind === 'users') {
|
|
11
|
+
items.push({
|
|
12
|
+
kind: 'users',
|
|
13
|
+
label: section.label ?? 'Users',
|
|
14
|
+
path: '/admin/users',
|
|
15
|
+
basePath: section.basePath ?? '/api/hazo_auth',
|
|
16
|
+
icon: section.icon,
|
|
17
|
+
permission,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
else if (section.kind === 'jobs') {
|
|
21
|
+
items.push({
|
|
22
|
+
kind: 'jobs',
|
|
23
|
+
label: section.label ?? 'Jobs',
|
|
24
|
+
path: '/admin/jobs',
|
|
25
|
+
basePath: section.basePath ?? '/api/admin/jobs',
|
|
26
|
+
icon: section.icon,
|
|
27
|
+
permission,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
else if (section.kind === 'logs') {
|
|
31
|
+
items.push({
|
|
32
|
+
kind: 'logs',
|
|
33
|
+
label: section.label ?? 'Logs',
|
|
34
|
+
path: '/admin/logs',
|
|
35
|
+
basePath: section.basePath ?? '/api/admin/logs',
|
|
36
|
+
icon: section.icon,
|
|
37
|
+
permission,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
else if (section.kind === 'env_migration') {
|
|
41
|
+
items.push({
|
|
42
|
+
kind: 'env_migration',
|
|
43
|
+
label: section.label ?? 'Env Migration',
|
|
44
|
+
path: '/admin/env-migration',
|
|
45
|
+
basePath: section.basePath ?? '/api/admin',
|
|
46
|
+
icon: section.icon,
|
|
47
|
+
permission,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
else if (section.kind === 'health') {
|
|
51
|
+
items.push({
|
|
52
|
+
kind: 'health',
|
|
53
|
+
label: section.label ?? 'Health',
|
|
54
|
+
path: '/admin/health',
|
|
55
|
+
basePath: section.basePath ?? '/api/admin',
|
|
56
|
+
icon: section.icon,
|
|
57
|
+
permission,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
else if (section.kind === 'custom') {
|
|
61
|
+
items.push({
|
|
62
|
+
kind: 'custom',
|
|
63
|
+
label: section.label,
|
|
64
|
+
path: section.path,
|
|
65
|
+
basePath: '',
|
|
66
|
+
icon: section.icon,
|
|
67
|
+
permission,
|
|
68
|
+
component: section.component,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return items;
|
|
73
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"env_migration_panel.d.ts","sourceRoot":"","sources":["../../src/components/env_migration_panel.tsx"],"names":[],"mappings":"AACA,OAAO,KAAsC,MAAM,OAAO,CAAC;AAE3D,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AA+CD,wBAAgB,iBAAiB,CAAC,EAAE,QAAuB,EAAE,EAAE,sBAAsB,qBA4dpF"}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
/** Parse the job result (string from hazo_jobs, or already an object). */
|
|
5
|
+
function normalizeResult(result) {
|
|
6
|
+
if (!result)
|
|
7
|
+
return null;
|
|
8
|
+
if (typeof result === 'string') {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(result);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return result;
|
|
17
|
+
}
|
|
18
|
+
const PROD_ROLES = ['prod', 'production'];
|
|
19
|
+
export function EnvMigrationPanel({ basePath = '/api/admin' }) {
|
|
20
|
+
// Env list
|
|
21
|
+
const [envs, setEnvs] = useState([]);
|
|
22
|
+
const [envsError, setEnvsError] = useState(null);
|
|
23
|
+
// Form state
|
|
24
|
+
const [from, setFrom] = useState('');
|
|
25
|
+
const [to, setTo] = useState('');
|
|
26
|
+
const [includeDb, setIncludeDb] = useState(true);
|
|
27
|
+
const [includeFiles, setIncludeFiles] = useState(true);
|
|
28
|
+
const [transport, setTransport] = useState('auto');
|
|
29
|
+
const [scrub, setScrub] = useState('none');
|
|
30
|
+
const [tables, setTables] = useState('*');
|
|
31
|
+
const [dryRun, setDryRun] = useState(false);
|
|
32
|
+
// Prod gate
|
|
33
|
+
const [prodConfirm, setProdConfirm] = useState('');
|
|
34
|
+
// Snapshots
|
|
35
|
+
const [snapshots, setSnapshots] = useState([]);
|
|
36
|
+
const [snapshotsLoading, setSnapshotsLoading] = useState(false);
|
|
37
|
+
// Job / run state
|
|
38
|
+
const [running, setRunning] = useState(false);
|
|
39
|
+
const [progress, setProgress] = useState(null);
|
|
40
|
+
const [job, setJob] = useState(null);
|
|
41
|
+
const [submitError, setSubmitError] = useState(null);
|
|
42
|
+
const [detailsOpen, setDetailsOpen] = useState(true);
|
|
43
|
+
// Restore state
|
|
44
|
+
const [restoreMsg, setRestoreMsg] = useState(null);
|
|
45
|
+
const pollRef = useRef(null);
|
|
46
|
+
// Load env list on mount
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
fetch(`${basePath}/env/list`, { credentials: 'include' })
|
|
49
|
+
.then(async (r) => { if (!r.ok)
|
|
50
|
+
throw new Error(`Failed to load environments (${r.status})`); return r.json(); })
|
|
51
|
+
.then((data) => {
|
|
52
|
+
if (Array.isArray(data)) {
|
|
53
|
+
setEnvs(data);
|
|
54
|
+
if (data.length > 0)
|
|
55
|
+
setFrom(data[0]);
|
|
56
|
+
if (data.length > 1)
|
|
57
|
+
setTo(data[1]);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
setEnvsError('Failed to parse env list');
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
.catch(() => setEnvsError('Failed to load environments'));
|
|
64
|
+
}, [basePath]);
|
|
65
|
+
// Load snapshots when `to` changes
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (!to)
|
|
68
|
+
return;
|
|
69
|
+
setSnapshotsLoading(true);
|
|
70
|
+
fetch(`${basePath}/env/snapshots?to=${encodeURIComponent(to)}`, { credentials: 'include' })
|
|
71
|
+
.then(async (r) => { if (!r.ok)
|
|
72
|
+
throw new Error(`Request failed (${r.status})`); return r.json(); })
|
|
73
|
+
.then((data) => {
|
|
74
|
+
setSnapshots(Array.isArray(data) ? data : []);
|
|
75
|
+
})
|
|
76
|
+
.catch(() => setSnapshots([]))
|
|
77
|
+
.finally(() => setSnapshotsLoading(false));
|
|
78
|
+
}, [basePath, to]);
|
|
79
|
+
// Clear polling on unmount
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
return () => {
|
|
82
|
+
if (pollRef.current !== null)
|
|
83
|
+
clearInterval(pollRef.current);
|
|
84
|
+
};
|
|
85
|
+
}, []);
|
|
86
|
+
function stopPolling() {
|
|
87
|
+
if (pollRef.current !== null) {
|
|
88
|
+
clearInterval(pollRef.current);
|
|
89
|
+
pollRef.current = null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function startPolling(id) {
|
|
93
|
+
stopPolling();
|
|
94
|
+
pollRef.current = setInterval(async () => {
|
|
95
|
+
try {
|
|
96
|
+
const [progressRes, jobRes] = await Promise.all([
|
|
97
|
+
fetch(`${basePath}/env/migrate/${id}/progress`, { credentials: 'include' }),
|
|
98
|
+
fetch(`${basePath}/env/migrate/${id}`, { credentials: 'include' }),
|
|
99
|
+
]);
|
|
100
|
+
if (!progressRes.ok)
|
|
101
|
+
throw new Error(`Request failed (${progressRes.status})`);
|
|
102
|
+
if (!jobRes.ok)
|
|
103
|
+
throw new Error(`Request failed (${jobRes.status})`);
|
|
104
|
+
const progressData = await progressRes.json();
|
|
105
|
+
const jobData = await jobRes.json();
|
|
106
|
+
setProgress(progressData);
|
|
107
|
+
setJob(jobData);
|
|
108
|
+
if (jobData.status === 'completed' || jobData.status === 'failed') {
|
|
109
|
+
stopPolling();
|
|
110
|
+
setRunning(false);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// polling errors are non-fatal; stop on repeated failure is handled by status check
|
|
115
|
+
}
|
|
116
|
+
}, 1500);
|
|
117
|
+
}
|
|
118
|
+
async function handleSubmit(e) {
|
|
119
|
+
e.preventDefault();
|
|
120
|
+
setSubmitError(null);
|
|
121
|
+
setProgress(null);
|
|
122
|
+
setJob(null);
|
|
123
|
+
setRestoreMsg(null);
|
|
124
|
+
setDetailsOpen(true);
|
|
125
|
+
const isProd = PROD_ROLES.includes(to);
|
|
126
|
+
if (isProd && prodConfirm !== to)
|
|
127
|
+
return;
|
|
128
|
+
const payload = {
|
|
129
|
+
from,
|
|
130
|
+
to,
|
|
131
|
+
includeDb,
|
|
132
|
+
includeFiles,
|
|
133
|
+
transport,
|
|
134
|
+
scrub,
|
|
135
|
+
tables,
|
|
136
|
+
dryRun,
|
|
137
|
+
};
|
|
138
|
+
if (isProd) {
|
|
139
|
+
payload.allowProdTarget = true;
|
|
140
|
+
payload.confirmToken = prodConfirm;
|
|
141
|
+
}
|
|
142
|
+
setRunning(true);
|
|
143
|
+
try {
|
|
144
|
+
const res = await fetch(`${basePath}/env/migrate`, {
|
|
145
|
+
method: 'POST',
|
|
146
|
+
credentials: 'include',
|
|
147
|
+
headers: { 'Content-Type': 'application/json' },
|
|
148
|
+
body: JSON.stringify(payload),
|
|
149
|
+
});
|
|
150
|
+
const data = await res.json();
|
|
151
|
+
if (!res.ok || !data.jobId) {
|
|
152
|
+
setSubmitError(typeof data.error === 'string' ? data.error : 'Migration failed to start');
|
|
153
|
+
setRunning(false);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
startPolling(data.jobId);
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
setSubmitError(err instanceof Error ? err.message : 'Network error');
|
|
160
|
+
setRunning(false);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
async function handleRestore(snapshotId) {
|
|
164
|
+
setRestoreMsg(null);
|
|
165
|
+
try {
|
|
166
|
+
const res = await fetch(`${basePath}/env/snapshots/restore`, {
|
|
167
|
+
method: 'POST',
|
|
168
|
+
credentials: 'include',
|
|
169
|
+
headers: { 'Content-Type': 'application/json' },
|
|
170
|
+
body: JSON.stringify({ to, snapshotId }),
|
|
171
|
+
});
|
|
172
|
+
const data = await res.json();
|
|
173
|
+
if (data.ok) {
|
|
174
|
+
setRestoreMsg('Snapshot restored successfully.');
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
setRestoreMsg(`Restore failed: ${data.error ?? 'unknown error'}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
setRestoreMsg(`Restore failed: ${err instanceof Error ? err.message : 'Network error'}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const isProdTarget = PROD_ROLES.includes(to);
|
|
185
|
+
const prodConfirmValid = !isProdTarget || prodConfirm === to;
|
|
186
|
+
const canRun = !running && prodConfirmValid && !!from && !!to && from !== to;
|
|
187
|
+
return (_jsxs("div", { className: "p-6 max-w-2xl space-y-6", children: [_jsx("h2", { className: "text-base font-semibold text-gray-800", children: "Env Migration" }), envsError && (_jsx("div", { className: "text-sm text-red-500", children: envsError })), _jsxs("form", { onSubmit: handleSubmit, className: "space-y-4", children: [_jsxs("div", { className: "flex gap-4", children: [_jsxs("div", { className: "flex-1", children: [_jsx("label", { className: "block text-xs font-medium text-gray-600 mb-1", children: "From" }), _jsx("select", { value: from, onChange: e => setFrom(e.target.value), className: "w-full border border-gray-300 rounded px-2 py-1.5 text-sm", disabled: running, children: envs.map(env => (_jsx("option", { value: env, children: env }, env))) })] }), _jsxs("div", { className: "flex-1", children: [_jsx("label", { className: "block text-xs font-medium text-gray-600 mb-1", children: "To" }), _jsx("select", { value: to, onChange: e => { setTo(e.target.value); setProdConfirm(''); }, className: "w-full border border-gray-300 rounded px-2 py-1.5 text-sm", disabled: running, children: envs.map(env => (_jsx("option", { value: env, children: env }, env))) })] })] }), _jsxs("div", { className: "flex gap-6", children: [_jsxs("label", { className: "flex items-center gap-2 text-sm text-gray-700", children: [_jsx("input", { type: "checkbox", checked: includeDb, onChange: e => setIncludeDb(e.target.checked), disabled: running }), "Include DB"] }), _jsxs("label", { className: "flex items-center gap-2 text-sm text-gray-700", children: [_jsx("input", { type: "checkbox", checked: includeFiles, onChange: e => setIncludeFiles(e.target.checked), disabled: running }), "Include Files"] }), _jsxs("label", { className: "flex items-center gap-2 text-sm text-gray-700", children: [_jsx("input", { type: "checkbox", checked: dryRun, onChange: e => setDryRun(e.target.checked), disabled: running }), "Dry run"] })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-xs font-medium text-gray-600 mb-1", children: "Transport" }), _jsxs("select", { value: transport, onChange: e => setTransport(e.target.value), className: "border border-gray-300 rounded px-2 py-1.5 text-sm", disabled: running, children: [_jsx("option", { value: "auto", children: "auto" }), _jsx("option", { value: "local", children: "local" }), _jsx("option", { value: "api", children: "api" }), _jsx("option", { value: "ssh", disabled: true, children: "ssh (coming soon)" })] })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-xs font-medium text-gray-600 mb-1", children: "Scrub (PII masking)" }), _jsxs("select", { value: scrub, onChange: e => setScrub(e.target.value), className: "border border-gray-300 rounded px-2 py-1.5 text-sm", disabled: running, children: [_jsx("option", { value: "none", children: "none" }), _jsx("option", { value: "auto", children: "auto" })] })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-xs font-medium text-gray-600 mb-1", children: "Tables (glob, * = all)" }), _jsx("input", { type: "text", value: tables, onChange: e => setTables(e.target.value), className: "w-full border border-gray-300 rounded px-2 py-1.5 text-sm", disabled: running })] }), isProdTarget && (_jsxs("div", { className: "border border-red-300 rounded p-3 space-y-2 bg-red-50", children: [_jsx("div", { className: "text-sm font-medium text-red-700", children: "Warning: you are targeting a production environment." }), _jsxs("label", { className: "block text-xs font-medium text-red-600 mb-1", children: ["Type ", _jsx("code", { className: "font-mono bg-red-100 px-1 rounded", children: to }), " to confirm"] }), _jsx("input", { type: "text", value: prodConfirm, onChange: e => setProdConfirm(e.target.value), placeholder: to, className: "w-full border border-red-300 rounded px-2 py-1.5 text-sm", disabled: running })] })), _jsx("div", { children: _jsx("button", { type: "submit", disabled: !canRun, className: "px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed", children: running ? 'Running…' : dryRun ? 'Run dry run' : 'Run migration' }) })] }), running && progress && (_jsxs("div", { className: "space-y-2", children: [_jsx("div", { className: "text-xs text-gray-500 uppercase tracking-wide font-medium", children: "Progress" }), _jsxs("div", { className: "text-sm text-gray-700", children: [progress.phase, ": ", progress.message] }), _jsx("div", { className: "w-full bg-gray-200 rounded h-2", children: _jsx("div", { className: "bg-blue-500 h-2 rounded transition-all", style: { width: `${Math.max(0, Math.min(100, progress.percent))}%` } }) }), _jsxs("div", { className: "text-xs text-gray-500", children: [progress.percent, "%"] })] })), submitError && (_jsx("div", { className: "text-sm text-red-600 border border-red-200 rounded p-3 bg-red-50", children: submitError })), job?.status === 'completed' && (() => {
|
|
188
|
+
const result = normalizeResult(job.result);
|
|
189
|
+
const db = result?.db;
|
|
190
|
+
const files = result?.files;
|
|
191
|
+
const summaryBits = [
|
|
192
|
+
db?.rows !== undefined ? `${db.rows} row${db.rows === 1 ? '' : 's'}` : null,
|
|
193
|
+
db?.tables !== undefined ? `${db.tables} table${db.tables === 1 ? '' : 's'}` : null,
|
|
194
|
+
files?.copied !== undefined ? `${files.copied} file${files.copied === 1 ? '' : 's'}` : null,
|
|
195
|
+
].filter(Boolean).join(' · ');
|
|
196
|
+
const rows = [];
|
|
197
|
+
if (db?.tables !== undefined)
|
|
198
|
+
rows.push(['Tables', db.tables]);
|
|
199
|
+
if (db?.rows !== undefined)
|
|
200
|
+
rows.push(['Rows', db.rows]);
|
|
201
|
+
if (db?.scrubbed !== undefined)
|
|
202
|
+
rows.push(['Scrubbed', db.scrubbed]);
|
|
203
|
+
if (files?.copied !== undefined)
|
|
204
|
+
rows.push(['Files copied', files.copied]);
|
|
205
|
+
if (files?.bytes !== undefined)
|
|
206
|
+
rows.push(['Bytes', files.bytes]);
|
|
207
|
+
if (files?.placeholdered !== undefined)
|
|
208
|
+
rows.push(['Placeholdered', files.placeholdered]);
|
|
209
|
+
if (result?.durationMs !== undefined)
|
|
210
|
+
rows.push(['Duration', `${result.durationMs}ms`]);
|
|
211
|
+
if (result?.snapshotId)
|
|
212
|
+
rows.push(['Snapshot ID', _jsx("span", { className: "break-all", children: result.snapshotId })]);
|
|
213
|
+
return (_jsxs("div", { className: "border border-green-200 rounded bg-green-50 overflow-hidden", children: [_jsxs("button", { type: "button", onClick: () => setDetailsOpen(o => !o), "aria-expanded": detailsOpen, className: "w-full flex items-center justify-between gap-3 p-4 text-left hover:bg-green-100/50 transition-colors", children: [_jsxs("span", { className: "text-sm font-medium text-green-700", children: [dryRun ? 'Dry run — no changes made.' : 'Migration completed.', summaryBits && (_jsx("span", { className: "ml-2 font-normal text-xs text-green-600", children: summaryBits }))] }), _jsx("svg", { className: `w-4 h-4 shrink-0 text-green-600 transition-transform ${detailsOpen ? 'rotate-180' : ''}`, fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 2, children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M19 9l-7 7-7-7" }) })] }), detailsOpen && (_jsxs("div", { className: "px-4 pb-4 pt-3 border-t border-green-100 space-y-3", children: [rows.length > 0 ? (_jsx("table", { className: "text-xs w-full", children: _jsx("tbody", { className: "divide-y divide-green-100", children: rows.map(([label, value]) => (_jsxs("tr", { children: [_jsx("td", { className: "py-1 text-gray-500 pr-4 align-top whitespace-nowrap", children: label }), _jsx("td", { className: "py-1 font-mono", children: value })] }, label))) }) })) : (_jsx("div", { className: "text-xs text-gray-500", children: "No output details reported." })), result?.warnings && result.warnings.length > 0 && (_jsxs("div", { className: "space-y-1", children: [_jsx("div", { className: "text-xs font-medium text-yellow-700", children: "Warnings" }), result.warnings.map((w, i) => (_jsx("div", { className: "text-xs text-yellow-700", children: w }, i)))] })), result && (_jsxs("details", { className: "text-xs", children: [_jsx("summary", { className: "cursor-pointer text-gray-500 hover:text-gray-700 select-none", children: "Raw output" }), _jsx("pre", { className: "mt-2 p-2 bg-white border border-green-100 rounded overflow-x-auto text-[11px] leading-relaxed text-gray-700", children: JSON.stringify(result, null, 2) })] }))] }))] }));
|
|
214
|
+
})(), job?.status === 'failed' && (() => {
|
|
215
|
+
const result = normalizeResult(job.result);
|
|
216
|
+
return (_jsxs("div", { className: "border border-red-200 rounded p-4 space-y-3 bg-red-50", children: [_jsx("div", { className: "text-sm font-medium text-red-700", children: "Migration failed" }), job.error && _jsx("div", { className: "text-sm text-red-600", children: job.error }), result?.warnings && result.warnings.length > 0 && (_jsxs("div", { className: "space-y-1", children: [_jsx("div", { className: "text-xs font-medium text-red-600", children: "Warnings" }), result.warnings.map((w, i) => (_jsx("div", { className: "text-xs text-red-600", children: w }, i)))] })), result?.snapshotId && (_jsx("button", { onClick: () => handleRestore(result.snapshotId), disabled: running, className: "px-3 py-1.5 bg-red-600 text-white text-xs font-medium rounded hover:bg-red-700 disabled:opacity-50", children: "Restore last snapshot" }))] }));
|
|
217
|
+
})(), restoreMsg && (_jsx("div", { className: `text-sm border rounded p-3 ${restoreMsg.startsWith('Snapshot restored') ? 'border-green-200 bg-green-50 text-green-700' : 'border-red-200 bg-red-50 text-red-600'}`, children: restoreMsg })), to && (_jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "text-xs text-gray-500 uppercase tracking-wide font-medium", children: ["Snapshots for \"", to, "\""] }), snapshotsLoading && _jsx("div", { className: "text-xs text-gray-400", children: "Loading snapshots\u2026" }), !snapshotsLoading && snapshots.length === 0 && (_jsx("div", { className: "text-xs text-gray-400", children: "No snapshots found." })), !snapshotsLoading && snapshots.length > 0 && (_jsx("ul", { className: "space-y-1", children: snapshots.map(snap => (_jsxs("li", { className: "flex items-center justify-between text-xs border rounded px-3 py-2", children: [_jsxs("div", { children: [_jsx("div", { className: "font-mono text-gray-700", children: snap.fileName }), _jsx("div", { className: "text-gray-400", children: snap.createdAt })] }), _jsx("button", { onClick: () => handleRestore(snap.snapshotId), disabled: running, className: "px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 border border-gray-300 rounded disabled:opacity-50 disabled:cursor-not-allowed", children: "Restore" })] }, snap.snapshotId))) }))] }))] }));
|
|
218
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"health_panel.d.ts","sourceRoot":"","sources":["../../src/components/health_panel.tsx"],"names":[],"mappings":"AAGA,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAcD,wBAAgB,WAAW,CAAC,EAAE,QAAuB,EAAE,EAAE,gBAAgB,sCAqCxE"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
export function HealthPanel({ basePath = '/api/admin' }) {
|
|
5
|
+
const [report, setReport] = useState(null);
|
|
6
|
+
const [error, setError] = useState(null);
|
|
7
|
+
const [loading, setLoading] = useState(true);
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
fetch(`${basePath}/env/health`, { credentials: 'include' })
|
|
10
|
+
.then(async (r) => { if (!r.ok)
|
|
11
|
+
throw new Error(`Health check failed (${r.status})`); return r.json(); })
|
|
12
|
+
.then(setReport)
|
|
13
|
+
.catch((e) => setError(e instanceof Error ? e.message : 'Failed to load health report'))
|
|
14
|
+
.finally(() => setLoading(false));
|
|
15
|
+
}, [basePath]);
|
|
16
|
+
if (loading)
|
|
17
|
+
return _jsx("div", { className: "p-6 text-sm text-gray-400", children: "Loading health report\u2026" });
|
|
18
|
+
if (error)
|
|
19
|
+
return _jsx("div", { className: "p-6 text-sm text-red-500", children: error });
|
|
20
|
+
if (!report)
|
|
21
|
+
return null;
|
|
22
|
+
return (_jsxs("div", { className: "p-6 max-w-2xl", children: [_jsxs("div", { className: `mb-4 text-sm font-medium ${report.passed ? 'text-green-600' : 'text-red-600'}`, children: [report.passed ? '✓ All checks passed' : '✗ Some checks failed', " \u2014 env: ", report.env] }), _jsx("ul", { className: "space-y-2", children: (report.checks ?? []).map((c, i) => (_jsxs("li", { className: "flex items-start gap-3 text-sm border rounded p-3", children: [_jsx("span", { className: c.status === 'ok' ? 'text-green-500' : c.status === 'warn' ? 'text-yellow-500' : 'text-red-500', children: c.status === 'ok' ? '✓' : c.status === 'warn' ? '⚠' : '✗' }), _jsxs("div", { children: [_jsx("div", { className: "font-medium text-gray-800", children: c.label }), c.detail && _jsx("div", { className: "text-gray-500 mt-0.5", children: c.detail })] })] }, i))) })] }));
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"jobs_panel.d.ts","sourceRoot":"","sources":["../../src/components/jobs_panel.tsx"],"names":[],"mappings":"AACA,OAAO,KAA8B,MAAM,OAAO,CAAC;AAEnD,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,wBAAgB,SAAS,CAAC,EAAE,QAA4B,EAAE,EAAE,cAAc,qBAoBzE"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
export function JobsPanel({ basePath = '/api/admin/jobs' }) {
|
|
5
|
+
const [Panel, setPanel] = useState(null);
|
|
6
|
+
const [error, setError] = useState(null);
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
// Import styles first (side effect), then the component
|
|
9
|
+
// @ts-ignore
|
|
10
|
+
import('hazo_jobs/ui/styles.css').catch(() => { });
|
|
11
|
+
import('hazo_jobs/ui').then((m) => {
|
|
12
|
+
setPanel(() => m.JobsAdminPanel);
|
|
13
|
+
}).catch(() => setError('hazo_jobs is not installed'));
|
|
14
|
+
}, []);
|
|
15
|
+
if (error)
|
|
16
|
+
return _jsx("div", { className: "p-6 text-sm text-gray-500", children: error });
|
|
17
|
+
if (!Panel)
|
|
18
|
+
return _jsx("div", { className: "p-6 text-sm text-gray-400", children: "Loading..." });
|
|
19
|
+
const fetchFn = (path, init) => fetch(path, { credentials: 'include', ...init });
|
|
20
|
+
return _jsx(Panel, { fetchFn: fetchFn, basePath: basePath });
|
|
21
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"logs_panel.d.ts","sourceRoot":"","sources":["../../src/components/logs_panel.tsx"],"names":[],"mappings":"AACA,OAAO,KAA8B,MAAM,OAAO,CAAC;AAEnD,MAAM,WAAW,cAAc;IAC7B,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,wBAAgB,SAAS,CAAC,EAAE,WAA+B,EAAE,EAAE,cAAc,qBAc5E"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
export function LogsPanel({ apiBasePath = '/api/admin/logs' }) {
|
|
5
|
+
const [LogViewer, setLogViewer] = useState(null);
|
|
6
|
+
const [error, setError] = useState(null);
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
import('hazo_logs/ui').then((m) => {
|
|
9
|
+
setLogViewer(() => m.LogViewerPage);
|
|
10
|
+
}).catch(() => setError('hazo_logs is not installed'));
|
|
11
|
+
}, []);
|
|
12
|
+
if (error)
|
|
13
|
+
return _jsx("div", { className: "p-6 text-sm text-gray-500", children: error });
|
|
14
|
+
if (!LogViewer)
|
|
15
|
+
return _jsx("div", { className: "p-6 text-sm text-gray-400", children: "Loading..." });
|
|
16
|
+
return _jsx(LogViewer, { apiBasePath: apiBasePath, embedded: true });
|
|
17
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface UsersPanelProps {
|
|
2
|
+
apiBasePath?: string;
|
|
3
|
+
hrbacEnabled?: boolean;
|
|
4
|
+
userTypesEnabled?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare function UsersPanel({ apiBasePath, hrbacEnabled, userTypesEnabled }: UsersPanelProps): import("react").JSX.Element;
|
|
7
|
+
//# sourceMappingURL=users_panel.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"users_panel.d.ts","sourceRoot":"","sources":["../../src/components/users_panel.tsx"],"names":[],"mappings":"AAGA,MAAM,WAAW,eAAe;IAC9B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED,wBAAgB,UAAU,CAAC,EAAE,WAA8B,EAAE,YAAY,EAAE,gBAAgB,EAAE,EAAE,eAAe,+BAsB7G"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
export function UsersPanel({ apiBasePath = '/api/hazo_auth', hrbacEnabled, userTypesEnabled }) {
|
|
5
|
+
const [mod, setMod] = useState(null);
|
|
6
|
+
const [error, setError] = useState(null);
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
Promise.all([
|
|
9
|
+
import('hazo_auth'),
|
|
10
|
+
import('hazo_auth/components/layouts/user_management'),
|
|
11
|
+
]).then(([authMod, umMod]) => {
|
|
12
|
+
setMod({ HazoAuthProvider: authMod.HazoAuthProvider, UserManagementLayout: umMod.UserManagementLayout });
|
|
13
|
+
}).catch(() => setError('hazo_auth is not installed'));
|
|
14
|
+
}, []);
|
|
15
|
+
if (error)
|
|
16
|
+
return _jsx("div", { className: "p-6 text-sm text-gray-500", children: error });
|
|
17
|
+
if (!mod)
|
|
18
|
+
return _jsx("div", { className: "p-6 text-sm text-gray-400", children: "Loading..." });
|
|
19
|
+
const { HazoAuthProvider, UserManagementLayout } = mod;
|
|
20
|
+
return (_jsx(HazoAuthProvider, { apiBasePath: apiBasePath, children: _jsx(UserManagementLayout, { hrbacEnabled: hrbacEnabled, userTypesEnabled: userTypesEnabled }) }));
|
|
21
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const HAZO_ADMIN_PERMISSIONS: {
|
|
2
|
+
readonly ADMIN_SYSTEM: "admin_system";
|
|
3
|
+
readonly ENV_MIGRATE: "env.migrate";
|
|
4
|
+
readonly ENV_MASK_MANAGE: "env.mask.manage";
|
|
5
|
+
};
|
|
6
|
+
export type HazoAdminPermissionKey = keyof typeof HAZO_ADMIN_PERMISSIONS;
|
|
7
|
+
export type HazoAdminPermission = (typeof HAZO_ADMIN_PERMISSIONS)[HazoAdminPermissionKey];
|
|
8
|
+
//# sourceMappingURL=index.client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.client.d.ts","sourceRoot":"","sources":["../src/index.client.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,sBAAsB;;;;CAIzB,CAAC;AAEX,MAAM,MAAM,sBAAsB,GAAG,MAAM,OAAO,sBAAsB,CAAC;AACzE,MAAM,MAAM,mBAAmB,GAAG,CAAC,OAAO,sBAAsB,CAAC,CAAC,sBAAsB,CAAC,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AACtE,YAAY,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAClE,OAAO,EAAE,sBAAsB,EAAE,MAAM,mBAAmB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { AdminLayout } from './components/admin_layout.js';
|
|
2
|
+
export type { AdminLayoutProps } from './components/admin_layout.js';
|
|
3
|
+
export { AdminApp } from './components/admin_app.js';
|
|
4
|
+
export type { AdminAppProps } from './components/admin_app.js';
|
|
5
|
+
export { UsersPanel } from './components/users_panel.js';
|
|
6
|
+
export type { UsersPanelProps } from './components/users_panel.js';
|
|
7
|
+
export { JobsPanel } from './components/jobs_panel.js';
|
|
8
|
+
export type { JobsPanelProps } from './components/jobs_panel.js';
|
|
9
|
+
export { LogsPanel } from './components/logs_panel.js';
|
|
10
|
+
export type { LogsPanelProps } from './components/logs_panel.js';
|
|
11
|
+
export { EnvMigrationPanel } from './components/env_migration_panel.js';
|
|
12
|
+
export type { EnvMigrationPanelProps } from './components/env_migration_panel.js';
|
|
13
|
+
export { HealthPanel } from './components/health_panel.js';
|
|
14
|
+
export type { HealthPanelProps } from './components/health_panel.js';
|
|
15
|
+
export type { AdminManifest, AdminNavSection, AdminNavItem } from './components/admin_nav.js';
|
|
16
|
+
export { resolveNav } from './components/admin_nav.js';
|
|
17
|
+
//# sourceMappingURL=index.ui.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.ui.d.ts","sourceRoot":"","sources":["../src/index.ui.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAC3D,YAAY,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;AACrE,OAAO,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AACrD,YAAY,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAC/D,OAAO,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AACzD,YAAY,EAAE,eAAe,EAAE,MAAM,6BAA6B,CAAC;AACnE,OAAO,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAC;AACvD,YAAY,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AACjE,OAAO,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAC;AACvD,YAAY,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AACjE,OAAO,EAAE,iBAAiB,EAAE,MAAM,qCAAqC,CAAC;AACxE,YAAY,EAAE,sBAAsB,EAAE,MAAM,qCAAqC,CAAC;AAClF,OAAO,EAAE,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAC3D,YAAY,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;AACrE,YAAY,EAAE,aAAa,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAC9F,OAAO,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC"}
|
package/dist/index.ui.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// hazo_admin/ui — client-safe exports
|
|
2
|
+
export { AdminLayout } from './components/admin_layout.js';
|
|
3
|
+
export { AdminApp } from './components/admin_app.js';
|
|
4
|
+
export { UsersPanel } from './components/users_panel.js';
|
|
5
|
+
export { JobsPanel } from './components/jobs_panel.js';
|
|
6
|
+
export { LogsPanel } from './components/logs_panel.js';
|
|
7
|
+
export { EnvMigrationPanel } from './components/env_migration_panel.js';
|
|
8
|
+
export { HealthPanel } from './components/health_panel.js';
|
|
9
|
+
export { resolveNav } from './components/admin_nav.js';
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
/**
|
|
3
|
+
* The request type accepted by adminGate and withAdminGate.
|
|
4
|
+
* Using the standard web Request interface avoids version-skew issues
|
|
5
|
+
* between the NextRequest instance in hazo_admin vs hazo_auth at build time.
|
|
6
|
+
* At runtime the same NextRequest object is passed through unchanged.
|
|
7
|
+
*/
|
|
8
|
+
export type AdminRequest = Request & {
|
|
9
|
+
cookies: {
|
|
10
|
+
get(name: string): {
|
|
11
|
+
value: string;
|
|
12
|
+
} | undefined;
|
|
13
|
+
};
|
|
14
|
+
headers: Headers;
|
|
15
|
+
};
|
|
16
|
+
export type AdminGateResult = {
|
|
17
|
+
allowed: true;
|
|
18
|
+
user: any;
|
|
19
|
+
permissions: string[];
|
|
20
|
+
scope_ok?: boolean;
|
|
21
|
+
} | {
|
|
22
|
+
allowed: false;
|
|
23
|
+
status: 403;
|
|
24
|
+
reason: 'unauthenticated' | 'forbidden' | 'scope_denied';
|
|
25
|
+
missing_permissions?: string[];
|
|
26
|
+
};
|
|
27
|
+
export declare function adminGate(request: AdminRequest, opts: {
|
|
28
|
+
required_permissions: string[];
|
|
29
|
+
scope_id?: string;
|
|
30
|
+
}): Promise<AdminGateResult>;
|
|
31
|
+
export declare function withAdminGate(handler: (request: AdminRequest, gate: Extract<AdminGateResult, {
|
|
32
|
+
allowed: true;
|
|
33
|
+
}>) => Promise<Response> | Response, opts: {
|
|
34
|
+
required_permissions: string[];
|
|
35
|
+
scope_id?: string | ((req: AdminRequest) => string | undefined);
|
|
36
|
+
}): (request: AdminRequest) => Promise<Response>;
|
|
37
|
+
//# sourceMappingURL=admin_gate.server.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"admin_gate.server.d.ts","sourceRoot":"","sources":["../../src/lib/admin_gate.server.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAC;AAKrB;;;;;GAKG;AACH,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG;IACnC,OAAO,EAAE;QACP,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG;YAAE,KAAK,EAAE,MAAM,CAAA;SAAE,GAAG,SAAS,CAAC;KAClD,CAAC;IACF,OAAO,EAAE,OAAO,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,eAAe,GACvB;IAAE,OAAO,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,GAAG,CAAC;IAAC,WAAW,EAAE,MAAM,EAAE,CAAC;IAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAE,GACvE;IACE,OAAO,EAAE,KAAK,CAAC;IACf,MAAM,EAAE,GAAG,CAAC;IACZ,MAAM,EAAE,iBAAiB,GAAG,WAAW,GAAG,cAAc,CAAC;IACzD,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;CAChC,CAAC;AAEN,wBAAsB,SAAS,CAC7B,OAAO,EAAE,YAAY,EACrB,IAAI,EAAE;IAAE,oBAAoB,EAAE,MAAM,EAAE,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAC1D,OAAO,CAAC,eAAe,CAAC,CAyC1B;AAED,wBAAgB,aAAa,CAC3B,OAAO,EAAE,CACP,OAAO,EAAE,YAAY,EACrB,IAAI,EAAE,OAAO,CAAC,eAAe,EAAE;IAAE,OAAO,EAAE,IAAI,CAAA;CAAE,CAAC,KAC9C,OAAO,CAAC,QAAQ,CAAC,GAAG,QAAQ,EACjC,IAAI,EAAE;IACJ,oBAAoB,EAAE,MAAM,EAAE,CAAC;IAC/B,QAAQ,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,GAAG,EAAE,YAAY,KAAK,MAAM,GAAG,SAAS,CAAC,CAAC;CACjE,GACA,CAAC,OAAO,EAAE,YAAY,KAAK,OAAO,CAAC,QAAQ,CAAC,CAmB9C"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
import { createLogger } from 'hazo_core';
|
|
3
|
+
const log = createLogger('hazo_admin');
|
|
4
|
+
export async function adminGate(request, opts) {
|
|
5
|
+
try {
|
|
6
|
+
// Dynamic import avoids bundling hazo_auth when unused.
|
|
7
|
+
// Cast to any to bypass dual-instance NextRequest version skew at type-check time.
|
|
8
|
+
const { hazo_get_auth } = await import('hazo_auth/server-lib');
|
|
9
|
+
const result = await hazo_get_auth(request, {
|
|
10
|
+
required_permissions: opts.required_permissions,
|
|
11
|
+
scope_id: opts.scope_id,
|
|
12
|
+
strict: false,
|
|
13
|
+
});
|
|
14
|
+
if (!result.authenticated) {
|
|
15
|
+
log.debug('adminGate: unauthenticated');
|
|
16
|
+
return { allowed: false, status: 403, reason: 'unauthenticated' };
|
|
17
|
+
}
|
|
18
|
+
if (result.permission_ok === false) {
|
|
19
|
+
log.debug('adminGate: forbidden', { missing: result.missing_permissions });
|
|
20
|
+
return {
|
|
21
|
+
allowed: false,
|
|
22
|
+
status: 403,
|
|
23
|
+
reason: 'forbidden',
|
|
24
|
+
missing_permissions: result.missing_permissions,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
if (opts.scope_id !== undefined && result.scope_ok === false) {
|
|
28
|
+
log.debug('adminGate: scope_denied', { scope_id: opts.scope_id });
|
|
29
|
+
return { allowed: false, status: 403, reason: 'scope_denied' };
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
allowed: true,
|
|
33
|
+
user: result.user,
|
|
34
|
+
permissions: result.permissions ?? [],
|
|
35
|
+
scope_ok: result.scope_ok,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
log.debug('adminGate: error', { err });
|
|
40
|
+
return { allowed: false, status: 403, reason: 'unauthenticated' };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function withAdminGate(handler, opts) {
|
|
44
|
+
return async (request) => {
|
|
45
|
+
const scope_id = typeof opts.scope_id === 'function' ? opts.scope_id(request) : opts.scope_id;
|
|
46
|
+
const gate = await adminGate(request, {
|
|
47
|
+
required_permissions: opts.required_permissions,
|
|
48
|
+
scope_id,
|
|
49
|
+
});
|
|
50
|
+
if (!gate.allowed) {
|
|
51
|
+
return new Response(JSON.stringify({ error: { reason: gate.reason } }), {
|
|
52
|
+
status: 403,
|
|
53
|
+
headers: { 'Content-Type': 'application/json' },
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return handler(request, gate);
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lib/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|