shutro-erp 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/README.md +58 -58
  2. package/dist/account_portal/.kosh/bindings.js +1 -1
  3. package/dist/account_portal/.kosh/schema.d.ts +31 -1
  4. package/dist/account_portal/index.js +3 -2
  5. package/dist/accounting/.kosh/bindings.js +1 -1
  6. package/dist/accounting/.kosh/schema.d.ts +31 -1
  7. package/dist/accounting/index.js +4 -0
  8. package/dist/assistant/routes.js +1 -1
  9. package/dist/automation/engine.js +1 -1
  10. package/dist/automation/web/messages.js +2 -2
  11. package/dist/cli/apps.d.ts +1 -1
  12. package/dist/cli/apps.js +14 -14
  13. package/dist/cli/bin.js +3 -0
  14. package/dist/cli/index.js +11 -11
  15. package/dist/cli/init.d.ts +1 -1
  16. package/dist/cli/init.js +60 -36
  17. package/dist/cli/launch.js +10 -7
  18. package/dist/cli/project.d.ts +7 -7
  19. package/dist/cli/project.js +25 -25
  20. package/dist/cli/prompt.js +2 -2
  21. package/dist/cli/templates.d.ts +3 -3
  22. package/dist/cli/templates.js +33 -28
  23. package/dist/cli/ui.d.ts +1 -8
  24. package/dist/cli/ui.js +36 -12
  25. package/dist/contacts/.kosh/bindings.js +1 -1
  26. package/dist/contacts/.kosh/schema.d.ts +31 -1
  27. package/dist/integrations/inbound.d.ts +36 -0
  28. package/dist/integrations/inbound.js +171 -0
  29. package/dist/integrations/index.d.ts +18 -0
  30. package/dist/integrations/index.js +400 -0
  31. package/dist/integrations/models.d.ts +6 -0
  32. package/dist/integrations/models.js +119 -0
  33. package/dist/integrations/web/DeliveryTools.d.ts +4 -0
  34. package/dist/integrations/web/DeliveryTools.js +15 -0
  35. package/dist/integrations/web/EndpointTools.d.ts +7 -0
  36. package/dist/integrations/web/EndpointTools.js +25 -0
  37. package/dist/integrations/web/InboundTools.d.ts +4 -0
  38. package/dist/integrations/web/InboundTools.js +15 -0
  39. package/dist/integrations/web/WebhookTools.d.ts +7 -0
  40. package/dist/integrations/web/WebhookTools.js +25 -0
  41. package/dist/integrations/web/index.d.ts +16 -0
  42. package/dist/integrations/web/index.js +27 -0
  43. package/dist/integrations/web/messages.d.ts +2 -0
  44. package/dist/integrations/web/messages.js +53 -0
  45. package/dist/integrations/webhooks.d.ts +63 -0
  46. package/dist/integrations/webhooks.js +217 -0
  47. package/dist/l10n_bd/.kosh/bindings.js +1 -1
  48. package/dist/l10n_bd/.kosh/schema.d.ts +31 -1
  49. package/dist/l10n_in/.kosh/bindings.js +1 -1
  50. package/dist/l10n_in/.kosh/schema.d.ts +31 -1
  51. package/dist/l10n_in/einvoice.js +1 -1
  52. package/dist/l10n_in/web/messages.js +2 -2
  53. package/dist/mail/index.js +2 -2
  54. package/dist/modules-sdk/index.d.ts +1 -1
  55. package/dist/modules-sdk/loader.js +2 -2
  56. package/dist/modules-sdk/manifest.d.ts +48 -0
  57. package/dist/notify/messages.js +3 -3
  58. package/dist/notify/routes.js +1 -1
  59. package/dist/notify/web/messages.js +1 -1
  60. package/dist/payment/.kosh/bindings.d.ts +22 -0
  61. package/dist/payment/.kosh/bindings.js +15 -0
  62. package/dist/payment/.kosh/schema.d.ts +1553 -0
  63. package/dist/payment/index.d.ts +34 -0
  64. package/dist/payment/index.js +375 -0
  65. package/dist/payment/module.d.ts +6853 -0
  66. package/dist/payment/module.js +74 -0
  67. package/dist/payment/providers.d.ts +64 -0
  68. package/dist/payment/providers.js +77 -0
  69. package/dist/payment/settle.d.ts +11 -0
  70. package/dist/payment/settle.js +59 -0
  71. package/dist/payment/sslcommerz.d.ts +12 -0
  72. package/dist/payment/sslcommerz.js +185 -0
  73. package/dist/portal/.kosh/bindings.js +1 -1
  74. package/dist/portal/.kosh/schema.d.ts +31 -1
  75. package/dist/portal/routes-documents.js +5 -0
  76. package/dist/product/.kosh/bindings.js +1 -1
  77. package/dist/product/.kosh/schema.d.ts +31 -1
  78. package/dist/project/.kosh/bindings.js +1 -1
  79. package/dist/project/.kosh/schema.d.ts +31 -1
  80. package/dist/project_portal/.kosh/bindings.js +1 -1
  81. package/dist/project_portal/.kosh/schema.d.ts +31 -1
  82. package/dist/purchase/.kosh/bindings.js +1 -1
  83. package/dist/purchase/.kosh/schema.d.ts +31 -1
  84. package/dist/purchase_account/.kosh/bindings.js +1 -1
  85. package/dist/purchase_account/.kosh/schema.d.ts +31 -1
  86. package/dist/purchase_portal/.kosh/bindings.js +1 -1
  87. package/dist/purchase_portal/.kosh/schema.d.ts +31 -1
  88. package/dist/sale_account/.kosh/bindings.js +1 -1
  89. package/dist/sale_account/.kosh/schema.d.ts +31 -1
  90. package/dist/sale_portal/.kosh/bindings.js +1 -1
  91. package/dist/sale_portal/.kosh/schema.d.ts +31 -1
  92. package/dist/sale_project/.kosh/bindings.js +1 -1
  93. package/dist/sale_project/.kosh/schema.d.ts +31 -1
  94. package/dist/sales/.kosh/bindings.js +1 -1
  95. package/dist/sales/.kosh/schema.d.ts +31 -1
  96. package/dist/sales/index.js +2 -0
  97. package/dist/server/api/client.js +1 -1
  98. package/dist/server/api/openapi.js +17 -1
  99. package/dist/server/api/rest.d.ts +6 -1
  100. package/dist/server/api/rest.js +11 -0
  101. package/dist/server/apps.js +5 -5
  102. package/dist/server/auth/api-keys.d.ts +13 -0
  103. package/dist/server/auth/api-keys.js +65 -0
  104. package/dist/server/auth/base.js +24 -0
  105. package/dist/server/auth/crypto.js +1 -1
  106. package/dist/server/auth/scopes.d.ts +12 -0
  107. package/dist/server/auth/scopes.js +51 -0
  108. package/dist/server/auth/service.d.ts +57 -1
  109. package/dist/server/auth/service.js +229 -5
  110. package/dist/server/demo.d.ts +1 -1
  111. package/dist/server/demo.js +1 -1
  112. package/dist/server/env-names.d.ts +2 -0
  113. package/dist/server/env-names.js +16 -0
  114. package/dist/server/host.js +5 -1
  115. package/dist/server/identity.d.ts +7 -0
  116. package/dist/server/index.d.ts +2 -1
  117. package/dist/server/index.js +2 -1
  118. package/dist/server/registry.js +1 -1
  119. package/dist/server/server.d.ts +5 -0
  120. package/dist/server/server.js +45 -5
  121. package/dist/settings/routes.js +1 -1
  122. package/dist/settings/web/ApiKeysPage.d.ts +8 -0
  123. package/dist/settings/web/ApiKeysPage.js +70 -0
  124. package/dist/settings/web/index.d.ts +1 -0
  125. package/dist/settings/web/index.js +4 -0
  126. package/dist/settings/web/messages.js +62 -0
  127. package/dist/storage/routes.js +1 -1
  128. package/dist/studio/build.js +1 -1
  129. package/dist/studio/generate.js +4 -4
  130. package/dist/studio/module.js +5 -5
  131. package/dist/studio/web/messages.js +2 -2
  132. package/dist/ui/app.js +26 -3
  133. package/dist/ui/i18n/messages.js +2 -2
  134. package/dist/ui/pwa/worker.js +2 -2
  135. package/dist/ui/registry.d.ts +15 -0
  136. package/dist/ui/registry.js +20 -0
  137. package/package.json +15 -3
@@ -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({ url: options.databaseUrl, max: 1, applicationName: 'kosh apps listener' })
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
@@ -9,7 +9,7 @@ export { IDEMPOTENCY_TABLE, idempotencyTableStatements, idempotent, type Outcome
9
9
  export { type ApiField, type ApiModel, apiModels, defaultFields, publicApiModels, schemaName, } from './api/metadata.js';
10
10
  export { type FoundRecord, type MenuNode, moduleVisible, Navigation } from './api/navigation.js';
11
11
  export { fieldSchema, openApiDocument, openApiOf } from './api/openapi.js';
12
- export { registerRestApi } from './api/rest.js';
12
+ export { readApiRecord, registerRestApi } from './api/rest.js';
13
13
  export { type ServedView, type ViewField, ViewRegistry } from './api/views.js';
14
14
  export { APPS_CHANNEL, APPS_LOG_TABLE, APPS_TABLE, type AppLoadContext, type ApplyAppsOptions, type AppsResult, applyApps, appsDigest, appTableStatements, FRAMEWORK_TABLES, installDefaults, installedApps, installedModules, type PrepareAppsOptions, type PreparedApps, prepareApps, readInstalledApps, recordInstalledApps, restoreAppMigrations, } from './apps.js';
15
15
  export { type AuditInput, audit } from './audit.js';
@@ -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';
@@ -9,7 +9,7 @@ export { IDEMPOTENCY_TABLE, idempotencyTableStatements, idempotent, } from './ap
9
9
  export { apiModels, defaultFields, publicApiModels, schemaName, } from './api/metadata.js';
10
10
  export { moduleVisible, Navigation } from './api/navigation.js';
11
11
  export { fieldSchema, openApiDocument, openApiOf } from './api/openapi.js';
12
- export { registerRestApi } from './api/rest.js';
12
+ export { readApiRecord, registerRestApi } from './api/rest.js';
13
13
  export { ViewRegistry } from './api/views.js';
14
14
  export { APPS_CHANNEL, APPS_LOG_TABLE, APPS_TABLE, applyApps, appsDigest, appTableStatements, FRAMEWORK_TABLES, installDefaults, installedApps, installedModules, prepareApps, readInstalledApps, recordInstalledApps, restoreAppMigrations, } from './apps.js';
15
15
  export { audit } from './audit.js';
@@ -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';
@@ -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 Kosh manifest.`, 'Wrap it with defineModule() from shutro-erp/modules-sdk (its access levels are required) and depend on it.');
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
  }
@@ -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';
@@ -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 ? `ip:${request.ip}` : `user:${request.identity.user}`,
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(), { title: 'Kosh API', version: KOSH_VERSION });
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
- await m.routes(context);
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) => {
@@ -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 kosh apps.');
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: 'অ্যাপ খুঁজুন',
@@ -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 KOSH_STORAGE_DIR, or S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY and S3_SECRET_KEY.');
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
  }
@@ -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: kosh studio build [folder …] (default: every src/modules/*/studio.json)\n');
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);
@@ -14,7 +14,7 @@ export class StudioSpecError extends Error {
14
14
  this.name = 'StudioSpecError';
15
15
  }
16
16
  }
17
- const HEADER = '// Generated by Kosh Studio from studio.json: do not edit (edit studio.json, then `kosh studio build`).';
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 Kosh Studio module (RFC 0012): its fields, models, view changes, menus, approvals and rules, from studio.json.',
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 Kosh Studio module (RFC 0012): `studio.json` is its source; `index.ts` and `models.ts` are generated from it',
226
- '(`kosh studio build`). To take it over as code, delete `studio.json` and edit the TypeScript.',
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
@@ -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 KOSH_STUDIO_GIT_URL and KOSH_STUDIO_GIT_TOKEN to open pull requests; download the patch meanwhile.');
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 Kosh Studio change: **${review.spec.label}** (\`${name}\` ${review.spec.version}).`,
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 kosh apps ${command} ${name}`,
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 KOSH_STUDIO_GIT_URL, the token and its rights (contents and pull requests); the patch can be downloaded meanwhile.');
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.', 'KOSH_STUDIO=write is for development and single-server installations.');
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 kosh apps upgrade).',
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: 'এই সার্ভার পরিবর্তন প্রস্তাব করে: প্যাচ ডাউনলোড করে কমিট ও ডিপ্লয় করুন (তারপর kosh apps upgrade)।',
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('Kosh routes render inside <KoshApp>.');
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([indexRoute, openRoute, appRoute, actionRoute, recordRoute]),
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() {
@@ -4,7 +4,7 @@
4
4
  */
5
5
  export const en = {
6
6
  app: {
7
- name: 'Kosh',
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 Kosh is ready.',
172
+ newVersion: 'A new version of Shutro ERP is ready.',
173
173
  reload: 'Reload',
174
174
  },
175
175
  home: {
@@ -7,7 +7,7 @@
7
7
  */
8
8
  /** The worker's JavaScript. */
9
9
  export function serviceWorkerSource(config) {
10
- return `/* Kosh service worker ${config.version}: generated by shutro-erp/ui/vite, do not edit. */
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: 'Kosh', body: '', url: '/', id: 0 };
60
+ let data = { title: 'Shutro ERP', body: '', url: '/', id: 0 };
61
61
  try {
62
62
  data = { ...data, ...event.data.json() };
63
63
  } catch {
@@ -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;