@naisys/erp 3.0.0-beta.36 → 3.0.0-beta.38
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/client-dist/assets/{index-jH5OYerq.js → index-By5W5npg.js} +30 -9
- package/client-dist/index.html +1 -1
- package/dist/auth-middleware.js +66 -124
- package/dist/dbConfig.js +1 -1
- package/dist/erpServer.js +3 -0
- package/dist/generated/prisma/internal/class.js +4 -4
- package/dist/generated/prisma/internal/prismaNamespace.js +1 -1
- package/dist/routes/auth.js +6 -18
- package/dist/routes/user-permissions.js +19 -5
- package/dist/routes/users.js +37 -16
- package/dist/services/user-service.js +14 -31
- package/dist/userService.js +3 -7
- package/npm-shrinkwrap.json +28 -28
- package/package.json +6 -6
- package/prisma/migrations/20260427010000_hash_user_api_keys/migration.sql +10 -0
- package/prisma/migrations/20260427020000_nullable_user_password_hash/migration.sql +39 -0
- package/prisma/schema.prisma +2 -2
|
@@ -724,7 +724,7 @@ var CompactMarkdown = ({ children }) => (0, import_jsx_runtime.jsx)(Markdown, {
|
|
|
724
724
|
});
|
|
725
725
|
//#endregion
|
|
726
726
|
//#region ../../../packages/common-browser/dist/SecretField.js
|
|
727
|
-
var SecretField = ({ value, onRotate, rotating }) => {
|
|
727
|
+
var SecretField = ({ value, onRotate, rotating, emptyLabel = "Not set" }) => {
|
|
728
728
|
const [visible, setVisible] = (0, import_react.useState)(false);
|
|
729
729
|
return (0, import_jsx_runtime.jsxs)(Group, {
|
|
730
730
|
gap: "xs",
|
|
@@ -759,7 +759,7 @@ var SecretField = ({ value, onRotate, rotating }) => {
|
|
|
759
759
|
] }) : (0, import_jsx_runtime.jsx)(Text, {
|
|
760
760
|
c: "dimmed",
|
|
761
761
|
size: "sm",
|
|
762
|
-
children:
|
|
762
|
+
children: emptyLabel
|
|
763
763
|
}), onRotate && (0, import_jsx_runtime.jsx)(Tooltip, {
|
|
764
764
|
label: value ? "Rotate key" : "Generate API key",
|
|
765
765
|
children: (0, import_jsx_runtime.jsx)(ActionIcon, {
|
|
@@ -1691,7 +1691,6 @@ CreateResponseSchema.extend({ runNo: number$2() });
|
|
|
1691
1691
|
object$1({
|
|
1692
1692
|
id: number$2(),
|
|
1693
1693
|
username: string$1(),
|
|
1694
|
-
apiKey: string$1().nullable().optional(),
|
|
1695
1694
|
_links: array$1(HateoasLinkSchema).optional(),
|
|
1696
1695
|
_actions: array$1(HateoasActionSchema).optional()
|
|
1697
1696
|
});
|
|
@@ -1953,7 +1952,7 @@ object$1({
|
|
|
1953
1952
|
isAgent: boolean$1(),
|
|
1954
1953
|
createdAt: string$1(),
|
|
1955
1954
|
updatedAt: string$1(),
|
|
1956
|
-
|
|
1955
|
+
hasApiKey: boolean$1(),
|
|
1957
1956
|
permissions: array$1(UserPermissionSchema),
|
|
1958
1957
|
_links: array$1(any()).optional(),
|
|
1959
1958
|
_actions: array$1(any()).optional()
|
|
@@ -9731,6 +9730,7 @@ var UserDetail = () => {
|
|
|
9731
9730
|
const [editError, setEditError] = (0, import_react.useState)("");
|
|
9732
9731
|
const [grantPerm, setGrantPerm] = (0, import_react.useState)(null);
|
|
9733
9732
|
const [rotating, setRotating] = (0, import_react.useState)(false);
|
|
9733
|
+
const [apiKey, setApiKey] = (0, import_react.useState)(null);
|
|
9734
9734
|
const [pwOpened, { open: openPw, close: closePw }] = useDisclosure();
|
|
9735
9735
|
const [newPassword, setNewPassword] = (0, import_react.useState)("");
|
|
9736
9736
|
const [pwSaving, setPwSaving] = (0, import_react.useState)(false);
|
|
@@ -9740,6 +9740,7 @@ var UserDetail = () => {
|
|
|
9740
9740
|
setLoading(true);
|
|
9741
9741
|
try {
|
|
9742
9742
|
setUser(await api.get(apiEndpoints.user(routeUsername)));
|
|
9743
|
+
setApiKey(null);
|
|
9743
9744
|
} catch {} finally {
|
|
9744
9745
|
setLoading(false);
|
|
9745
9746
|
}
|
|
@@ -9806,11 +9807,15 @@ var UserDetail = () => {
|
|
|
9806
9807
|
};
|
|
9807
9808
|
const handleRotateKey = async () => {
|
|
9808
9809
|
if (!routeUsername) return;
|
|
9809
|
-
if (!confirm("
|
|
9810
|
+
if (!confirm("Generate a new API key? The old key will stop working immediately.")) return;
|
|
9810
9811
|
setRotating(true);
|
|
9811
9812
|
try {
|
|
9812
|
-
await api.post(apiEndpoints.userRotateKey(routeUsername), {});
|
|
9813
|
-
|
|
9813
|
+
const result = await api.post(apiEndpoints.userRotateKey(routeUsername), {});
|
|
9814
|
+
setApiKey(result.apiKey ?? null);
|
|
9815
|
+
setUser((current) => current ? {
|
|
9816
|
+
...current,
|
|
9817
|
+
hasApiKey: !!result.apiKey
|
|
9818
|
+
} : current);
|
|
9814
9819
|
} catch (err) {
|
|
9815
9820
|
showErrorNotification(err);
|
|
9816
9821
|
} finally {
|
|
@@ -9912,12 +9917,28 @@ var UserDetail = () => {
|
|
|
9912
9917
|
w: 120,
|
|
9913
9918
|
children: "Type:"
|
|
9914
9919
|
}), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Text, { children: user.isAgent ? "Agent" : "User" })] }),
|
|
9915
|
-
|
|
9920
|
+
supervisorAuth ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Group, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Text, {
|
|
9921
|
+
fw: 600,
|
|
9922
|
+
w: 120,
|
|
9923
|
+
children: "Credentials:"
|
|
9924
|
+
}), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Text, {
|
|
9925
|
+
size: "sm",
|
|
9926
|
+
children: [
|
|
9927
|
+
"Password and API key are managed in the",
|
|
9928
|
+
" ",
|
|
9929
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Anchor, {
|
|
9930
|
+
href: `/supervisor/users/${user.username}`,
|
|
9931
|
+
children: "supervisor"
|
|
9932
|
+
}),
|
|
9933
|
+
"."
|
|
9934
|
+
]
|
|
9935
|
+
})] }) : (user.hasApiKey || hasAction(user._actions, "rotate-key")) && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Group, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Text, {
|
|
9916
9936
|
fw: 600,
|
|
9917
9937
|
w: 120,
|
|
9918
9938
|
children: "API Key:"
|
|
9919
9939
|
}), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SecretField, {
|
|
9920
|
-
value:
|
|
9940
|
+
value: apiKey,
|
|
9941
|
+
emptyLabel: user.hasApiKey ? "Generated (hidden)" : "Not set",
|
|
9921
9942
|
onRotate: hasAction(user._actions, "rotate-key") ? handleRotateKey : void 0,
|
|
9922
9943
|
rotating
|
|
9923
9944
|
})] })
|
package/client-dist/index.html
CHANGED
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
<meta name="format-detection" content="telephone=no" />
|
|
46
46
|
|
|
47
47
|
<title>NAISYS ERP</title>
|
|
48
|
-
<script type="module" crossorigin src="/erp/assets/index-
|
|
48
|
+
<script type="module" crossorigin src="/erp/assets/index-By5W5npg.js"></script>
|
|
49
49
|
<link rel="modulepreload" crossorigin href="/erp/assets/rolldown-runtime-CvHMtSRF.js">
|
|
50
50
|
<link rel="modulepreload" crossorigin href="/erp/assets/vendor-DFaFIeiT.js">
|
|
51
51
|
<link rel="stylesheet" crossorigin href="/erp/assets/vendor-CLUPjUnv.css">
|
package/dist/auth-middleware.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AuthCache } from "@naisys/common";
|
|
1
|
+
import { AuthCache, urlMatchesPrefix } from "@naisys/common";
|
|
2
2
|
import { extractBearerToken, hashToken, SESSION_COOKIE_NAME, } from "@naisys/common-node";
|
|
3
3
|
import { findAgentByApiKey } from "@naisys/hub-database";
|
|
4
4
|
import { findSession, findUserByApiKey } from "@naisys/supervisor-database";
|
|
@@ -13,6 +13,63 @@ async function loadPermissions(userId) {
|
|
|
13
13
|
});
|
|
14
14
|
return perms.map((p) => p.permission);
|
|
15
15
|
}
|
|
16
|
+
async function materializeErpUser(localUser) {
|
|
17
|
+
return {
|
|
18
|
+
id: localUser.id,
|
|
19
|
+
username: localUser.username,
|
|
20
|
+
permissions: await loadPermissions(localUser.id),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
async function resolveCookie(token) {
|
|
24
|
+
const tokenHash = hashToken(token);
|
|
25
|
+
return authCache.getOrLoad(`cookie:${tokenHash}`, async () => {
|
|
26
|
+
const localUser = isSupervisorAuth()
|
|
27
|
+
? await loadCookieUserSso(tokenHash)
|
|
28
|
+
: await loadCookieUserStandalone(tokenHash);
|
|
29
|
+
return localUser ? materializeErpUser(localUser) : null;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
async function loadCookieUserSso(tokenHash) {
|
|
33
|
+
const session = await findSession(tokenHash);
|
|
34
|
+
if (!session)
|
|
35
|
+
return null;
|
|
36
|
+
return erpDb.user.upsert({
|
|
37
|
+
where: { uuid: session.uuid },
|
|
38
|
+
create: { uuid: session.uuid, username: session.username },
|
|
39
|
+
update: {},
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
async function loadCookieUserStandalone(tokenHash) {
|
|
43
|
+
const session = await erpDb.session.findUnique({
|
|
44
|
+
where: { tokenHash, expiresAt: { gt: new Date() } },
|
|
45
|
+
include: { user: true },
|
|
46
|
+
});
|
|
47
|
+
return session?.user ?? null;
|
|
48
|
+
}
|
|
49
|
+
async function resolveApiKey(apiKey) {
|
|
50
|
+
const apiKeyHash = hashToken(apiKey);
|
|
51
|
+
return authCache.getOrLoad(`apikey:${apiKeyHash}`, async () => {
|
|
52
|
+
const localUser = isSupervisorAuth()
|
|
53
|
+
? await loadApiKeyUserSso(apiKey)
|
|
54
|
+
: await erpDb.user.findUnique({ where: { apiKeyHash } });
|
|
55
|
+
return localUser ? materializeErpUser(localUser) : null;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
async function loadApiKeyUserSso(apiKey) {
|
|
59
|
+
// Try supervisor DB (humans + agents with external keys),
|
|
60
|
+
// then hub DB (agents matching their hub-issued runtime key).
|
|
61
|
+
const supervisorUser = await findUserByApiKey(apiKey);
|
|
62
|
+
const hubAgent = supervisorUser ? null : await findAgentByApiKey(apiKey);
|
|
63
|
+
const match = supervisorUser ?? hubAgent;
|
|
64
|
+
if (!match)
|
|
65
|
+
return null;
|
|
66
|
+
const isAgent = supervisorUser?.isAgent ?? !!hubAgent;
|
|
67
|
+
return erpDb.user.upsert({
|
|
68
|
+
where: { uuid: match.uuid },
|
|
69
|
+
create: { uuid: match.uuid, username: match.username, isAgent },
|
|
70
|
+
update: {},
|
|
71
|
+
});
|
|
72
|
+
}
|
|
16
73
|
export function hasPermission(user, permission) {
|
|
17
74
|
if (!user)
|
|
18
75
|
return false;
|
|
@@ -44,13 +101,11 @@ function isPublicRoute(url) {
|
|
|
44
101
|
// Exact match: API root
|
|
45
102
|
if (url === "/erp/api/" || url === "/erp/api")
|
|
46
103
|
return true;
|
|
47
|
-
// Prefix matches
|
|
48
104
|
for (const prefix of PUBLIC_PREFIXES) {
|
|
49
|
-
if (url
|
|
105
|
+
if (urlMatchesPrefix(url, prefix))
|
|
50
106
|
return true;
|
|
51
107
|
}
|
|
52
|
-
|
|
53
|
-
if (url.startsWith("/erp/api/schemas"))
|
|
108
|
+
if (urlMatchesPrefix(url, "/erp/api/schemas"))
|
|
54
109
|
return true;
|
|
55
110
|
// Non-ERP-API paths (static files, supervisor routes, etc.)
|
|
56
111
|
if (!url.startsWith("/erp/api"))
|
|
@@ -63,131 +118,18 @@ export function registerAuthMiddleware(fastify) {
|
|
|
63
118
|
fastify.addHook("onRequest", async (request, reply) => {
|
|
64
119
|
const token = request.cookies?.[SESSION_COOKIE_NAME];
|
|
65
120
|
if (token) {
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (cached !== undefined) {
|
|
70
|
-
// Cache hit (valid or negative)
|
|
71
|
-
if (cached)
|
|
72
|
-
request.erpUser = cached;
|
|
73
|
-
}
|
|
74
|
-
else if (isSupervisorAuth()) {
|
|
75
|
-
// SSO mode: supervisor DB is source of truth for sessions
|
|
76
|
-
const session = await findSession(tokenHash);
|
|
77
|
-
if (session) {
|
|
78
|
-
let localUser = await erpDb.user.findUnique({
|
|
79
|
-
where: { uuid: session.uuid },
|
|
80
|
-
});
|
|
81
|
-
if (!localUser) {
|
|
82
|
-
localUser = await erpDb.user.create({
|
|
83
|
-
data: {
|
|
84
|
-
uuid: session.uuid,
|
|
85
|
-
username: session.username,
|
|
86
|
-
passwordHash: session.passwordHash,
|
|
87
|
-
},
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
const permissions = await loadPermissions(localUser.id);
|
|
91
|
-
const erpUser = {
|
|
92
|
-
id: localUser.id,
|
|
93
|
-
username: localUser.username,
|
|
94
|
-
permissions,
|
|
95
|
-
};
|
|
96
|
-
authCache.set(cacheKey, erpUser);
|
|
97
|
-
request.erpUser = erpUser;
|
|
98
|
-
}
|
|
99
|
-
else {
|
|
100
|
-
authCache.set(cacheKey, null);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
else {
|
|
104
|
-
// Standalone mode: local session only
|
|
105
|
-
const session = await erpDb.session.findUnique({
|
|
106
|
-
where: {
|
|
107
|
-
tokenHash,
|
|
108
|
-
expiresAt: { gt: new Date() },
|
|
109
|
-
},
|
|
110
|
-
include: { user: true },
|
|
111
|
-
});
|
|
112
|
-
if (session) {
|
|
113
|
-
const permissions = await loadPermissions(session.user.id);
|
|
114
|
-
const erpUser = {
|
|
115
|
-
id: session.user.id,
|
|
116
|
-
username: session.user.username,
|
|
117
|
-
permissions,
|
|
118
|
-
};
|
|
119
|
-
authCache.set(cacheKey, erpUser);
|
|
120
|
-
request.erpUser = erpUser;
|
|
121
|
-
}
|
|
122
|
-
else {
|
|
123
|
-
authCache.set(cacheKey, null);
|
|
124
|
-
}
|
|
125
|
-
}
|
|
121
|
+
const user = await resolveCookie(token);
|
|
122
|
+
if (user)
|
|
123
|
+
request.erpUser = user;
|
|
126
124
|
}
|
|
127
|
-
// API key auth (for agents / machine-to-machine)
|
|
128
125
|
if (!request.erpUser) {
|
|
129
126
|
const apiKey = extractBearerToken(request.headers.authorization);
|
|
130
127
|
if (apiKey) {
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (cached !== undefined) {
|
|
135
|
-
if (cached)
|
|
136
|
-
request.erpUser = cached;
|
|
137
|
-
}
|
|
138
|
-
else if (isSupervisorAuth()) {
|
|
139
|
-
// SSO mode: try supervisor DB (human users), then hub DB (agents)
|
|
140
|
-
const match = (await findUserByApiKey(apiKey)) ??
|
|
141
|
-
(await findAgentByApiKey(apiKey));
|
|
142
|
-
if (match) {
|
|
143
|
-
let localUser = await erpDb.user.findUnique({
|
|
144
|
-
where: { uuid: match.uuid },
|
|
145
|
-
});
|
|
146
|
-
if (!localUser) {
|
|
147
|
-
localUser = await erpDb.user.create({
|
|
148
|
-
data: {
|
|
149
|
-
uuid: match.uuid,
|
|
150
|
-
username: match.username,
|
|
151
|
-
passwordHash: "!api-key-only",
|
|
152
|
-
isAgent: true,
|
|
153
|
-
},
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
const permissions = await loadPermissions(localUser.id);
|
|
157
|
-
const erpUser = {
|
|
158
|
-
id: localUser.id,
|
|
159
|
-
username: localUser.username,
|
|
160
|
-
permissions,
|
|
161
|
-
};
|
|
162
|
-
authCache.set(cacheKey, erpUser);
|
|
163
|
-
request.erpUser = erpUser;
|
|
164
|
-
}
|
|
165
|
-
else {
|
|
166
|
-
authCache.set(cacheKey, null);
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
else {
|
|
170
|
-
// Standalone mode: check local ERP user table
|
|
171
|
-
const localUser = await erpDb.user.findUnique({
|
|
172
|
-
where: { apiKey },
|
|
173
|
-
});
|
|
174
|
-
if (localUser) {
|
|
175
|
-
const permissions = await loadPermissions(localUser.id);
|
|
176
|
-
const erpUser = {
|
|
177
|
-
id: localUser.id,
|
|
178
|
-
username: localUser.username,
|
|
179
|
-
permissions,
|
|
180
|
-
};
|
|
181
|
-
authCache.set(cacheKey, erpUser);
|
|
182
|
-
request.erpUser = erpUser;
|
|
183
|
-
}
|
|
184
|
-
else {
|
|
185
|
-
authCache.set(cacheKey, null);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
128
|
+
const user = await resolveApiKey(apiKey);
|
|
129
|
+
if (user)
|
|
130
|
+
request.erpUser = user;
|
|
188
131
|
}
|
|
189
132
|
}
|
|
190
|
-
// Check if auth is required
|
|
191
133
|
if (request.erpUser)
|
|
192
134
|
return; // Authenticated, always allowed
|
|
193
135
|
if (isPublicRoute(request.url))
|
package/dist/dbConfig.js
CHANGED
package/dist/erpServer.js
CHANGED
|
@@ -129,6 +129,9 @@ async function startServer(wizardRan) {
|
|
|
129
129
|
const isProd = process.env.NODE_ENV === "production";
|
|
130
130
|
const fastify = Fastify({
|
|
131
131
|
pluginTimeout: 60_000,
|
|
132
|
+
// trustProxy: TLS terminates at the reverse proxy, so honor X-Forwarded-*
|
|
133
|
+
// headers — otherwise request.protocol reads the internal http hop.
|
|
134
|
+
trustProxy: true,
|
|
132
135
|
logger: {
|
|
133
136
|
transport: {
|
|
134
137
|
target: "pino-pretty",
|