shutro-erp 0.3.0 → 0.3.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/README.md +58 -58
- package/dist/account_portal/.kosh/bindings.js +1 -1
- package/dist/account_portal/.kosh/schema.d.ts +31 -1
- package/dist/accounting/.kosh/bindings.js +1 -1
- package/dist/accounting/.kosh/schema.d.ts +31 -1
- package/dist/assistant/routes.js +1 -1
- package/dist/automation/engine.js +1 -1
- package/dist/automation/web/messages.js +2 -2
- package/dist/cli/apps.d.ts +1 -1
- package/dist/cli/apps.js +14 -14
- package/dist/cli/bin.js +3 -0
- package/dist/cli/index.js +11 -11
- package/dist/cli/init.d.ts +1 -1
- package/dist/cli/init.js +58 -36
- package/dist/cli/launch.js +2 -2
- package/dist/cli/project.d.ts +7 -7
- package/dist/cli/project.js +25 -25
- package/dist/cli/prompt.js +2 -2
- package/dist/cli/templates.d.ts +3 -3
- package/dist/cli/templates.js +29 -27
- package/dist/cli/ui.d.ts +1 -8
- package/dist/cli/ui.js +17 -11
- package/dist/contacts/.kosh/bindings.js +1 -1
- package/dist/contacts/.kosh/schema.d.ts +31 -1
- package/dist/l10n_bd/.kosh/bindings.js +1 -1
- package/dist/l10n_bd/.kosh/schema.d.ts +31 -1
- package/dist/l10n_in/.kosh/bindings.js +1 -1
- package/dist/l10n_in/.kosh/schema.d.ts +31 -1
- package/dist/l10n_in/einvoice.js +1 -1
- package/dist/l10n_in/web/messages.js +2 -2
- package/dist/mail/index.js +2 -2
- package/dist/modules-sdk/loader.js +2 -2
- package/dist/notify/messages.js +3 -3
- package/dist/notify/routes.js +1 -1
- package/dist/notify/web/messages.js +1 -1
- package/dist/portal/.kosh/bindings.js +1 -1
- package/dist/portal/.kosh/schema.d.ts +31 -1
- package/dist/product/.kosh/bindings.js +1 -1
- package/dist/product/.kosh/schema.d.ts +31 -1
- package/dist/project/.kosh/bindings.js +1 -1
- package/dist/project/.kosh/schema.d.ts +31 -1
- package/dist/project_portal/.kosh/bindings.js +1 -1
- package/dist/project_portal/.kosh/schema.d.ts +31 -1
- package/dist/purchase/.kosh/bindings.js +1 -1
- package/dist/purchase/.kosh/schema.d.ts +31 -1
- package/dist/purchase_account/.kosh/bindings.js +1 -1
- package/dist/purchase_account/.kosh/schema.d.ts +31 -1
- package/dist/purchase_portal/.kosh/bindings.js +1 -1
- package/dist/purchase_portal/.kosh/schema.d.ts +31 -1
- package/dist/sale_account/.kosh/bindings.js +1 -1
- package/dist/sale_account/.kosh/schema.d.ts +31 -1
- package/dist/sale_portal/.kosh/bindings.js +1 -1
- package/dist/sale_portal/.kosh/schema.d.ts +31 -1
- package/dist/sale_project/.kosh/bindings.js +1 -1
- package/dist/sale_project/.kosh/schema.d.ts +31 -1
- package/dist/sales/.kosh/bindings.js +1 -1
- package/dist/sales/.kosh/schema.d.ts +31 -1
- package/dist/server/api/client.js +1 -1
- package/dist/server/api/openapi.js +17 -1
- package/dist/server/apps.js +5 -5
- package/dist/server/auth/api-keys.d.ts +13 -0
- package/dist/server/auth/api-keys.js +65 -0
- package/dist/server/auth/base.js +24 -0
- package/dist/server/auth/crypto.js +1 -1
- package/dist/server/auth/scopes.d.ts +12 -0
- package/dist/server/auth/scopes.js +51 -0
- package/dist/server/auth/service.d.ts +57 -1
- package/dist/server/auth/service.js +229 -5
- package/dist/server/demo.d.ts +1 -1
- package/dist/server/demo.js +1 -1
- package/dist/server/env-names.d.ts +2 -0
- package/dist/server/env-names.js +16 -0
- package/dist/server/host.js +5 -1
- package/dist/server/identity.d.ts +7 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +1 -0
- package/dist/server/registry.js +1 -1
- package/dist/server/server.d.ts +5 -0
- package/dist/server/server.js +45 -5
- package/dist/settings/routes.js +1 -1
- package/dist/settings/web/ApiKeysPage.d.ts +8 -0
- package/dist/settings/web/ApiKeysPage.js +70 -0
- package/dist/settings/web/index.d.ts +1 -0
- package/dist/settings/web/index.js +4 -0
- package/dist/settings/web/messages.js +62 -0
- package/dist/storage/routes.js +1 -1
- package/dist/studio/build.js +1 -1
- package/dist/studio/generate.js +4 -4
- package/dist/studio/module.js +5 -5
- package/dist/studio/web/messages.js +2 -2
- package/dist/ui/app.js +26 -3
- package/dist/ui/i18n/messages.js +2 -2
- package/dist/ui/pwa/worker.js +2 -2
- package/dist/ui/registry.d.ts +15 -0
- package/dist/ui/registry.js +20 -0
- package/package.json +3 -3
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The environment's names: installations set `SHUTRO_ERP_*` (the product's name, as in the docs and new projects);
|
|
3
|
+
* the code reads the older `KOSH_*` names, which keep working. Applied once where a process starts (the server, the
|
|
4
|
+
* CLI): each `SHUTRO_ERP_X` that is set fills `KOSH_X` unless that one is set too.
|
|
5
|
+
*/
|
|
6
|
+
const PREFIX = 'SHUTRO_ERP_';
|
|
7
|
+
/** Copies `SHUTRO_ERP_*` variables to their `KOSH_*` names in `env` (the process's by default); returns `env`. */
|
|
8
|
+
export function applyEnvironmentNames(env = process.env) {
|
|
9
|
+
for (const [name, value] of Object.entries(env))
|
|
10
|
+
if (name.startsWith(PREFIX) && value !== undefined) {
|
|
11
|
+
const old = `KOSH_${name.slice(PREFIX.length)}`;
|
|
12
|
+
if (env[old] === undefined)
|
|
13
|
+
env[old] = value;
|
|
14
|
+
}
|
|
15
|
+
return env;
|
|
16
|
+
}
|
package/dist/server/host.js
CHANGED
|
@@ -152,7 +152,11 @@ export async function createAppHost(options) {
|
|
|
152
152
|
current.server.app.server.emit('upgrade', req, socket, head);
|
|
153
153
|
});
|
|
154
154
|
const listener = listen
|
|
155
|
-
? pg.createDriver({
|
|
155
|
+
? pg.createDriver({
|
|
156
|
+
url: options.databaseUrl,
|
|
157
|
+
max: 1,
|
|
158
|
+
applicationName: 'shutro-erp apps listener',
|
|
159
|
+
})
|
|
156
160
|
: undefined;
|
|
157
161
|
const stopListening = await listener?.listen(APPS_CHANNEL, (digest) => {
|
|
158
162
|
if (digest !== current.digest)
|
|
@@ -17,6 +17,13 @@ export interface RequestIdentity {
|
|
|
17
17
|
readonly grants?: readonly string[];
|
|
18
18
|
readonly lang?: string;
|
|
19
19
|
readonly tz?: string;
|
|
20
|
+
/**
|
|
21
|
+
* An API key's scopes (RFC 0017 §5.1), when the request carries one: they narrow what the user may do (`*`,
|
|
22
|
+
* `read:<app>`, `write:<app>`); the user's rights still apply. Absent for sessions.
|
|
23
|
+
*/
|
|
24
|
+
readonly scopes?: readonly string[];
|
|
25
|
+
/** The API key the request carries. */
|
|
26
|
+
readonly apiKey?: number;
|
|
20
27
|
}
|
|
21
28
|
/**
|
|
22
29
|
* The single explicit bypass (CLAUDE.md "superuser mode", RFC 0003 §5.2): the user holds an administrator role
|
package/dist/server/index.d.ts
CHANGED
|
@@ -20,6 +20,7 @@ export { type InstallBaseOptions, installBase } from './auth/install.js';
|
|
|
20
20
|
export { registerAuthRoutes } from './auth/routes.js';
|
|
21
21
|
export { type AuthOptions, AuthService, cookieOf, SESSION_COOKIE, type SessionInfo, timeOf, type UserInfo, } from './auth/service.js';
|
|
22
22
|
export { DEMO_PASSWORD, type DemoResult, installDemo } from './demo.js';
|
|
23
|
+
export { applyEnvironmentNames } from './env-names.js';
|
|
23
24
|
export { HttpProblem, type Problem, problemOf, ServerConfigError } from './errors.js';
|
|
24
25
|
export { type AppHost, type AppHostOptions, createAppHost, startAppHost } from './host.js';
|
|
25
26
|
export { type Authenticator, denyAll, isSuperuser, languageOf, type RequestIdentity, type RequestRunOptions, runOptionsOf, } from './identity.js';
|
package/dist/server/index.js
CHANGED
|
@@ -20,6 +20,7 @@ export { installBase } from './auth/install.js';
|
|
|
20
20
|
export { registerAuthRoutes } from './auth/routes.js';
|
|
21
21
|
export { AuthService, cookieOf, SESSION_COOKIE, timeOf, } from './auth/service.js';
|
|
22
22
|
export { DEMO_PASSWORD, installDemo } from './demo.js';
|
|
23
|
+
export { applyEnvironmentNames } from './env-names.js';
|
|
23
24
|
export { HttpProblem, problemOf, ServerConfigError } from './errors.js';
|
|
24
25
|
export { createAppHost, startAppHost } from './host.js';
|
|
25
26
|
export { denyAll, isSuperuser, languageOf, runOptionsOf, } from './identity.js';
|
package/dist/server/registry.js
CHANGED
|
@@ -53,6 +53,6 @@ export function buildKoshRegistry(modules, options = {}) {
|
|
|
53
53
|
});
|
|
54
54
|
for (const name of registry.loadOrder)
|
|
55
55
|
if (!loaded.byName.has(name) && name !== CHANGE_EVENTS_MODULE)
|
|
56
|
-
throw new ServerConfigError(`The Shutro module '${name}' has no
|
|
56
|
+
throw new ServerConfigError(`The Shutro module '${name}' has no Shutro ERP manifest.`, 'Wrap it with defineModule() from shutro-erp/modules-sdk (its access levels are required) and depend on it.');
|
|
57
57
|
return { loaded, registry };
|
|
58
58
|
}
|
package/dist/server/server.d.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Kosh server (RFC 0002, CLAUDE.md robustness): Fastify with Pino logs (request ids, redacted credentials),
|
|
3
|
+
* authentication through an {@link Authenticator}, one Shutro transaction per request (`request.env`), RFC 9457
|
|
4
|
+
* errors, rate limiting on Valkey, OpenTelemetry spans, health checks, and graceful shutdown.
|
|
5
|
+
*/
|
|
1
6
|
import type { KoshModule } from '../modules-sdk/index.js';
|
|
2
7
|
import type { Span } from '@opentelemetry/api';
|
|
3
8
|
import { type FastifyInstance, type FastifyServerFactory, type FastifyServerOptions } from 'fastify';
|
package/dist/server/server.js
CHANGED
|
@@ -12,8 +12,11 @@ import { Navigation } from './api/navigation.js';
|
|
|
12
12
|
import { openApiDocument } from './api/openapi.js';
|
|
13
13
|
import { registerRestApi } from './api/rest.js';
|
|
14
14
|
import { ViewRegistry } from './api/views.js';
|
|
15
|
+
import { registerApiKeyRoutes } from './auth/api-keys.js';
|
|
15
16
|
import { registerAuthRoutes } from './auth/routes.js';
|
|
17
|
+
import { enforceScopes } from './auth/scopes.js';
|
|
16
18
|
import { AuthService } from './auth/service.js';
|
|
19
|
+
import { applyEnvironmentNames } from './env-names.js';
|
|
17
20
|
import { HttpProblem, problemOf, ServerConfigError } from './errors.js';
|
|
18
21
|
import { denyAll, runOptionsOf } from './identity.js';
|
|
19
22
|
import { orm, pg } from './orm.js';
|
|
@@ -43,6 +46,7 @@ function externalGroupsOf(registry) {
|
|
|
43
46
|
}
|
|
44
47
|
/** Creates the server (not listening yet). @throws {ServerConfigError} for modules that cannot load together */
|
|
45
48
|
export async function createServer(options) {
|
|
49
|
+
applyEnvironmentNames(); // SHUTRO_ERP_* settings, read under their KOSH_* names
|
|
46
50
|
const registry = buildKoshRegistry(options.modules, {
|
|
47
51
|
...(options.security === undefined ? {} : { security: options.security }),
|
|
48
52
|
});
|
|
@@ -102,6 +106,15 @@ export async function createServer(options) {
|
|
|
102
106
|
}
|
|
103
107
|
});
|
|
104
108
|
});
|
|
109
|
+
// The app each module route belongs to (API key scopes, RFC 0017 §5.1): set while a module registers its routes.
|
|
110
|
+
const routeApps = new Map();
|
|
111
|
+
let routeModule = null;
|
|
112
|
+
app.addHook('onRoute', (route) => {
|
|
113
|
+
if (routeModule === null)
|
|
114
|
+
return;
|
|
115
|
+
for (const method of [route.method].flat())
|
|
116
|
+
routeApps.set(`${method} ${route.url}`, routeModule);
|
|
117
|
+
});
|
|
105
118
|
app.addHook('onRequest', async (request, reply) => {
|
|
106
119
|
reply.header('x-request-id', request.id);
|
|
107
120
|
request.span = tracer.startSpan(`HTTP ${request.method}`, {
|
|
@@ -118,6 +131,17 @@ export async function createServer(options) {
|
|
|
118
131
|
if (config.public === true || config.auth === false)
|
|
119
132
|
return;
|
|
120
133
|
request.identity = await authenticate(request);
|
|
134
|
+
// An API key's scopes (RFC 0017 §5.1): the route's app is the module that registered it, or its model's.
|
|
135
|
+
const scopes = request.identity?.scopes;
|
|
136
|
+
if (scopes !== undefined) {
|
|
137
|
+
const route = request.routeOptions.url ?? '';
|
|
138
|
+
const model = request.params?.model;
|
|
139
|
+
const owner = routeApps.get(`${request.method} ${route}`) ??
|
|
140
|
+
(typeof model === 'string'
|
|
141
|
+
? (registry.registry.models.get(model)?.module ?? null)
|
|
142
|
+
: null);
|
|
143
|
+
enforceScopes(scopes, request.method, route, owner);
|
|
144
|
+
}
|
|
121
145
|
// Customers and vendors of the portal (RFC 0015 §5.1): the portal API and signing in, nothing else.
|
|
122
146
|
if (request.identity !== null &&
|
|
123
147
|
external.size > 0 &&
|
|
@@ -133,7 +157,11 @@ export async function createServer(options) {
|
|
|
133
157
|
max: options.rateLimit?.max ?? 600,
|
|
134
158
|
timeWindow: options.rateLimit?.timeWindow ?? '1 minute',
|
|
135
159
|
...(valkey === undefined ? {} : { redis: valkey, nameSpace: 'kosh:ratelimit:' }),
|
|
136
|
-
keyGenerator: (request) => request.identity === null
|
|
160
|
+
keyGenerator: (request) => request.identity === null
|
|
161
|
+
? `ip:${request.ip}`
|
|
162
|
+
: request.identity.apiKey !== undefined
|
|
163
|
+
? `key:${request.identity.apiKey}`
|
|
164
|
+
: `user:${request.identity.user}`,
|
|
137
165
|
allowList: (request) => request.routeOptions.config.public === true,
|
|
138
166
|
errorResponseBuilder: (_request, context) => new HttpProblem(429, 'rate-limited', 'Too many requests', `Limit of ${context.max} requests per ${context.after} reached.`, `Retry after ${context.after}.`),
|
|
139
167
|
});
|
|
@@ -170,8 +198,10 @@ export async function createServer(options) {
|
|
|
170
198
|
detail: `No route ${request.method} ${request.url.split('?')[0] ?? ''}.`,
|
|
171
199
|
});
|
|
172
200
|
});
|
|
173
|
-
if (auth !== undefined)
|
|
201
|
+
if (auth !== undefined) {
|
|
174
202
|
registerAuthRoutes(app, auth, { loginLimit: options.auth?.loginLimit ?? 10 });
|
|
203
|
+
registerApiKeyRoutes(app, auth, registry);
|
|
204
|
+
}
|
|
175
205
|
// The generated REST API and its OpenAPI document (RFC 0002 §6.3–6.4).
|
|
176
206
|
for (const statement of idempotencyTableStatements())
|
|
177
207
|
await driver.query(statement);
|
|
@@ -182,7 +212,10 @@ export async function createServer(options) {
|
|
|
182
212
|
models,
|
|
183
213
|
driver,
|
|
184
214
|
openapi: () => {
|
|
185
|
-
openapi ??= openApiDocument(models.values(), {
|
|
215
|
+
openapi ??= openApiDocument(models.values(), {
|
|
216
|
+
title: 'Shutro ERP API',
|
|
217
|
+
version: KOSH_VERSION,
|
|
218
|
+
});
|
|
186
219
|
return openapi;
|
|
187
220
|
},
|
|
188
221
|
});
|
|
@@ -198,8 +231,15 @@ export async function createServer(options) {
|
|
|
198
231
|
...(options.apps === undefined ? {} : { apps: options.apps }),
|
|
199
232
|
};
|
|
200
233
|
for (const m of registry.loaded.modules)
|
|
201
|
-
if (m.routes !== undefined)
|
|
202
|
-
|
|
234
|
+
if (m.routes !== undefined) {
|
|
235
|
+
routeModule = m.name;
|
|
236
|
+
try {
|
|
237
|
+
await m.routes(context);
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
routeModule = null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
203
243
|
// Health checks: public, not rate limited. Liveness: the process answers. Readiness: dependencies answer.
|
|
204
244
|
app.get('/health/live', { config: { public: true } }, async () => ({ status: 'ok' }));
|
|
205
245
|
app.get('/health/ready', { config: { public: true } }, async (_request, reply) => {
|
package/dist/settings/routes.js
CHANGED
|
@@ -64,7 +64,7 @@ export function registerAppRoutes(context) {
|
|
|
64
64
|
const { app, driver } = context;
|
|
65
65
|
const appsOf = () => {
|
|
66
66
|
if (context.apps === undefined)
|
|
67
|
-
throw new HttpProblem(503, 'app-manager-unavailable', 'App manager unavailable', 'This server does not run in an app host, so it cannot change the installed apps.', 'Start the server with createAppHost (the starter project does), or use npx
|
|
67
|
+
throw new HttpProblem(503, 'app-manager-unavailable', 'App manager unavailable', 'This server does not run in an app host, so it cannot change the installed apps.', 'Start the server with createAppHost (the starter project does), or use npx shutro-erp apps.');
|
|
68
68
|
return context.apps;
|
|
69
69
|
};
|
|
70
70
|
/** Administrators and App managers only; returns the caller's user id. */
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings › API keys (RFC 0017 §5.1): your keys — and the service accounts' for administrators —, a new key with its
|
|
3
|
+
* scopes and expiry, its secret shown once, revocation. The server decides what each key may do; scopes only narrow
|
|
4
|
+
* the user's own rights.
|
|
5
|
+
*/
|
|
6
|
+
import { type PageProps } from '../../ui/index.js';
|
|
7
|
+
import { type ReactElement } from 'react';
|
|
8
|
+
export declare function ApiKeysPage({ api }: PageProps): ReactElement;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Settings › API keys (RFC 0017 §5.1): your keys — and the service accounts' for administrators —, a new key with its
|
|
4
|
+
* scopes and expiry, its secret shown once, revocation. The server decides what each key may do; scopes only narrow
|
|
5
|
+
* the user's own rights.
|
|
6
|
+
*/
|
|
7
|
+
import { Alert, ApiError, Button, Input, Label, Select, useKosh } from '../../ui/index.js';
|
|
8
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
9
|
+
import { useId, useState } from 'react';
|
|
10
|
+
import { useTranslation } from 'react-i18next';
|
|
11
|
+
export function ApiKeysPage({ api }) {
|
|
12
|
+
const { t } = useTranslation('settings');
|
|
13
|
+
const { format } = useKosh();
|
|
14
|
+
const id = useId();
|
|
15
|
+
const queryClient = useQueryClient();
|
|
16
|
+
const keys = useQuery({
|
|
17
|
+
queryKey: ['settings', 'api-keys'],
|
|
18
|
+
queryFn: () => api.call('GET', '/api/v1/auth/api-keys'),
|
|
19
|
+
});
|
|
20
|
+
const scopes = useQuery({
|
|
21
|
+
queryKey: ['settings', 'scopes'],
|
|
22
|
+
queryFn: () => api.call('GET', '/api/v1/auth/scopes'),
|
|
23
|
+
});
|
|
24
|
+
const apps = [
|
|
25
|
+
...new Set((scopes.data?.scopes ?? [])
|
|
26
|
+
.map((s) => s.scope)
|
|
27
|
+
.filter((s) => s.startsWith('read:'))
|
|
28
|
+
.map((s) => s.slice(5))),
|
|
29
|
+
];
|
|
30
|
+
const [name, setName] = useState('');
|
|
31
|
+
const [days, setDays] = useState('365');
|
|
32
|
+
const [everything, setEverything] = useState(false);
|
|
33
|
+
const [levels, setLevels] = useState({});
|
|
34
|
+
const [secret, setSecret] = useState(null);
|
|
35
|
+
const chosen = everything
|
|
36
|
+
? ['*']
|
|
37
|
+
: Object.entries(levels).flatMap(([app, level]) => level === 'none' ? [] : [`${level}:${app}`]);
|
|
38
|
+
const refresh = () => queryClient.invalidateQueries({ queryKey: ['settings', 'api-keys'] });
|
|
39
|
+
const create = useMutation({
|
|
40
|
+
mutationFn: () => api.call('POST', '/api/v1/auth/api-keys', {
|
|
41
|
+
name,
|
|
42
|
+
scopes: chosen,
|
|
43
|
+
expiresInDays: Number(days),
|
|
44
|
+
}),
|
|
45
|
+
onSuccess: async (key) => {
|
|
46
|
+
setSecret(key.token);
|
|
47
|
+
setName('');
|
|
48
|
+
setLevels({});
|
|
49
|
+
setEverything(false);
|
|
50
|
+
await refresh();
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
const revoke = useMutation({
|
|
54
|
+
mutationFn: (key) => api.call('POST', `/api/v1/auth/api-keys/${key}/revoke`),
|
|
55
|
+
onSuccess: refresh,
|
|
56
|
+
});
|
|
57
|
+
const error = create.error ?? revoke.error;
|
|
58
|
+
const message = (e) => (e instanceof ApiError ? e.message : String(e));
|
|
59
|
+
return (_jsxs("div", { className: "mx-auto grid max-w-4xl gap-6 p-4", children: [_jsx("h1", { className: "text-xl font-semibold", children: t('apiKeys.title') }), _jsx("p", { className: "text-sm text-muted-foreground", children: t('apiKeys.intro') }), error === null ? null : _jsx(Alert, { tone: "danger", children: message(error) }), secret === null ? null : (_jsx(Alert, { tone: "info", children: _jsxs("div", { className: "grid gap-2", children: [_jsx("p", { children: t('apiKeys.secretOnce') }), _jsx("code", { className: "break-all rounded bg-muted px-2 py-1 font-mono text-sm", children: secret }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { variant: "secondary", onClick: () => void navigator.clipboard?.writeText(secret), children: t('apiKeys.copy') }), _jsx(Button, { variant: "secondary", onClick: () => setSecret(null), children: t('apiKeys.done') })] })] }) })), _jsxs("section", { className: "grid gap-3 rounded-lg border p-4", "aria-labelledby": `${id}-new`, children: [_jsx("h2", { id: `${id}-new`, className: "font-medium", children: t('apiKeys.new') }), _jsxs("div", { className: "flex flex-wrap items-end gap-3", children: [_jsxs("div", { className: "grid gap-1", children: [_jsx(Label, { htmlFor: `${id}-name`, children: t('apiKeys.name') }), _jsx(Input, { id: `${id}-name`, className: "w-64", value: name, onChange: (e) => setName(e.target.value) })] }), _jsx(Select, { id: `${id}-days`, label: t('apiKeys.expires'), className: "w-40", value: days, options: ['30', '90', '365', '730'].map((d) => ({
|
|
60
|
+
value: d,
|
|
61
|
+
label: t('apiKeys.days', { count: Number(d) }),
|
|
62
|
+
})), onChange: setDays })] }), _jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx("input", { type: "checkbox", checked: everything, onChange: (e) => setEverything(e.target.checked) }), t('apiKeys.everything')] }), everything ? null : (_jsxs("fieldset", { className: "grid gap-2 sm:grid-cols-2", children: [_jsx("legend", { className: "mb-1 text-sm text-muted-foreground", children: t('apiKeys.perApp') }), apps.map((app) => (_jsx(Select, { id: `${id}-app-${app}`, label: app, value: levels[app] ?? 'none', options: ['none', 'read', 'write'].map((l) => ({
|
|
63
|
+
value: l,
|
|
64
|
+
label: t(`apiKeys.levels.${l}`),
|
|
65
|
+
})), onChange: (v) => setLevels({ ...levels, [app]: (v ?? 'none') }) }, app)))] })), _jsx("div", { children: _jsx(Button, { variant: "primary", disabled: name.trim() === '' || chosen.length === 0, busy: create.isPending, onClick: () => create.mutate(), children: t('apiKeys.create') }) })] }), (keys.data?.keys ?? []).length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: t('apiKeys.none') })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { className: "bg-muted", children: _jsxs("tr", { children: [['name', 'user', 'key', 'scopes', 'expires', 'lastUsed', 'status'].map((c) => (_jsx("th", { className: "px-2 py-1 text-start", children: t(`apiKeys.columns.${c}`) }, c))), _jsx("th", { className: "px-2 py-1", children: _jsx("span", { className: "sr-only", children: t('apiKeys.revoke') }) })] }) }), _jsx("tbody", { children: (keys.data?.keys ?? []).map((k) => (_jsxs("tr", { className: "border-b", children: [_jsx("td", { className: "px-2 py-1", children: k.name }), _jsx("td", { className: "px-2 py-1", children: k.userName }), _jsxs("td", { className: "px-2 py-1 font-mono text-xs", children: [k.prefix, "\u2026"] }), _jsx("td", { className: "px-2 py-1", children: k.scopes.join(' ') }), _jsx("td", { className: "px-2 py-1", children: format.datetime(k.expiresAt) }), _jsx("td", { className: "px-2 py-1", children: k.lastUsedAt === null ? '—' : format.datetime(k.lastUsedAt) }), _jsx("td", { className: "px-2 py-1", children: k.revoked
|
|
66
|
+
? t('apiKeys.revoked')
|
|
67
|
+
: new Date(k.expiresAt).getTime() < Date.now()
|
|
68
|
+
? t('apiKeys.expired')
|
|
69
|
+
: t('apiKeys.active') }), _jsx("td", { className: "px-2 py-1 text-end", children: k.revoked ? null : (_jsx(Button, { variant: "secondary", "aria-label": t('apiKeys.revokeKey', { name: k.name }), busy: revoke.isPending && revoke.variables === k.id, onClick: () => revoke.mutate(k.id), children: t('apiKeys.revoke') })) })] }, k.id))) })] }) }))] }));
|
|
70
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { type Registry } from '../../ui/index.js';
|
|
7
7
|
import type { i18n } from 'i18next';
|
|
8
8
|
import { messages } from './messages.js';
|
|
9
|
+
export { ApiKeysPage } from './ApiKeysPage.js';
|
|
9
10
|
export { AppsPage } from './AppsPage.js';
|
|
10
11
|
/** Registers the Apps page (and its messages in `i18n`, when given). */
|
|
11
12
|
export declare function registerSettingsWeb(registry: Registry, i18n?: i18n): Registry;
|
|
@@ -4,12 +4,16 @@
|
|
|
4
4
|
* client action `settings.action_apps`) and its messages.
|
|
5
5
|
*/
|
|
6
6
|
import { addMessages } from '../../ui/index.js';
|
|
7
|
+
import { ApiKeysPage } from './ApiKeysPage.js';
|
|
7
8
|
import { AppsPage } from './AppsPage.js';
|
|
8
9
|
import { messages } from './messages.js';
|
|
10
|
+
export { ApiKeysPage } from './ApiKeysPage.js';
|
|
9
11
|
export { AppsPage } from './AppsPage.js';
|
|
10
12
|
/** Registers the Apps page (and its messages in `i18n`, when given). */
|
|
11
13
|
export function registerSettingsWeb(registry, i18n) {
|
|
12
14
|
registry.registerPage('settings.Apps', AppsPage);
|
|
15
|
+
// Every signed-in user's own keys (RFC 0017 §5.1): in the user menu, not in the Settings app.
|
|
16
|
+
registry.registerUserPage('settings.ApiKeys', ApiKeysPage, { label: 'settings:apiKeys.title' });
|
|
13
17
|
if (i18n !== undefined)
|
|
14
18
|
addMessages(i18n, 'settings', messages);
|
|
15
19
|
return registry;
|
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
/** Messages of the Settings app (namespace `settings`). */
|
|
2
2
|
export const messages = {
|
|
3
3
|
en: {
|
|
4
|
+
apiKeys: {
|
|
5
|
+
title: 'API keys',
|
|
6
|
+
intro: 'Keys let scripts and other systems call the API as you, within the scopes you give them. Your own rights still apply; a key can never do more than you.',
|
|
7
|
+
new: 'New key',
|
|
8
|
+
name: 'Name',
|
|
9
|
+
expires: 'Expires in',
|
|
10
|
+
days_one: '{{count}} day',
|
|
11
|
+
days_other: '{{count}} days',
|
|
12
|
+
everything: 'Everything I may do (*)',
|
|
13
|
+
perApp: 'Or per app:',
|
|
14
|
+
levels: { none: 'No access', read: 'Read', write: 'Read and change' },
|
|
15
|
+
create: 'Create key',
|
|
16
|
+
secretOnce: 'Copy the key now: it is shown only once.',
|
|
17
|
+
copy: 'Copy',
|
|
18
|
+
done: 'Done',
|
|
19
|
+
none: 'No API keys yet.',
|
|
20
|
+
columns: {
|
|
21
|
+
name: 'Name',
|
|
22
|
+
user: 'User',
|
|
23
|
+
key: 'Key',
|
|
24
|
+
scopes: 'Scopes',
|
|
25
|
+
expires: 'Expires',
|
|
26
|
+
lastUsed: 'Last used',
|
|
27
|
+
status: 'Status',
|
|
28
|
+
},
|
|
29
|
+
active: 'Active',
|
|
30
|
+
revoked: 'Revoked',
|
|
31
|
+
expired: 'Expired',
|
|
32
|
+
revoke: 'Revoke',
|
|
33
|
+
revokeKey: 'Revoke {{name}}',
|
|
34
|
+
},
|
|
4
35
|
apps: 'Apps',
|
|
5
36
|
appsIntro: 'Install the apps your business needs. Disabling keeps the data, so nothing is lost.',
|
|
6
37
|
search: 'Search apps',
|
|
@@ -56,6 +87,37 @@ export const messages = {
|
|
|
56
87
|
version: 'Version {{version}}',
|
|
57
88
|
},
|
|
58
89
|
bn: {
|
|
90
|
+
apiKeys: {
|
|
91
|
+
title: 'এপিআই কী',
|
|
92
|
+
intro: 'কী দিয়ে স্ক্রিপ্ট ও অন্য সিস্টেম আপনার হয়ে এপিআই ব্যবহার করে, আপনার দেওয়া পরিসরের মধ্যে। আপনার নিজের অধিকারই প্রযোজ্য; কোনো কী আপনার চেয়ে বেশি করতে পারে না।',
|
|
93
|
+
new: 'নতুন কী',
|
|
94
|
+
name: 'নাম',
|
|
95
|
+
expires: 'মেয়াদ',
|
|
96
|
+
days_one: '{{count}} দিন',
|
|
97
|
+
days_other: '{{count}} দিন',
|
|
98
|
+
everything: 'আমি যা করতে পারি সবকিছু (*)',
|
|
99
|
+
perApp: 'অথবা প্রতিটি অ্যাপের জন্য:',
|
|
100
|
+
levels: { none: 'প্রবেশাধিকার নেই', read: 'পড়া', write: 'পড়া ও পরিবর্তন' },
|
|
101
|
+
create: 'কী তৈরি করুন',
|
|
102
|
+
secretOnce: 'কী-টি এখনই কপি করুন: এটি একবারই দেখানো হয়।',
|
|
103
|
+
copy: 'কপি',
|
|
104
|
+
done: 'সম্পন্ন',
|
|
105
|
+
none: 'এখনো কোনো এপিআই কী নেই।',
|
|
106
|
+
columns: {
|
|
107
|
+
name: 'নাম',
|
|
108
|
+
user: 'ব্যবহারকারী',
|
|
109
|
+
key: 'কী',
|
|
110
|
+
scopes: 'পরিসর',
|
|
111
|
+
expires: 'মেয়াদ শেষ',
|
|
112
|
+
lastUsed: 'শেষ ব্যবহার',
|
|
113
|
+
status: 'অবস্থা',
|
|
114
|
+
},
|
|
115
|
+
active: 'সক্রিয়',
|
|
116
|
+
revoked: 'বাতিল',
|
|
117
|
+
expired: 'মেয়াদোত্তীর্ণ',
|
|
118
|
+
revoke: 'বাতিল করুন',
|
|
119
|
+
revokeKey: '{{name}} বাতিল করুন',
|
|
120
|
+
},
|
|
59
121
|
apps: 'অ্যাপস',
|
|
60
122
|
appsIntro: 'আপনার ব্যবসার প্রয়োজনীয় অ্যাপগুলো ইনস্টল করুন। নিষ্ক্রিয় করলে ডেটা থেকে যায়, কিছুই হারায় না।',
|
|
61
123
|
search: 'অ্যাপ খুঁজুন',
|
package/dist/storage/routes.js
CHANGED
|
@@ -25,7 +25,7 @@ export function storageOf(app) {
|
|
|
25
25
|
if (storage === undefined) {
|
|
26
26
|
const configured = storageFromEnv();
|
|
27
27
|
if (configured === null)
|
|
28
|
-
throw new HttpProblem(503, 'storage-unavailable', 'File storage is not configured', 'No object store is configured for attachments.', 'Set
|
|
28
|
+
throw new HttpProblem(503, 'storage-unavailable', 'File storage is not configured', 'No object store is configured for attachments.', 'Set SHUTRO_ERP_STORAGE_DIR, or S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY and S3_SECRET_KEY.');
|
|
29
29
|
storage = configured;
|
|
30
30
|
stores.set(app, storage);
|
|
31
31
|
}
|
package/dist/studio/build.js
CHANGED
|
@@ -42,7 +42,7 @@ export async function studioModules(cwd) {
|
|
|
42
42
|
export async function runStudio(argv, write, cwd) {
|
|
43
43
|
const [action, ...folders] = argv;
|
|
44
44
|
if (action !== 'build') {
|
|
45
|
-
write('Usage:
|
|
45
|
+
write('Usage: shutro-erp studio build [folder …] (default: every src/modules/*/studio.json)\n');
|
|
46
46
|
return 2;
|
|
47
47
|
}
|
|
48
48
|
const dirs = folders.length > 0 ? folders.map((f) => resolve(cwd, f)) : await studioModules(cwd);
|
package/dist/studio/generate.js
CHANGED
|
@@ -14,7 +14,7 @@ export class StudioSpecError extends Error {
|
|
|
14
14
|
this.name = 'StudioSpecError';
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
|
-
const HEADER = '// Generated by
|
|
17
|
+
const HEADER = '// Generated by Shutro ERP Studio from studio.json: do not edit (edit studio.json, then `shutro-erp studio build`).';
|
|
18
18
|
/** A TypeScript literal with single quotes and stable key order (as given). */
|
|
19
19
|
export function lit(value, indent = '') {
|
|
20
20
|
if (value === null || value === undefined)
|
|
@@ -181,7 +181,7 @@ export function generateModule(spec, options = {}) {
|
|
|
181
181
|
HEADER,
|
|
182
182
|
'/**',
|
|
183
183
|
` * ${spec.label}${spec.description === undefined ? '' : `: ${spec.description}`}`,
|
|
184
|
-
' * A
|
|
184
|
+
' * A Shutro ERP Studio module (RFC 0012): its fields, models, view changes, menus, approvals and rules, from studio.json.',
|
|
185
185
|
' */',
|
|
186
186
|
`import { base } from '${im.base}';`,
|
|
187
187
|
...deps.map((d) => `import { ${camel(d)} } from '${im.module(d)}';`),
|
|
@@ -222,8 +222,8 @@ export function generateModule(spec, options = {}) {
|
|
|
222
222
|
const readme = [
|
|
223
223
|
`# ${spec.label} (\`${name}\` ${spec.version})`,
|
|
224
224
|
'',
|
|
225
|
-
'A
|
|
226
|
-
'(`
|
|
225
|
+
'A Shutro ERP Studio module (RFC 0012): `studio.json` is its source; `index.ts` and `models.ts` are generated from it',
|
|
226
|
+
'(`shutro-erp studio build`). To take it over as code, delete `studio.json` and edit the TypeScript.',
|
|
227
227
|
'',
|
|
228
228
|
...(spec.description === undefined ? [] : [spec.description, '']),
|
|
229
229
|
...((spec.fields ?? []).length === 0
|
package/dist/studio/module.js
CHANGED
|
@@ -368,7 +368,7 @@ export function registerStudioRoutes(context, options = {}) {
|
|
|
368
368
|
await guard(request);
|
|
369
369
|
const pullRequest = request.body?.pullRequest === true;
|
|
370
370
|
if (pullRequest && git === null)
|
|
371
|
-
throw new HttpProblem(409, 'studio-git-unconfigured', 'No repository for proposals', 'Set
|
|
371
|
+
throw new HttpProblem(409, 'studio-git-unconfigured', 'No repository for proposals', 'Set SHUTRO_ERP_STUDIO_GIT_URL and SHUTRO_ERP_STUDIO_GIT_TOKEN to open pull requests; download the patch meanwhile.');
|
|
372
372
|
const { review, files, modulesTs } = await prepare(specOf(request.body));
|
|
373
373
|
const patch = review.diff.map((d) => d.diff).join('');
|
|
374
374
|
if (!pullRequest || git === null)
|
|
@@ -384,7 +384,7 @@ export function registerStudioRoutes(context, options = {}) {
|
|
|
384
384
|
});
|
|
385
385
|
const command = review.change === 'install' ? 'install' : 'upgrade';
|
|
386
386
|
const body = [
|
|
387
|
-
`A
|
|
387
|
+
`A Shutro ERP Studio change: **${review.spec.label}** (\`${name}\` ${review.spec.version}).`,
|
|
388
388
|
'',
|
|
389
389
|
review.change === 'install'
|
|
390
390
|
? `Installs the module \`${name}\`.`
|
|
@@ -393,7 +393,7 @@ export function registerStudioRoutes(context, options = {}) {
|
|
|
393
393
|
'After deploying:',
|
|
394
394
|
'',
|
|
395
395
|
'```sh',
|
|
396
|
-
`npx
|
|
396
|
+
`npx shutro-erp apps ${command} ${name}`,
|
|
397
397
|
'```',
|
|
398
398
|
'',
|
|
399
399
|
...(review.statements.length === 0
|
|
@@ -417,14 +417,14 @@ export function registerStudioRoutes(context, options = {}) {
|
|
|
417
417
|
}
|
|
418
418
|
catch (error) {
|
|
419
419
|
if (error instanceof GitProposalError)
|
|
420
|
-
throw new HttpProblem(502, 'studio-git-failed', 'The repository refused the proposal', error.message, 'Check
|
|
420
|
+
throw new HttpProblem(502, 'studio-git-failed', 'The repository refused the proposal', error.message, 'Check SHUTRO_ERP_STUDIO_GIT_URL, the token and its rights (contents and pull requests); the patch can be downloaded meanwhile.');
|
|
421
421
|
throw error;
|
|
422
422
|
}
|
|
423
423
|
});
|
|
424
424
|
app.post('/api/v1/studio/apply', async (request) => {
|
|
425
425
|
const uid = await guard(request);
|
|
426
426
|
if (mode !== 'write')
|
|
427
|
-
throw new HttpProblem(409, 'studio-propose-only', 'Studio proposes changes here', 'This server runs Studio in propose mode: it never writes code. Download the patch and deploy it.', '
|
|
427
|
+
throw new HttpProblem(409, 'studio-propose-only', 'Studio proposes changes here', 'This server runs Studio in propose mode: it never writes code. Download the patch and deploy it.', 'SHUTRO_ERP_STUDIO=write is for development and single-server installations.');
|
|
428
428
|
const planId = request.body?.planId;
|
|
429
429
|
if (typeof planId !== 'string')
|
|
430
430
|
return invalid('planId (from /preview) is required.');
|
|
@@ -35,7 +35,7 @@ export const messages = {
|
|
|
35
35
|
openPullRequest: 'Open a pull request',
|
|
36
36
|
openMergeRequest: 'Open a merge request',
|
|
37
37
|
proposalOpened: 'Opened on {{repository}}:',
|
|
38
|
-
proposeNote: 'This server proposes changes: download the patch, commit it, and deploy it (then
|
|
38
|
+
proposeNote: 'This server proposes changes: download the patch, commit it, and deploy it (then shutro-erp apps upgrade).',
|
|
39
39
|
modulesTitle: 'Studio modules',
|
|
40
40
|
modulesIntro: 'Each Studio change is saved in a module of your project: reviewed as code, versioned, and upgraded like any app.',
|
|
41
41
|
modeWrite: 'Write mode: changes are applied on this server.',
|
|
@@ -109,7 +109,7 @@ export const messages = {
|
|
|
109
109
|
openPullRequest: 'পুল রিকোয়েস্ট খুলুন',
|
|
110
110
|
openMergeRequest: 'মার্জ রিকোয়েস্ট খুলুন',
|
|
111
111
|
proposalOpened: '{{repository}}-এ খোলা হয়েছে:',
|
|
112
|
-
proposeNote: 'এই সার্ভার পরিবর্তন প্রস্তাব করে: প্যাচ ডাউনলোড করে কমিট ও ডিপ্লয় করুন (তারপর
|
|
112
|
+
proposeNote: 'এই সার্ভার পরিবর্তন প্রস্তাব করে: প্যাচ ডাউনলোড করে কমিট ও ডিপ্লয় করুন (তারপর shutro-erp apps upgrade)।',
|
|
113
113
|
modulesTitle: 'স্টুডিও মডিউল',
|
|
114
114
|
modulesIntro: 'স্টুডিওর প্রতিটি পরিবর্তন আপনার প্রজেক্টের একটি মডিউলে সংরক্ষিত হয়: কোডের মতো পর্যালোচিত, সংস্করণযুক্ত এবং যেকোনো অ্যাপের মতো আপগ্রেডযোগ্য।',
|
|
115
115
|
modeWrite: 'রাইট মোড: পরিবর্তন এই সার্ভারে প্রয়োগ হয়।',
|
package/dist/ui/app.js
CHANGED
|
@@ -97,7 +97,7 @@ const ServicesContext = createContext(null);
|
|
|
97
97
|
function useServices() {
|
|
98
98
|
const value = useContext(ServicesContext);
|
|
99
99
|
if (value === null)
|
|
100
|
-
throw new Error('
|
|
100
|
+
throw new Error('Shutro ERP routes render inside <KoshApp>.');
|
|
101
101
|
return value;
|
|
102
102
|
}
|
|
103
103
|
// ------------------------------------------------------------------------------------------------- shell
|
|
@@ -181,7 +181,7 @@ function Shell() {
|
|
|
181
181
|
api.setCompany(id);
|
|
182
182
|
setCompany(id);
|
|
183
183
|
void queryClient.invalidateQueries();
|
|
184
|
-
} }), _jsx(LanguageMenu, {}), _jsx(ThemeMenu, {}), _jsxs(Menu, { children: [_jsx(MenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", "aria-label": user.name, children: _jsx(UserRound, { "aria-hidden": true }) }) }), _jsxs(MenuContent, { children: [_jsx(MenuLabel, { children: user.name }), _jsxs(MenuItem, { onSelect: async () => {
|
|
184
|
+
} }), _jsx(LanguageMenu, {}), _jsx(ThemeMenu, {}), _jsxs(Menu, { children: [_jsx(MenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", "aria-label": user.name, children: _jsx(UserRound, { "aria-hidden": true }) }) }), _jsxs(MenuContent, { children: [_jsx(MenuLabel, { children: user.name }), registry.userPages().map((p) => (_jsx(MenuItem, { onSelect: () => void navigate({ to: '/me/$page', params: { page: p.name } }), children: t(p.label) }, p.name))), _jsxs(MenuItem, { onSelect: async () => {
|
|
185
185
|
await api.logout();
|
|
186
186
|
queryClient.clear();
|
|
187
187
|
await dropOfflineCache(persister);
|
|
@@ -451,9 +451,32 @@ const openRoute = createRoute({
|
|
|
451
451
|
path: '/open/$model/$id',
|
|
452
452
|
component: OpenRecordPage,
|
|
453
453
|
});
|
|
454
|
+
/** A personal page from the user menu (`registry.registerUserPage`), outside any app. */
|
|
455
|
+
function UserPage() {
|
|
456
|
+
const { page } = useParams({ strict: false });
|
|
457
|
+
const { api, registry } = useServices();
|
|
458
|
+
const { t } = useTranslation();
|
|
459
|
+
const entry = registry.userPages().find((p) => p.name === page);
|
|
460
|
+
if (entry === undefined)
|
|
461
|
+
return _jsx("p", { className: "p-4 text-sm", children: t('notFound') });
|
|
462
|
+
const Page = entry.component;
|
|
463
|
+
return _jsx(Page, { api: api, label: t(entry.label) });
|
|
464
|
+
}
|
|
465
|
+
const userPageRoute = createRoute({
|
|
466
|
+
getParentRoute: () => shellRoute,
|
|
467
|
+
path: '/me/$page',
|
|
468
|
+
component: UserPage,
|
|
469
|
+
});
|
|
454
470
|
const routeTree = rootRoute.addChildren([
|
|
455
471
|
loginRoute,
|
|
456
|
-
shellRoute.addChildren([
|
|
472
|
+
shellRoute.addChildren([
|
|
473
|
+
indexRoute,
|
|
474
|
+
openRoute,
|
|
475
|
+
userPageRoute,
|
|
476
|
+
appRoute,
|
|
477
|
+
actionRoute,
|
|
478
|
+
recordRoute,
|
|
479
|
+
]),
|
|
457
480
|
]);
|
|
458
481
|
const LANG_KEY = 'kosh.lang';
|
|
459
482
|
function initialLanguage() {
|
package/dist/ui/i18n/messages.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export const en = {
|
|
6
6
|
app: {
|
|
7
|
-
name: '
|
|
7
|
+
name: 'Shutro ERP',
|
|
8
8
|
loading: 'Loading…',
|
|
9
9
|
error: 'Something went wrong.',
|
|
10
10
|
retry: 'Retry',
|
|
@@ -169,7 +169,7 @@ export const en = {
|
|
|
169
169
|
synced_other: '{{count}} changes saved on this device were sent',
|
|
170
170
|
needsAttention_one: '{{count}} change could not be sent. Open the cloud in the header.',
|
|
171
171
|
needsAttention_other: '{{count}} changes could not be sent. Open the cloud in the header.',
|
|
172
|
-
newVersion: 'A new version of
|
|
172
|
+
newVersion: 'A new version of Shutro ERP is ready.',
|
|
173
173
|
reload: 'Reload',
|
|
174
174
|
},
|
|
175
175
|
home: {
|
package/dist/ui/pwa/worker.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
/** The worker's JavaScript. */
|
|
9
9
|
export function serviceWorkerSource(config) {
|
|
10
|
-
return `/*
|
|
10
|
+
return `/* Shutro ERP service worker ${config.version}: generated by shutro-erp/ui/vite, do not edit. */
|
|
11
11
|
const VERSION = ${JSON.stringify(config.version)};
|
|
12
12
|
const ASSETS = ${JSON.stringify(config.assets)};
|
|
13
13
|
const CACHE = 'kosh-' + VERSION;
|
|
@@ -57,7 +57,7 @@ self.addEventListener('fetch', (event) => {
|
|
|
57
57
|
});
|
|
58
58
|
|
|
59
59
|
self.addEventListener('push', (event) => {
|
|
60
|
-
let data = { title: '
|
|
60
|
+
let data = { title: 'Shutro ERP', body: '', url: '/', id: 0 };
|
|
61
61
|
try {
|
|
62
62
|
data = { ...data, ...event.data.json() };
|
|
63
63
|
} catch {
|
package/dist/ui/registry.d.ts
CHANGED
|
@@ -128,6 +128,21 @@ export declare class Registry {
|
|
|
128
128
|
registerHeaderItem(name: string, component: ComponentType<HeaderItemProps>, options?: {
|
|
129
129
|
readonly replace?: boolean;
|
|
130
130
|
}): this;
|
|
131
|
+
/**
|
|
132
|
+
* A personal page every signed-in user reaches from the user menu, outside any app (`settings.ApiKeys`):
|
|
133
|
+
* served at `/me/<name>`; `label` is an i18n key (`settings:apiKeys.title`). The server still decides what the
|
|
134
|
+
* page may show and do.
|
|
135
|
+
*/
|
|
136
|
+
registerUserPage(name: string, component: ComponentType<PageProps>, options: {
|
|
137
|
+
readonly label: string;
|
|
138
|
+
readonly replace?: boolean;
|
|
139
|
+
}): this;
|
|
140
|
+
/** The user pages, in registration order, with their label keys. */
|
|
141
|
+
userPages(): readonly {
|
|
142
|
+
readonly name: string;
|
|
143
|
+
readonly label: string;
|
|
144
|
+
readonly component: ComponentType<PageProps>;
|
|
145
|
+
}[];
|
|
131
146
|
/** The header items, in registration order. */
|
|
132
147
|
headerItems(): readonly {
|
|
133
148
|
readonly name: string;
|