shutro-erp 0.3.2 → 0.3.3

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 (130) hide show
  1. package/dist/account_portal/.kosh/bindings.js +1 -1
  2. package/dist/account_portal/.kosh/schema.d.ts +29 -1
  3. package/dist/accounting/.kosh/bindings.js +1 -1
  4. package/dist/accounting/.kosh/schema.d.ts +29 -1
  5. package/dist/assistant/index.js +1 -1
  6. package/dist/contacts/.kosh/bindings.js +1 -1
  7. package/dist/contacts/.kosh/schema.d.ts +29 -1
  8. package/dist/l10n_bd/.kosh/bindings.js +1 -1
  9. package/dist/l10n_bd/.kosh/schema.d.ts +29 -1
  10. package/dist/l10n_in/.kosh/bindings.js +1 -1
  11. package/dist/l10n_in/.kosh/schema.d.ts +29 -4
  12. package/dist/l10n_in/module.d.ts +0 -62
  13. package/dist/l10n_in/module.js +2 -4
  14. package/dist/payment/.kosh/bindings.js +1 -1
  15. package/dist/payment/.kosh/schema.d.ts +30 -2
  16. package/dist/payment/bkash.d.ts +28 -0
  17. package/dist/payment/bkash.js +169 -0
  18. package/dist/payment/index.d.ts +4 -0
  19. package/dist/payment/index.js +80 -24
  20. package/dist/payment/module.d.ts +7 -4
  21. package/dist/payment/module.js +3 -0
  22. package/dist/payment/money.d.ts +7 -0
  23. package/dist/payment/money.js +37 -0
  24. package/dist/payment/providers.d.ts +13 -2
  25. package/dist/payment/razorpay.d.ts +12 -0
  26. package/dist/payment/razorpay.js +149 -0
  27. package/dist/payment/sslcommerz.js +1 -1
  28. package/dist/payment/stripe.d.ts +15 -0
  29. package/dist/payment/stripe.js +182 -0
  30. package/dist/permissions/admin.d.ts +3 -0
  31. package/dist/permissions/admin.js +108 -10
  32. package/dist/permissions/index.d.ts +1 -0
  33. package/dist/permissions/index.js +3 -0
  34. package/dist/permissions/roles.d.ts +2 -0
  35. package/dist/permissions/roles.js +236 -0
  36. package/dist/portal/.kosh/bindings.js +1 -1
  37. package/dist/portal/.kosh/schema.d.ts +29 -1
  38. package/dist/product/.kosh/bindings.js +1 -1
  39. package/dist/product/.kosh/schema.d.ts +29 -1
  40. package/dist/project/.kosh/bindings.js +1 -1
  41. package/dist/project/.kosh/schema.d.ts +29 -1
  42. package/dist/project_portal/.kosh/bindings.js +1 -1
  43. package/dist/project_portal/.kosh/schema.d.ts +29 -1
  44. package/dist/purchase/.kosh/bindings.js +1 -1
  45. package/dist/purchase/.kosh/schema.d.ts +29 -1
  46. package/dist/purchase_account/.kosh/bindings.js +1 -1
  47. package/dist/purchase_account/.kosh/schema.d.ts +29 -1
  48. package/dist/purchase_portal/.kosh/bindings.js +1 -1
  49. package/dist/purchase_portal/.kosh/schema.d.ts +29 -1
  50. package/dist/sale_account/.kosh/bindings.js +1 -1
  51. package/dist/sale_account/.kosh/schema.d.ts +29 -1
  52. package/dist/sale_portal/.kosh/bindings.js +1 -1
  53. package/dist/sale_portal/.kosh/schema.d.ts +29 -1
  54. package/dist/sale_project/.kosh/bindings.js +1 -1
  55. package/dist/sale_project/.kosh/schema.d.ts +29 -1
  56. package/dist/sales/.kosh/bindings.js +1 -1
  57. package/dist/sales/.kosh/schema.d.ts +29 -1
  58. package/dist/server/auth/base.js +22 -0
  59. package/dist/server/auth/routes.d.ts +3 -0
  60. package/dist/server/auth/routes.js +29 -0
  61. package/dist/server/auth/service.d.ts +52 -0
  62. package/dist/server/auth/service.js +168 -3
  63. package/dist/server/identity.d.ts +2 -0
  64. package/dist/server/index.d.ts +1 -1
  65. package/dist/server/server.js +11 -3
  66. package/dist/settings/company.d.ts +34 -0
  67. package/dist/settings/company.js +187 -0
  68. package/dist/settings/index.d.ts +10 -10
  69. package/dist/settings/index.js +159 -30
  70. package/dist/settings/people.d.ts +16 -0
  71. package/dist/settings/people.js +505 -0
  72. package/dist/settings/security.d.ts +13 -0
  73. package/dist/settings/security.js +158 -0
  74. package/dist/settings/web/AuditPage.d.ts +8 -0
  75. package/dist/settings/web/AuditPage.js +74 -0
  76. package/dist/settings/web/CompaniesPage.d.ts +8 -0
  77. package/dist/settings/web/CompaniesPage.js +52 -0
  78. package/dist/settings/web/CompanyPage.d.ts +9 -0
  79. package/dist/settings/web/CompanyPage.js +94 -0
  80. package/dist/settings/web/OverviewPage.d.ts +9 -0
  81. package/dist/settings/web/OverviewPage.js +66 -0
  82. package/dist/settings/web/PeoplePage.d.ts +9 -0
  83. package/dist/settings/web/PeoplePage.js +163 -0
  84. package/dist/settings/web/ProfilePage.d.ts +14 -0
  85. package/dist/settings/web/ProfilePage.js +194 -0
  86. package/dist/settings/web/RolesEditor.d.ts +35 -0
  87. package/dist/settings/web/RolesEditor.js +49 -0
  88. package/dist/settings/web/RolesPage.d.ts +9 -0
  89. package/dist/settings/web/RolesPage.js +296 -0
  90. package/dist/settings/web/SecurityPage.d.ts +8 -0
  91. package/dist/settings/web/SecurityPage.js +81 -0
  92. package/dist/settings/web/index.d.ts +9 -0
  93. package/dist/settings/web/index.js +26 -1
  94. package/dist/settings/web/messages.js +566 -0
  95. package/dist/ui/api.d.ts +4 -0
  96. package/dist/ui/app.d.ts +5 -0
  97. package/dist/ui/app.js +108 -31
  98. package/dist/ui/components/primitives.d.ts +9 -1
  99. package/dist/ui/components/primitives.js +12 -4
  100. package/dist/ui/context.d.ts +10 -1
  101. package/dist/ui/context.js +60 -2
  102. package/dist/ui/i18n/messages.d.ts +46 -0
  103. package/dist/ui/i18n/messages.js +92 -0
  104. package/dist/ui/index.d.ts +2 -1
  105. package/dist/ui/index.js +2 -1
  106. package/dist/ui/lib/accent.d.ts +13 -0
  107. package/dist/ui/lib/accent.js +36 -0
  108. package/dist/ui/shell/Activate.d.ts +6 -0
  109. package/dist/ui/shell/Activate.js +39 -0
  110. package/dist/ui/shell/CommandPalette.d.ts +7 -1
  111. package/dist/ui/shell/CommandPalette.js +16 -2
  112. package/dist/ui/shell/Sidebar.d.ts +27 -0
  113. package/dist/ui/shell/Sidebar.js +84 -0
  114. package/dist/ui/shell/TwoFactorSetup.d.ts +5 -0
  115. package/dist/ui/shell/TwoFactorSetup.js +32 -0
  116. package/dist/ui/shell/shortcuts.d.ts +3 -0
  117. package/dist/ui/shell/shortcuts.js +13 -4
  118. package/dist/ui/styles.css +70 -28
  119. package/dist/ui/views/DateField.d.ts +10 -0
  120. package/dist/ui/views/DateField.js +129 -0
  121. package/dist/ui/views/FormView.js +161 -21
  122. package/dist/ui/views/ListView.d.ts +2 -0
  123. package/dist/ui/views/ListView.js +8 -3
  124. package/dist/ui/views/StatusChip.d.ts +10 -0
  125. package/dist/ui/views/StatusChip.js +22 -0
  126. package/dist/ui/views/widgets.d.ts +3 -0
  127. package/dist/ui/views/widgets.js +26 -8
  128. package/dist/views/types.d.ts +7 -0
  129. package/dist/views/validate.js +4 -0
  130. package/package.json +2 -2
@@ -0,0 +1,15 @@
1
+ import type { PaymentAdapter } from './providers.js';
2
+ export interface StripeOptions {
3
+ readonly secretKey?: string | undefined;
4
+ /** The API's base (default `https://api.stripe.com/v1`). */
5
+ readonly baseUrl?: string | undefined;
6
+ /** How old a signed event may be, in seconds (default 300). */
7
+ readonly tolerance?: number;
8
+ /** The HTTP client (tests). */
9
+ readonly fetch?: typeof fetch;
10
+ /** The clock (tests). */
11
+ readonly now?: () => number;
12
+ }
13
+ export declare function stripeAdapter(options?: StripeOptions): PaymentAdapter;
14
+ /** Stripe with its key from the environment, read on first use. */
15
+ export declare function stripeFromEnvironment(env?: NodeJS.ProcessEnv): PaymentAdapter;
@@ -0,0 +1,182 @@
1
+ /**
2
+ * The Stripe adapter (RFC 0017 §5.5), on Checkout Sessions: a session per transaction (`client_reference_id` and
3
+ * `metadata.reference` = its reference, one line of the invoice's amount in subunits) whose page the customer pays
4
+ * on, back to the return route. Stripe's webhook is set up in its dashboard with the provider's endpoint URL; Stripe
5
+ * gives the signing secret (`whsec_…`), which goes into **the endpoint's Secret**. `Stripe-Signature`
6
+ * (`t=…,v1=…`: HMAC-SHA256 hex of `"<t>.<raw body>"`) is checked with it, and old timestamps are refused (5 minutes)
7
+ * against replays; the event's `id` makes deliveries idempotent. Returns fetch the session from Stripe.
8
+ *
9
+ * Credentials come from the environment, never from records: `SHUTRO_ERP_STRIPE_SECRET_KEY`. Test keys
10
+ * (`sk_test_…`, `rk_test_…`) serve providers in test mode, live keys live ones.
11
+ */
12
+ import { createHmac, timingSafeEqual } from 'node:crypto';
13
+ import { fromSubunits, toSubunits } from './money.js';
14
+ const text = (v) => (v === undefined || v === null ? '' : String(v));
15
+ /** What a Checkout Session says of its payment. */
16
+ function sessionResult(reference, session, kind) {
17
+ const theirs = text(session['client_reference_id']) ||
18
+ text(session['metadata']?.['reference']);
19
+ if (theirs !== reference)
20
+ return null;
21
+ const currency = text(session['currency']).toUpperCase();
22
+ const intent = session['payment_intent'];
23
+ const providerReference = (typeof intent === 'string' ? intent : text(intent?.['id'])) ||
24
+ text(session['id']) ||
25
+ null;
26
+ // A delayed method (a bank debit) that did not go through.
27
+ if (kind === 'checkout.session.async_payment_failed')
28
+ return {
29
+ reference,
30
+ status: 'failed',
31
+ providerReference,
32
+ error: 'The payment failed at Stripe.',
33
+ };
34
+ if (text(session['payment_status']) === 'paid')
35
+ return {
36
+ reference,
37
+ status: 'done',
38
+ providerReference,
39
+ amount: fromSubunits(Number(session['amount_total'] ?? 0), currency),
40
+ currency,
41
+ };
42
+ if (text(session['status']) === 'expired' || kind === 'checkout.session.expired')
43
+ return { reference, status: 'cancelled', providerReference };
44
+ return { reference, status: 'pending', providerReference };
45
+ }
46
+ /** Stripe's form encoding of nested parameters (`line_items[0][price_data][currency]=bdt`). */
47
+ function form(params, prefix = '', out = new URLSearchParams()) {
48
+ for (const [key, value] of Object.entries(params)) {
49
+ const name = prefix === '' ? key : `${prefix}[${key}]`;
50
+ if (value === undefined || value === null)
51
+ continue;
52
+ if (Array.isArray(value))
53
+ value.forEach((v, i) => {
54
+ if (typeof v === 'object' && v !== null)
55
+ form(v, `${name}[${i}]`, out);
56
+ else
57
+ out.append(`${name}[${i}]`, String(v));
58
+ });
59
+ else if (typeof value === 'object')
60
+ form(value, name, out);
61
+ else
62
+ out.append(name, String(value));
63
+ }
64
+ return out;
65
+ }
66
+ export function stripeAdapter(options = {}) {
67
+ const key = options.secretKey ?? '';
68
+ const base = (options.baseUrl || 'https://api.stripe.com/v1').replace(/\/$/, '');
69
+ const http = options.fetch ?? fetch;
70
+ const tolerance = options.tolerance ?? 300;
71
+ const now = options.now ?? (() => Date.now());
72
+ const ready = /^(sk|rk)_(test|live)_/.test(key);
73
+ const call = async (method, path, params) => {
74
+ if (!ready)
75
+ throw new Error('Stripe is not configured: set SHUTRO_ERP_STRIPE_SECRET_KEY.');
76
+ const res = await http(`${base}${path}`, {
77
+ method,
78
+ headers: {
79
+ authorization: `Bearer ${key}`,
80
+ 'content-type': 'application/x-www-form-urlencoded',
81
+ },
82
+ ...(params === undefined ? {} : { body: form(params) }),
83
+ signal: AbortSignal.timeout(30_000),
84
+ });
85
+ const answer = (await res.json().catch(() => ({})));
86
+ if (!res.ok) {
87
+ const error = (answer['error'] ?? {});
88
+ throw new Error(`Stripe answered ${res.status}: ${text(error['message']) || 'no detail'}.`);
89
+ }
90
+ return answer;
91
+ };
92
+ const session = (id) => call('GET', `/checkout/sessions/${encodeURIComponent(id)}`);
93
+ return {
94
+ code: 'stripe',
95
+ configured: (mode) => ready && key.includes(mode === 'test' ? '_test_' : '_live_'),
96
+ async createPayment(request) {
97
+ const made = await call('POST', '/checkout/sessions', {
98
+ mode: 'payment',
99
+ client_reference_id: request.reference,
100
+ success_url: request.returnUrl,
101
+ cancel_url: request.returnUrl,
102
+ ...(request.customer.email === null ? {} : { customer_email: request.customer.email }),
103
+ line_items: [
104
+ {
105
+ quantity: 1,
106
+ price_data: {
107
+ currency: request.currency.toLowerCase(),
108
+ unit_amount: toSubunits(request.amount, request.currency),
109
+ product_data: { name: request.description },
110
+ },
111
+ },
112
+ ],
113
+ metadata: { reference: request.reference },
114
+ payment_intent_data: { metadata: { reference: request.reference } },
115
+ });
116
+ const id = text(made['id']);
117
+ const page = text(made['url']);
118
+ if (id === '' || page === '')
119
+ throw new Error('Stripe made no Checkout Session.');
120
+ return { redirectUrl: page, providerReference: id };
121
+ },
122
+ verifyCallback(call, secret) {
123
+ const header = call.headers['stripe-signature'] ?? '';
124
+ const parts = header.split(',').map((p) => p.split('='));
125
+ const t = Number(parts.find(([k]) => k === 't')?.[1]);
126
+ if (!Number.isFinite(t) || Math.abs(now() / 1000 - t) > tolerance)
127
+ return null;
128
+ const expected = Buffer.from(createHmac('sha256', secret).update(`${t}.${call.body}`).digest('hex'));
129
+ const genuine = parts
130
+ .filter(([k]) => k === 'v1')
131
+ .some(([, v]) => {
132
+ const given = Buffer.from(v ?? '');
133
+ return given.length === expected.length && timingSafeEqual(given, expected);
134
+ });
135
+ if (!genuine)
136
+ return null;
137
+ const id = text(call.json?.['id']);
138
+ return id !== '' ? id : null;
139
+ },
140
+ readCallback(call) {
141
+ const event = call.json;
142
+ const kind = text(event?.['type']);
143
+ if (!kind.startsWith('checkout.session.'))
144
+ return null;
145
+ const object = (event?.['data']?.['object'] ?? null);
146
+ if (object === null)
147
+ return null;
148
+ const reference = text(object['client_reference_id']) ||
149
+ text(object['metadata']?.['reference']);
150
+ const result = sessionResult(reference, object, kind);
151
+ return result === null || result.status === 'pending' ? null : result;
152
+ },
153
+ async returned({ reference, providerReference }) {
154
+ if (providerReference === null || providerReference === '')
155
+ return null;
156
+ const result = sessionResult(reference, await session(providerReference));
157
+ return result?.status === 'pending' ? null : result;
158
+ },
159
+ async status(reference, _mode, providerReference) {
160
+ if (!ready || providerReference === null || providerReference === '')
161
+ return null;
162
+ return sessionResult(reference, await session(providerReference));
163
+ },
164
+ };
165
+ }
166
+ /** Stripe with its key from the environment, read on first use. */
167
+ export function stripeFromEnvironment(env = process.env) {
168
+ let made;
169
+ const real = () => (made ??= stripeAdapter({
170
+ secretKey: env['KOSH_STRIPE_SECRET_KEY'],
171
+ baseUrl: env['KOSH_STRIPE_BASE_URL'],
172
+ }));
173
+ return {
174
+ code: 'stripe',
175
+ configured: (mode) => real().configured(mode),
176
+ createPayment: (request) => real().createPayment(request),
177
+ verifyCallback: (call, secret) => real().verifyCallback(call, secret),
178
+ readCallback: (call) => real().readCallback(call),
179
+ returned: (input) => real().returned?.(input) ?? Promise.resolve(null),
180
+ status: (reference, mode, providerReference) => real().status(reference, mode, providerReference),
181
+ };
182
+ }
@@ -9,5 +9,8 @@
9
9
  * Approval policies are data: administrators edit `approval.policy` / `approval.level` through the generated API.
10
10
  */
11
11
  import { type ModuleContext } from '../server/index.js';
12
+ import type { Env } from '../server/orm.js';
13
+ /** Refuses non-administrators (or, with `permission`, users without it). */
14
+ export declare function requireAdministrator(env: Env, permission?: string): void;
12
15
  /** Registers `/api/v1/admin/*`. */
13
16
  export declare function registerAdminApi(context: ModuleContext): void;
@@ -43,7 +43,7 @@ function idParam(request, key = 'id') {
43
43
  return id;
44
44
  }
45
45
  /** Refuses non-administrators (or, with `permission`, users without it). */
46
- function requireAdministrator(env, permission) {
46
+ export function requireAdministrator(env, permission) {
47
47
  if (env.administrator)
48
48
  return;
49
49
  if (permission !== undefined && env.access.has(permission))
@@ -99,6 +99,10 @@ export function registerAdminApi(context) {
99
99
  params: [],
100
100
  });
101
101
  const admins = env.access.administratorGroups();
102
+ // How many people hold each role (administrators read the assignments).
103
+ const holders = new Map();
104
+ for (const a of await find(env, 'auth.user_group', {}, ['user', 'group']))
105
+ holders.set(String(a['group']), (holders.get(String(a['group'])) ?? new Set()).add(a['user']));
102
106
  const levelsOf = (implies) => {
103
107
  const closure = reg.security.closure(implies.filter((c) => reg.security.groups.has(c)));
104
108
  return [...loaded.accessLevels.entries()]
@@ -114,6 +118,7 @@ export function registerAdminApi(context) {
114
118
  administrator: admins.has(g.code),
115
119
  builtIn: true,
116
120
  accessLevels: levelsOf(g.implies),
121
+ holders: holders.get(g.code)?.size ?? 0,
117
122
  })),
118
123
  ...runtime.map((r) => ({
119
124
  code: r.code,
@@ -122,6 +127,7 @@ export function registerAdminApi(context) {
122
127
  administrator: admins.has(r.code),
123
128
  builtIn: false,
124
129
  accessLevels: levelsOf(r.implies),
130
+ holders: holders.get(r.code)?.size ?? 0,
125
131
  })),
126
132
  ],
127
133
  };
@@ -342,6 +348,7 @@ export function registerAdminApi(context) {
342
348
  models: Object.fromEntries(models.map((name) => [
343
349
  name,
344
350
  {
351
+ label: reg.get(name).model.label ?? name,
345
352
  perms: PERMS.filter((p) => env.access.can(name, p)),
346
353
  hiddenFields: Object.keys(reg.get(name).model.fields).filter((k) => !env.access.canField(name, k, 'read')),
347
354
  readonlyFields: Object.keys(reg.get(name).model.fields).filter((k) => env.access.canField(name, k, 'read') &&
@@ -351,25 +358,67 @@ export function registerAdminApi(context) {
351
358
  permissions: [...reg.security.permissions.keys()]
352
359
  .filter((code) => env.access.has(code))
353
360
  .sort(),
361
+ /** The permissions' names, for screens. */
362
+ permissionLabels: Object.fromEntries([...reg.security.permissions.values()].map((p) => [p.code, p.label ?? p.code])),
354
363
  };
355
364
  });
356
365
  });
357
366
  const hidden = new Set(loaded.modules.flatMap((m) => m.privateModels ?? []));
358
367
  const privateModel = (name) => hidden.has(name);
359
368
  // ------------------------------------------------------------------------------------------- audit log
360
- app.get('/api/v1/admin/audit', async (request) => {
369
+ // Filters (RFC 0019 §3.5): `kind` (`auth.` for a family), `actor` (a user id), `model` and `record` (the target),
370
+ // `from` / `to` (ISO dates, `to` inclusive of its day), `q` (words of the summary), `before` (an id: the next page);
371
+ // `format=csv` downloads the page as CSV — an export, itself audited.
372
+ app.get('/api/v1/admin/audit', async (request, reply) => {
361
373
  const q = (request.query ?? {});
362
- const limit = q.limit === undefined ? 100 : Number(q.limit);
374
+ const limit = q['limit'] === undefined ? 100 : Number(q['limit']);
363
375
  if (!Number.isInteger(limit) || limit < 1 || limit > 1000)
364
376
  throw bad('limit must be 1–1000.');
365
- return request.env(async (env) => {
377
+ const where = {};
378
+ const text = (key) => typeof q[key] === 'string' && q[key] !== '' ? q[key] : undefined;
379
+ const id = (key) => {
380
+ const v = text(key);
381
+ if (v === undefined)
382
+ return undefined;
383
+ const n = Number(v);
384
+ if (!Number.isSafeInteger(n) || n <= 0)
385
+ throw bad(`${key} is a positive integer.`);
386
+ return n;
387
+ };
388
+ const date = (key) => {
389
+ const v = text(key);
390
+ if (v === undefined)
391
+ return undefined;
392
+ const d = new Date(v);
393
+ if (Number.isNaN(d.getTime()))
394
+ throw bad(`${key} is an ISO date.`);
395
+ return d;
396
+ };
397
+ const kind = text('kind');
398
+ if (kind !== undefined)
399
+ where['kind'] = kind.endsWith('.') ? { like: `${kind}%` } : kind;
400
+ if (id('actor') !== undefined)
401
+ where['actor'] = id('actor');
402
+ if (text('model') !== undefined)
403
+ where['targetModel'] = text('model');
404
+ if (id('record') !== undefined)
405
+ where['targetId'] = id('record');
406
+ if (text('q') !== undefined)
407
+ where['summary'] = { ilike: `%${text('q')}%` };
408
+ const from = date('from');
409
+ const to = date('to');
410
+ if (from !== undefined || to !== undefined)
411
+ where['createdAt'] = {
412
+ ...(from === undefined ? {} : { gte: from }),
413
+ // `to` is a day: everything before the next one.
414
+ ...(to === undefined ? {} : { lt: new Date(to.getTime() + 86_400_000) }),
415
+ };
416
+ if (id('before') !== undefined)
417
+ where['id'] = { lt: id('before') };
418
+ const csv = q['format'] === 'csv';
419
+ const events = await request.env(async (env) => {
366
420
  requireAdministrator(env, 'permissions.view_audit');
367
421
  return env.asSuperuser({ reason: 'read audit log' }, async (su) => {
368
- const where = {};
369
- if (typeof q.kind === 'string' && q.kind !== '')
370
- where['kind'] = q.kind.endsWith('.') ? { like: `${q.kind}%` } : q.kind;
371
- if (q.before !== undefined)
372
- where['id'] = { lt: Number(q.before) };
373
422
  const set = await ref(su, 'audit.event').search({ where, order: ['id desc'], limit });
374
423
  const keys = [
375
424
  'kind',
@@ -382,8 +431,57 @@ export function registerAdminApi(context) {
382
431
  'ip',
383
432
  'createdAt',
384
433
  ];
385
- return { events: set.length === 0 ? [] : (await set.load(keys)).read(keys) };
434
+ const found = set.length === 0 ? [] : (await set.load(keys)).read(['id', ...keys]);
435
+ const actors = [
436
+ ...new Set(found.map((e) => e['actor']).filter((a) => typeof a === 'number')),
437
+ ];
438
+ const names = new Map(actors.length === 0
439
+ ? []
440
+ : (await // Deactivated people keep their name in the log.
441
+ (await ref(su.withFlags({ activeTest: false }), 'res.users').search({
442
+ where: { id: { in: actors } },
443
+ })).load(['name']))
444
+ .read(['id', 'name'])
445
+ .map((u) => [u['id'], String(u['name'])]));
446
+ if (csv)
447
+ await audit(env, {
448
+ kind: 'data.export',
449
+ summary: `Exported ${found.length} audit event(s).`,
450
+ targetModel: 'audit.event',
451
+ details: { filters: where },
452
+ ip: request.ip,
453
+ });
454
+ return found.map((e) => ({
455
+ ...e,
456
+ actorName: typeof e['actor'] === 'number' ? (names.get(e['actor']) ?? null) : null,
457
+ }));
386
458
  });
387
459
  }, { write: true }); // the system read of the log is itself audited
460
+ if (!csv)
461
+ return { events };
462
+ const cell = (v) => {
463
+ const s = v === null || v === undefined ? '' : v instanceof Date ? v.toISOString() : String(v);
464
+ // Quoted, and never read as a formula by a spreadsheet.
465
+ return `"${(/^[=+\-@]/.test(s) ? `'${s}` : s).replaceAll('"', '""')}"`;
466
+ };
467
+ const columns = [
468
+ 'id',
469
+ 'createdAt',
470
+ 'kind',
471
+ 'actorName',
472
+ 'summary',
473
+ 'targetModel',
474
+ 'targetId',
475
+ 'ip',
476
+ ];
477
+ const lines = [
478
+ columns.join(','),
479
+ ...events.map((e) => columns.map((c) => cell(e[c])).join(',')),
480
+ ];
481
+ // A byte-order mark so spreadsheets read UTF-8 (Bangla names).
482
+ return reply
483
+ .header('content-type', 'text/csv; charset=utf-8')
484
+ .header('content-disposition', 'attachment; filename="audit-log.csv"')
485
+ .send(`${lines.join('\r\n')}\r\n`);
388
486
  });
389
487
  }
@@ -8,6 +8,7 @@ import { defineModule } from '../modules-sdk/index.js';
8
8
  export { registerAdminApi } from './admin.js';
9
9
  export { type ApprovalRequestInfo, approvalGuard, approvalState, decide, inbox, installApprovalPolicies, requestApprovals, requestsOf, } from './approvals.js';
10
10
  export { permissionsShutro } from './models.js';
11
+ export { registerRoleRoutes } from './roles.js';
11
12
  export { registerApprovalRoutes } from './routes.js';
12
13
  /** Package identity. */
13
14
  export declare const PACKAGE: {
@@ -9,10 +9,12 @@ import { base } from '../server/index.js';
9
9
  import { registerAdminApi } from './admin.js';
10
10
  import { installApprovalPolicies } from './approvals.js';
11
11
  import { permissionsShutro } from './models.js';
12
+ import { registerRoleRoutes } from './roles.js';
12
13
  import { registerApprovalRoutes } from './routes.js';
13
14
  export { registerAdminApi } from './admin.js';
14
15
  export { approvalGuard, approvalState, decide, inbox, installApprovalPolicies, requestApprovals, requestsOf, } from './approvals.js';
15
16
  export { permissionsShutro } from './models.js';
17
+ export { registerRoleRoutes } from './roles.js';
16
18
  export { registerApprovalRoutes } from './routes.js';
17
19
  /** Package identity. */
18
20
  export const PACKAGE = Object.freeze({
@@ -36,6 +38,7 @@ export const permissions = defineModule({
36
38
  },
37
39
  routes: (context) => {
38
40
  registerAdminApi(context);
41
+ registerRoleRoutes(context);
39
42
  registerApprovalRoutes(context);
40
43
  },
41
44
  });
@@ -0,0 +1,2 @@
1
+ import { type ModuleContext } from '../server/index.js';
2
+ export declare function registerRoleRoutes(context: ModuleContext): void;
@@ -0,0 +1,236 @@
1
+ import { HttpProblem } from '../server/index.js';
2
+ import { requireAdministrator } from './admin.js';
3
+ const PERMS = ['read', 'write', 'create', 'unlink', 'export', 'import'];
4
+ const find = async (env, model, where, keys) => {
5
+ const set = (await env.model(model).search({ where, order: ['id asc'] }));
6
+ return set.length === 0 ? [] : (await set.load(keys)).read(['id', ...keys]);
7
+ };
8
+ const bad = (detail) => new HttpProblem(400, 'invalid-body', 'Invalid request', detail);
9
+ export function registerRoleRoutes(context) {
10
+ const { app, registry } = context;
11
+ const reg = registry.registry;
12
+ const loaded = registry.loaded;
13
+ const modules = loaded.modules;
14
+ const hidden = new Set(modules.flatMap((m) => m.privateModels ?? []));
15
+ const appOf = new Map(modules.map((m) => [m.name, m.app?.label ?? null]));
16
+ /** Every group a set of roles amounts to: declared groups' closure, runtime roles followed through. */
17
+ const closureOf = (codes, runtime) => {
18
+ const roles = new Map(runtime.roles.map((r) => [r.code, r]));
19
+ const seen = new Set();
20
+ const queue = [...codes];
21
+ while (queue.length > 0) {
22
+ const code = queue.pop();
23
+ if (seen.has(code))
24
+ continue;
25
+ seen.add(code);
26
+ for (const next of roles.get(code)?.implies ?? [])
27
+ queue.push(next);
28
+ }
29
+ return new Set([
30
+ ...seen,
31
+ ...reg.security.closure([...seen].filter((c) => reg.security.groups.has(c))),
32
+ ]);
33
+ };
34
+ /** What a set of groups grants: per model the permissions, and the feature permissions. */
35
+ const grants = (groups, runtime, except) => {
36
+ const models = new Map();
37
+ for (const [name, model] of reg.models) {
38
+ const access = (model.model.access ?? {});
39
+ const perms = new Set();
40
+ for (const g of groups)
41
+ for (const p of access[g] ?? [])
42
+ perms.add(p);
43
+ if (perms.size > 0)
44
+ models.set(name, perms);
45
+ }
46
+ for (const a of runtime.access)
47
+ if (groups.has(a.role) && a.role !== except)
48
+ for (const p of a.perms) {
49
+ const set = models.get(a.model) ?? new Set();
50
+ set.add(p);
51
+ models.set(a.model, set);
52
+ }
53
+ const permissions = new Set();
54
+ for (const p of reg.security.permissions.values())
55
+ if (p.groups.some((g) => groups.has(g)))
56
+ permissions.add(p.code);
57
+ for (const g of runtime.permissions)
58
+ if (groups.has(g.role) && g.role !== except)
59
+ permissions.add(g.permission);
60
+ const levels = new Set([...loaded.accessLevels.entries()]
61
+ .filter(([, level]) => level.groups.length > 0 && level.groups.every((g) => groups.has(g)))
62
+ .map(([code]) => code));
63
+ return { models, permissions, levels };
64
+ };
65
+ const levelLabel = (code) => {
66
+ const level = loaded.accessLevels.get(code);
67
+ return level === undefined
68
+ ? code
69
+ : `${appOf.get(level.module) ?? level.module} · ${level.label}`;
70
+ };
71
+ const permissionLabel = (code) => reg.security.permissions.get(code)?.label ?? code;
72
+ /** The role, whether declared in code or made at runtime. */
73
+ const roleOf = (code, runtime) => {
74
+ const declared = reg.security.groups.get(code);
75
+ if (declared !== undefined)
76
+ return {
77
+ code,
78
+ label: declared.label ?? code,
79
+ implies: [...(declared.implies ?? [])],
80
+ builtIn: true,
81
+ };
82
+ const made = runtime.roles.find((r) => r.code === code);
83
+ if (made === undefined)
84
+ throw new HttpProblem(404, 'missing-record', 'Not found', `There is no role '${code}'.`);
85
+ return { code, label: made.label, implies: [...made.implies], builtIn: false };
86
+ };
87
+ const holdersOf = async (env, code) => env.asSuperuser({ reason: 'role holders' }, async (su) => {
88
+ const assigned = await find(su, 'auth.user_group', { group: code }, ['user']);
89
+ const ids = [...new Set(assigned.map((a) => a['user']))];
90
+ if (ids.length === 0)
91
+ return [];
92
+ return (await find(su, 'res.users', { id: { in: ids } }, ['name', 'login'])).map((u) => ({
93
+ id: u['id'],
94
+ name: String(u['name']),
95
+ login: String(u['login']),
96
+ }));
97
+ });
98
+ const codeParam = (request) => String(request.params['code'] ?? '');
99
+ app.get('/api/v1/admin/roles/:code', async (request) => {
100
+ const code = codeParam(request);
101
+ return request.env(async (env) => {
102
+ requireAdministrator(env);
103
+ const runtime = await env.securityAdmin.describe();
104
+ const role = roleOf(code, runtime);
105
+ const closure = closureOf([code], runtime);
106
+ // Locked: what code and the included roles give; own: this role's runtime grants.
107
+ const locked = grants(closure, runtime, code);
108
+ const own = new Map(runtime.access.filter((a) => a.role === code).map((a) => [a.model, [...a.perms]]));
109
+ const ownPermissions = runtime.permissions
110
+ .filter((p) => p.role === code)
111
+ .map((p) => p.permission);
112
+ return {
113
+ ...role,
114
+ administrator: env.access.administratorGroups().has(code),
115
+ accessLevels: [...grants(closureOf(role.implies, runtime), runtime).levels],
116
+ holders: await holdersOf(env, code),
117
+ models: [...reg.models]
118
+ .filter(([name]) => !hidden.has(name) && !name.startsWith('shutro.'))
119
+ .map(([name, model]) => ({
120
+ name,
121
+ label: model.model.label ?? name,
122
+ app: appOf.get(model.module) ?? null,
123
+ locked: [...(locked.models.get(name) ?? [])],
124
+ own: own.get(name) ?? [],
125
+ }))
126
+ // Apps first (by name), technical models last.
127
+ .sort((a, b) => Number(a.app === null) - Number(b.app === null) ||
128
+ (a.app ?? '').localeCompare(b.app ?? '') ||
129
+ a.label.localeCompare(b.label)),
130
+ permissions: [...reg.security.permissions.values()].map((p) => ({
131
+ code: p.code,
132
+ label: p.label ?? p.code,
133
+ locked: locked.permissions.has(p.code),
134
+ own: ownPermissions.includes(p.code),
135
+ })),
136
+ rules: runtime.rules.filter((r) => r.role === code),
137
+ fields: runtime.fields.filter((f) => f.read.includes(code) || f.write.includes(code)),
138
+ };
139
+ }, { write: true });
140
+ });
141
+ app.post('/api/v1/admin/roles/:code/preview', async (request) => {
142
+ const code = codeParam(request);
143
+ const body = (request.body ?? {});
144
+ const list = (v, what) => {
145
+ if (v === undefined)
146
+ return undefined;
147
+ if (!Array.isArray(v) || v.some((x) => typeof x !== 'string'))
148
+ throw bad(`${what} is a list of codes.`);
149
+ return v;
150
+ };
151
+ const levels = list(body['accessLevels'], 'accessLevels');
152
+ const implies = list(body['implies'], 'implies');
153
+ const permissions = list(body['permissions'], 'permissions');
154
+ const access = body['access'];
155
+ if (access !== undefined &&
156
+ (typeof access !== 'object' || access === null || Array.isArray(access)))
157
+ throw bad('access is { model: [permissions] }.');
158
+ return request.env(async (env) => {
159
+ requireAdministrator(env);
160
+ const runtime = await env.securityAdmin.describe();
161
+ const role = roleOf(code, runtime);
162
+ // The role's proposed state, as a runtime overlay of itself.
163
+ const levelGroups = (levels ?? []).flatMap((l) => {
164
+ const level = loaded.accessLevels.get(l);
165
+ if (level === undefined)
166
+ throw bad(`'${l}' is not an access level.`);
167
+ return [...level.groups];
168
+ });
169
+ const proposedImplies = levels === undefined && implies === undefined
170
+ ? role.implies
171
+ : [...new Set([...levelGroups, ...(implies ?? [])])];
172
+ const ownAccess = (access ??
173
+ Object.fromEntries(runtime.access.filter((a) => a.role === code).map((a) => [a.model, a.perms])));
174
+ const proposed = {
175
+ ...runtime,
176
+ roles: role.builtIn
177
+ ? runtime.roles
178
+ : runtime.roles.map((r) => (r.code === code ? { ...r, implies: proposedImplies } : r)),
179
+ access: [
180
+ ...runtime.access.filter((a) => a.role !== code),
181
+ ...Object.entries(ownAccess).map(([model, perms]) => ({
182
+ role: code,
183
+ model,
184
+ perms: perms,
185
+ })),
186
+ ],
187
+ permissions: permissions === undefined
188
+ ? runtime.permissions
189
+ : [
190
+ ...runtime.permissions.filter((p) => p.role !== code),
191
+ ...permissions.map((permission) => ({ role: code, permission })),
192
+ ],
193
+ };
194
+ const before = grants(closureOf([code], runtime), runtime);
195
+ const after = grants(closureOf([code], proposed), proposed);
196
+ const holders = await holdersOf(env, code);
197
+ const others = await env.asSuperuser({ reason: 'role preview' }, (su) => find(su, 'auth.user_group', { user: { in: holders.map((h) => h.id) } }, [
198
+ 'user',
199
+ 'group',
200
+ ]));
201
+ const people = holders
202
+ .map((h) => {
203
+ const elsewhere = grants(closureOf(others
204
+ .filter((o) => o['user'] === h.id && o['group'] !== code)
205
+ .map((o) => String(o['group'])), runtime), runtime);
206
+ const diff = (from, to, kept) => [...to].filter((x) => !from.has(x) && !kept.has(x));
207
+ return {
208
+ id: h.id,
209
+ name: h.name,
210
+ gains: [
211
+ ...diff(before.levels, after.levels, elsewhere.levels).map(levelLabel),
212
+ ...diff(before.permissions, after.permissions, elsewhere.permissions).map(permissionLabel),
213
+ ],
214
+ losses: [
215
+ ...diff(after.levels, before.levels, elsewhere.levels).map(levelLabel),
216
+ ...diff(after.permissions, before.permissions, elsewhere.permissions).map(permissionLabel),
217
+ ],
218
+ };
219
+ })
220
+ .filter((p) => p.gains.length > 0 || p.losses.length > 0);
221
+ const models = [...new Set([...before.models.keys(), ...after.models.keys()])]
222
+ .map((name) => {
223
+ const was = before.models.get(name) ?? new Set();
224
+ const will = after.models.get(name) ?? new Set();
225
+ return {
226
+ model: name,
227
+ label: reg.models.get(name)?.model?.label ?? name,
228
+ added: PERMS.filter((p) => will.has(p) && !was.has(p)),
229
+ removed: PERMS.filter((p) => was.has(p) && !will.has(p)),
230
+ };
231
+ })
232
+ .filter((m) => m.added.length > 0 || m.removed.length > 0);
233
+ return { holders: holders.length, people, models };
234
+ }, { write: true });
235
+ });
236
+ }
@@ -1,4 +1,4 @@
1
- // GENERATED by `pnpm generate:models` (Shutro’s generator). Do not edit. definitions-hash: bae647ca883975a1
1
+ // GENERATED by `pnpm generate:models` (Shutro’s generator). Do not edit. definitions-hash: f41721dc1c51ba8a
2
2
  // Bindings of module `portal`: import the definition API from here to get types scoped to what it can see.
3
3
  export { defineModel, defineModule, extendModel, f, replace } from '../../server/orm.js';
4
4
  import { definitions } from '../../server/orm.js';