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,505 @@
1
+ /**
2
+ * People (RFC 0019 §3.2): the installation's users — not portal users or service accounts — with their roles per
3
+ * company, invited by an expiring link (emailed when mail is set up, always returned to copy), deactivated and
4
+ * reactivated (sessions revoked at once), their two-factor reset. Administrators and People managers
5
+ * (`settings.people`) use it; People managers cannot grant administrator roles, change their own roles, or act on
6
+ * administrators. Shutro keeps the last active administrator (a model constraint); everything is audited.
7
+ *
8
+ * - `GET /api/v1/settings/people?status=&q=` the list
9
+ * - `GET /api/v1/settings/people/catalogue` apps with their access levels, roles, companies
10
+ * - `GET /api/v1/settings/people/:id` a person: details, roles, sign-in state, recent activity
11
+ * - `POST /api/v1/settings/people` `{ name, email, assignments }`: an invitation
12
+ * - `PATCH /api/v1/settings/people/:id` `{ name?, email?, active? }`
13
+ * - `PUT /api/v1/settings/people/:id/roles` `{ assignments: [{ group, company }] }` (replaces them)
14
+ * - `POST /api/v1/settings/people/:id/invitation` a new link (earlier ones stop working)
15
+ * - `POST /api/v1/settings/people/:id/reset-2fa` two-factor off, sessions revoked
16
+ * - `POST /api/v1/auth/activate` `{ token, password }`: the invited person's password
17
+ */
18
+ import { createHash, randomBytes } from 'node:crypto';
19
+ import { layout } from '../mail/index.js';
20
+ import { audit, HttpProblem, isAdministratorUser } from '../server/index.js';
21
+ /** How long an invitation works. */
22
+ export const INVITATION_DAYS = 7;
23
+ const ref = (env, name) => env.model(name);
24
+ const rows = async (set, keys) => {
25
+ const s = await set;
26
+ return s.length === 0 ? [] : (await s.load(keys)).read(['id', ...keys]);
27
+ };
28
+ const digest = (token) => createHash('sha256').update(token).digest('hex');
29
+ const EMAIL = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
30
+ const iso = (v) => v === null || v === undefined ? null : new Date(v instanceof Date ? v : String(v)).toISOString();
31
+ export function registerPeopleRoutes(context, options) {
32
+ const { app, registry } = context;
33
+ const auth = context.auth;
34
+ if (auth === undefined)
35
+ return;
36
+ const modules = registry.loaded.modules;
37
+ /** Groups that make a user external (portal users): not people of the installation. */
38
+ const external = new Set(modules.flatMap((m) => m.externalGroups ?? []));
39
+ const bad = (detail) => new HttpProblem(422, 'invalid-input', 'Invalid input', detail);
40
+ const idOf = (request) => {
41
+ const id = Number(request.params.id);
42
+ if (!Number.isSafeInteger(id) || id <= 0)
43
+ throw new HttpProblem(400, 'invalid-id', 'Invalid id', 'The id is a positive integer.');
44
+ return id;
45
+ };
46
+ /** Administrators and People managers; runs `fn` as the system, audited by Shutro. */
47
+ const guarded = (request, fn) => request.env(async (env) => {
48
+ if (!env.administrator && !env.groups.has('settings.people'))
49
+ throw new HttpProblem(403, 'access', 'People managers only', 'Managing people needs the Administrator role or the Settings / People manager access.');
50
+ return env.asSuperuser({ reason: 'people' }, async (su) => {
51
+ const out = await fn(env, su, { administrator: env.administrator });
52
+ await su.flush();
53
+ return out;
54
+ });
55
+ }, { write: true });
56
+ /** Administrators only act on administrators. */
57
+ const mayTouch = async (su, manager, user) => {
58
+ if (!manager.administrator && (await isAdministratorUser(su, user)))
59
+ throw new HttpProblem(403, 'access', 'Administrators only', 'Only an administrator changes an administrator’s account.');
60
+ };
61
+ const adminGroups = (su) => new Set(su.access.administratorGroups());
62
+ /** Roles a person may be given: every declared group and runtime role. */
63
+ const knownGroups = async () => {
64
+ const out = new Map();
65
+ for (const [code, g] of registry.registry.security.groups)
66
+ out.set(code, g.label ?? code);
67
+ // Runtime roles (the permissions app's), when that table exists: a failed query would abort the transaction.
68
+ const [table] = await context.driver.query({
69
+ text: "SELECT to_regclass('shutro_role') IS NOT NULL AS found",
70
+ params: [],
71
+ });
72
+ const runtime = table?.found === true
73
+ ? await context.driver.query({
74
+ text: 'SELECT code, label FROM shutro_role',
75
+ params: [],
76
+ })
77
+ : [];
78
+ for (const r of runtime)
79
+ out.set(r.code, r.label);
80
+ return out;
81
+ };
82
+ const checkAssignments = async (su, input, manager) => {
83
+ if (!Array.isArray(input))
84
+ throw bad('assignments is a list of { group, company }.');
85
+ const known = await knownGroups();
86
+ const admin = adminGroups(su);
87
+ const companies = new Set((await rows(ref(su, 'res.company').search({ where: {} }), ['name'])).map((c) => c['id']));
88
+ const out = [];
89
+ for (const a of input) {
90
+ const group = String(a?.['group'] ?? '');
91
+ const company = a?.['company'] === null || a?.['company'] === undefined ? null : Number(a['company']);
92
+ if (!known.has(group) || external.has(group))
93
+ throw bad(`'${group}' is not a role here.`);
94
+ if (company !== null && !companies.has(company))
95
+ throw bad(`There is no company ${company}.`);
96
+ if (admin.has(group) && !manager.administrator)
97
+ throw new HttpProblem(403, 'access', 'Administrators only', 'Only an administrator gives the Administrator role.');
98
+ if (!out.some((x) => x.group === group && x.company === company))
99
+ out.push({ group, company });
100
+ }
101
+ return out;
102
+ };
103
+ /** A new invitation for `user` (earlier unused ones expire); its link. */
104
+ const invite = async (su, user, by) => {
105
+ const pending = await ref(su, 'settings.invitation').search({ where: { user, usedAt: null } });
106
+ if (pending.length > 0)
107
+ await pending.write({ expiresAt: new Date() });
108
+ const token = randomBytes(32).toString('base64url');
109
+ const expiresAt = new Date(Date.now() + INVITATION_DAYS * 86_400_000);
110
+ await ref(su, 'settings.invitation').create([
111
+ { user, tokenHash: digest(token), expiresAt, invitedBy: by },
112
+ ]);
113
+ return {
114
+ url: `${options.baseUrl.replace(/\/$/, '')}/activate?token=${token}`,
115
+ expiresAt: expiresAt.toISOString(),
116
+ };
117
+ };
118
+ /** Emails the link after the commit (a failed send is logged; the inviter has the link). */
119
+ const email = (env, to, name, company, url) => {
120
+ const mailer = options.mailer;
121
+ if (mailer === undefined)
122
+ return false;
123
+ env.afterCommit(async () => {
124
+ const body = layout({
125
+ title: `Join ${company} on Shutro ERP`,
126
+ paragraphs: [
127
+ `Hello ${name},`,
128
+ `You are invited to work with ${company} on Shutro ERP. Choose your password to start.`,
129
+ `The link works for ${INVITATION_DAYS} days.`,
130
+ ],
131
+ action: { label: 'Choose your password', url },
132
+ });
133
+ await mailer
134
+ .send({ to, subject: `Join ${company} on Shutro ERP`, ...body })
135
+ .catch((error) => app.log.error({ err: error }, 'invitation not sent'));
136
+ });
137
+ return true;
138
+ };
139
+ const companyName = async (su, id) => id === null
140
+ ? 'your company'
141
+ : String((await rows(ref(su, 'res.company').browse([id]), ['name']))[0]?.['name'] ?? '');
142
+ /** The people's state: groups per user, credentials, last sign-in, pending invitations. */
143
+ const describe = async (su, users) => {
144
+ const ids = users.map((u) => u['id']);
145
+ if (ids.length === 0)
146
+ return [];
147
+ const groups = await rows(ref(su, 'auth.user_group').search({ where: { user: { in: ids } } }), [
148
+ 'user',
149
+ 'group',
150
+ 'company',
151
+ ]);
152
+ const credentials = await rows(ref(su, 'auth.credential').search({ where: { user: { in: ids } } }), ['user', 'totpEnabled']);
153
+ const seen = await rows(ref(su, 'auth.session').search({ where: { user: { in: ids } } }), [
154
+ 'user',
155
+ 'lastSeenAt',
156
+ ]);
157
+ const sessions = ids.map((user) => ({
158
+ user,
159
+ last: seen
160
+ .filter((r) => r['user'] === user)
161
+ .map((r) => new Date(String(r['lastSeenAt'])))
162
+ .sort((a, b) => b.getTime() - a.getTime())[0] ?? null,
163
+ }));
164
+ const invitations = await rows(ref(su, 'settings.invitation').search({ where: { user: { in: ids }, usedAt: null } }), ['user', 'expiresAt']);
165
+ const known = await knownGroups();
166
+ const admin = adminGroups(su);
167
+ // "Purchase · Manager": a group named by the app level that gives it, else by its own label.
168
+ const levelLabel = new Map();
169
+ for (const m of modules)
170
+ for (const level of Object.values(m.accessLevels ?? {}))
171
+ for (const g of level.groups)
172
+ if (!levelLabel.has(g) && m.app !== undefined)
173
+ levelLabel.set(g, `${m.app.label} · ${level.label}`);
174
+ return users
175
+ .map((u) => {
176
+ const id = u['id'];
177
+ const mine = groups.filter((g) => g['user'] === id);
178
+ const open = invitations
179
+ .filter((i) => i['user'] === id && new Date(String(i['expiresAt'])).getTime() > Date.now())
180
+ .map((i) => iso(i['expiresAt']));
181
+ const signedIn = sessions.find((s) => s.user === id)?.last ?? null;
182
+ return {
183
+ id,
184
+ name: String(u['name'] ?? ''),
185
+ login: String(u['login'] ?? ''),
186
+ email: typeof u['email'] === 'string' ? u['email'] : null,
187
+ active: u['active'] !== false,
188
+ state: u['active'] === false
189
+ ? 'deactivated'
190
+ : open.length > 0 && signedIn === null
191
+ ? 'invited'
192
+ : 'active',
193
+ invitationExpiresAt: open[0] ?? null,
194
+ twoFactor: credentials.some((c) => c['user'] === id && c['totpEnabled'] === true),
195
+ lastSeenAt: iso(signedIn),
196
+ administrator: mine.some((g) => admin.has(String(g['group']))),
197
+ assignments: mine.map((g) => ({
198
+ group: String(g['group']),
199
+ label: levelLabel.get(String(g['group'])) ??
200
+ known.get(String(g['group'])) ??
201
+ String(g['group']),
202
+ company: g['company'] ?? null,
203
+ })),
204
+ external: mine.length > 0 && mine.every((g) => external.has(String(g['group']))),
205
+ };
206
+ })
207
+ .filter((p) => !p.external)
208
+ .map(({ external: _external, ...p }) => p);
209
+ };
210
+ /** Users that are people: not service accounts (portal users are filtered by their groups). */
211
+ const people = async (su, where = {}) => rows(ref(su, 'res.users')
212
+ .search({ where: { and: [{ isService: { ne: true } }, where] }, order: ['name asc'] })
213
+ .then((s) => s), ['name', 'login', 'email', 'active']);
214
+ /** With the deactivated ones (archived records). */
215
+ const withArchived = (su) => su.withFlags({ activeTest: false });
216
+ app.get('/api/v1/settings/people', async (request) => {
217
+ const query = request.query;
218
+ return guarded(request, async (_env, su) => {
219
+ const q = (query.q ?? '').trim();
220
+ const list = await describe(su, await people(withArchived(su), q === '' ? {} : { or: [{ name: { ilike: `%${q}%` } }, { login: { ilike: `%${q}%` } }] }));
221
+ return {
222
+ people: query.status === undefined || query.status === '' || query.status === 'all'
223
+ ? list
224
+ : list.filter((p) => p.state === query.status),
225
+ };
226
+ });
227
+ });
228
+ app.get('/api/v1/settings/people/catalogue', async (request) => guarded(request, async (_env, su, manager) => {
229
+ const known = await knownGroups();
230
+ const admin = adminGroups(su);
231
+ const apps = modules
232
+ .filter((m) => m.accessLevels !== undefined && Object.keys(m.accessLevels).length > 0)
233
+ .map((m) => ({
234
+ module: m.name,
235
+ // A technical module (no app) by its first access level's name: "Files", not "storage".
236
+ label: m.app?.label ?? Object.values(m.accessLevels ?? {})[0]?.label ?? m.name,
237
+ app: m.app !== undefined,
238
+ levels: Object.entries(m.accessLevels ?? {}).map(([key, level]) => ({
239
+ key,
240
+ label: level.label,
241
+ groups: [...level.groups],
242
+ })),
243
+ }))
244
+ .filter((a) => a.levels.some((l) => l.groups.every((g) => !external.has(g))));
245
+ return {
246
+ apps,
247
+ roles: [...known]
248
+ .filter(([code]) => !external.has(code))
249
+ .map(([code, label]) => ({ code, label, administrator: admin.has(code) })),
250
+ administratorGroups: [...admin],
251
+ companies: (await rows(ref(su, 'res.company').search({ where: {} }), ['name'])).map((c) => ({
252
+ id: c['id'],
253
+ name: String(c['name']),
254
+ })),
255
+ manager,
256
+ };
257
+ }));
258
+ app.get('/api/v1/settings/people/:id', async (request) => {
259
+ const id = idOf(request);
260
+ return guarded(request, async (_env, su) => {
261
+ const [person] = await describe(su, await people(withArchived(su), { id }));
262
+ if (person === undefined)
263
+ throw new HttpProblem(404, 'missing-record', 'Not found', `There is no person ${id}.`);
264
+ const activity = (await rows(ref(su, 'audit.event').search({
265
+ where: {
266
+ or: [{ actor: id }, { and: [{ targetModel: 'res.users' }, { targetId: id }] }],
267
+ },
268
+ order: ['id desc'],
269
+ limit: 20,
270
+ }), ['kind', 'summary', 'createdAt'])).map((a) => ({
271
+ kind: String(a['kind']),
272
+ summary: String(a['summary']),
273
+ at: a['createdAt'],
274
+ }));
275
+ return {
276
+ ...person,
277
+ activity: activity.map((a) => ({ kind: a.kind, summary: a.summary, at: iso(a.at) })),
278
+ };
279
+ });
280
+ });
281
+ app.post('/api/v1/settings/people', async (request, reply) => {
282
+ const body = (request.body ?? {});
283
+ const name = String(body['name'] ?? '').trim();
284
+ const login = String(body['email'] ?? '')
285
+ .trim()
286
+ .toLowerCase();
287
+ if (name === '' || name.length > 200)
288
+ throw bad('The name is 1–200 characters.');
289
+ if (!EMAIL.test(login))
290
+ throw bad('The email is their login and where the invitation goes.');
291
+ const made = await guarded(request, async (env, su, manager) => {
292
+ const assignments = await checkAssignments(su, body['assignments'] ?? [], manager);
293
+ const taken = await ref(withArchived(su), 'res.users').search({ where: { login }, limit: 1 });
294
+ if (taken.length > 0)
295
+ throw new HttpProblem(409, 'login-taken', 'Already a person here', `${login} already has an account: find them in People.`);
296
+ const companies = [
297
+ ...new Set(assignments.map((a) => a.company).filter((c) => c !== null)),
298
+ ];
299
+ const home = companies[0] ?? env.companyId;
300
+ const created = await ref(su, 'res.users').create([
301
+ {
302
+ login,
303
+ name,
304
+ email: login,
305
+ company: home,
306
+ companies: { set: companies.length === 0 ? [env.companyId] : companies },
307
+ },
308
+ ]);
309
+ const user = created.ids[0];
310
+ // No usable password until the invitation is used.
311
+ await auth.setPassword(su, user, randomBytes(32).toString('base64url'));
312
+ if (assignments.length > 0)
313
+ await ref(su, 'auth.user_group').create(assignments.map((a) => ({ user, group: a.group, company: a.company })));
314
+ const link = await invite(su, user, env.uid);
315
+ await audit(env, {
316
+ kind: 'people.invited',
317
+ summary: `${name} (${login}) invited with ${assignments.length} role(s).`,
318
+ targetModel: 'res.users',
319
+ targetId: user,
320
+ details: { assignments },
321
+ ip: request.ip,
322
+ });
323
+ const emailed = email(env, login, name, await companyName(su, home), link.url);
324
+ return { id: user, login, activationUrl: link.url, expiresAt: link.expiresAt, emailed };
325
+ });
326
+ return reply.code(201).send(made);
327
+ });
328
+ app.patch('/api/v1/settings/people/:id', async (request) => {
329
+ const id = idOf(request);
330
+ const body = (request.body ?? {});
331
+ return guarded(request, async (env, su, manager) => {
332
+ await mayTouch(su, manager, id);
333
+ const values = {};
334
+ if (body['name'] !== undefined) {
335
+ const name = String(body['name']).trim();
336
+ if (name === '' || name.length > 200)
337
+ throw bad('The name is 1–200 characters.');
338
+ values['name'] = name;
339
+ }
340
+ if (body['email'] !== undefined) {
341
+ const mail = body['email'] === null ? '' : String(body['email']).trim();
342
+ if (mail !== '' && !EMAIL.test(mail))
343
+ throw bad(`'${mail}' is not an email address.`);
344
+ values['email'] = mail === '' ? null : mail;
345
+ }
346
+ if (body['active'] !== undefined) {
347
+ if (typeof body['active'] !== 'boolean')
348
+ throw bad('active is true or false.');
349
+ if (body['active'] === false && id === env.uid)
350
+ throw bad('You cannot deactivate yourself.');
351
+ values['active'] = body['active'];
352
+ }
353
+ const target = ref(withArchived(su), 'res.users').browse([id]);
354
+ if (Object.keys(values).length > 0)
355
+ await target.write(values);
356
+ if (values['active'] === false) {
357
+ // Signed out everywhere, invitations void.
358
+ const sessions = await ref(su, 'auth.session').search({
359
+ where: { user: id, revoked: false },
360
+ });
361
+ if (sessions.length > 0)
362
+ await sessions.write({ revoked: true });
363
+ const pending = await ref(su, 'settings.invitation').search({
364
+ where: { user: id, usedAt: null },
365
+ });
366
+ if (pending.length > 0)
367
+ await pending.write({ expiresAt: new Date() });
368
+ }
369
+ await audit(env, {
370
+ kind: values['active'] === false
371
+ ? 'people.deactivated'
372
+ : values['active'] === true
373
+ ? 'people.reactivated'
374
+ : 'people.changed',
375
+ summary: `Person ${id}: ${Object.keys(values).join(', ')} changed.`,
376
+ targetModel: 'res.users',
377
+ targetId: id,
378
+ details: values,
379
+ ip: request.ip,
380
+ });
381
+ auth.forget(id);
382
+ const [person] = await describe(su, await people(withArchived(su), { id }));
383
+ return person;
384
+ });
385
+ });
386
+ app.put('/api/v1/settings/people/:id/roles', async (request) => {
387
+ const id = idOf(request);
388
+ const body = (request.body ?? {});
389
+ return guarded(request, async (env, su, manager) => {
390
+ if (!manager.administrator && id === env.uid)
391
+ throw new HttpProblem(403, 'access', 'Not your own', 'Someone else changes your roles.');
392
+ await mayTouch(su, manager, id);
393
+ const assignments = await checkAssignments(su, body['assignments'], manager);
394
+ const existing = await ref(su, 'auth.user_group').search({ where: { user: id } });
395
+ // A People manager keeps the administrator roles a person has (they cannot give or take them).
396
+ if (existing.length > 0)
397
+ await existing.unlink();
398
+ if (assignments.length > 0)
399
+ await ref(su, 'auth.user_group').create(assignments.map((a) => ({ user: id, group: a.group, company: a.company })));
400
+ // The companies they may switch to follow their roles.
401
+ const companies = [
402
+ ...new Set(assignments.map((a) => a.company).filter((c) => c !== null)),
403
+ ];
404
+ if (companies.length > 0)
405
+ await ref(withArchived(su), 'res.users')
406
+ .browse([id])
407
+ .write({ companies: { set: companies } });
408
+ await audit(env, {
409
+ kind: 'security.assignment',
410
+ summary: `Roles of person ${id} set (${assignments.length}).`,
411
+ targetModel: 'res.users',
412
+ targetId: id,
413
+ details: { assignments },
414
+ ip: request.ip,
415
+ });
416
+ auth.forget(id);
417
+ const [person] = await describe(su, await people(withArchived(su), { id }));
418
+ return person;
419
+ });
420
+ });
421
+ app.post('/api/v1/settings/people/:id/invitation', async (request) => {
422
+ const id = idOf(request);
423
+ return guarded(request, async (env, su, manager) => {
424
+ await mayTouch(su, manager, id);
425
+ const [user] = await rows(ref(su, 'res.users').browse([id]), ['name', 'login', 'company']);
426
+ if (user === undefined)
427
+ throw new HttpProblem(404, 'missing-record', 'Not found', `There is no active person ${id}.`);
428
+ const link = await invite(su, id, env.uid);
429
+ await audit(env, {
430
+ kind: 'people.invited',
431
+ summary: `A new invitation for ${String(user['name'])}.`,
432
+ targetModel: 'res.users',
433
+ targetId: id,
434
+ ip: request.ip,
435
+ });
436
+ const emailed = email(env, String(user['login']), String(user['name']), await companyName(su, user['company'] ?? null), link.url);
437
+ return { activationUrl: link.url, expiresAt: link.expiresAt, emailed };
438
+ });
439
+ });
440
+ app.post('/api/v1/settings/people/:id/reset-2fa', async (request) => {
441
+ const id = idOf(request);
442
+ return guarded(request, async (env, su, manager) => {
443
+ await mayTouch(su, manager, id);
444
+ const credentials = await ref(su, 'auth.credential').search({ where: { user: id } });
445
+ if (credentials.length > 0)
446
+ await credentials.write({ totpEnabled: false, totpSecret: null, recoveryCodes: null });
447
+ const sessions = await ref(su, 'auth.session').search({
448
+ where: { user: id, revoked: false },
449
+ });
450
+ if (sessions.length > 0)
451
+ await sessions.write({ revoked: true });
452
+ await audit(env, {
453
+ kind: 'auth.2fa_reset',
454
+ summary: `Two-factor sign-in of person ${id} reset; signed out everywhere.`,
455
+ targetModel: 'res.users',
456
+ targetId: id,
457
+ ip: request.ip,
458
+ });
459
+ auth.forget(id);
460
+ return { ok: true };
461
+ });
462
+ });
463
+ app.post('/api/v1/auth/activate', { config: { auth: false } }, async (request) => {
464
+ const body = (request.body ?? {});
465
+ const token = typeof body['token'] === 'string' ? body['token'] : '';
466
+ const password = typeof body['password'] === 'string' ? body['password'] : '';
467
+ const invalid = () => new HttpProblem(410, 'invitation-invalid', 'This link no longer works', 'The invitation was used, replaced or has expired.', 'Ask for a new invitation.');
468
+ if (token === '')
469
+ throw invalid();
470
+ if (password.length < 12 || password.length > 1024)
471
+ throw bad('The password has at least 12 characters.');
472
+ const login = await context.shutro.bootstrap({ reason: 'activation' }, async (su) => {
473
+ const found = await ref(su, 'settings.invitation').search({
474
+ where: { tokenHash: digest(token) },
475
+ limit: 1,
476
+ });
477
+ const [row] = await rows(found, ['user', 'expiresAt', 'usedAt']);
478
+ if (row === undefined ||
479
+ row['usedAt'] !== null ||
480
+ new Date(String(row['expiresAt'])).getTime() <= Date.now())
481
+ throw invalid();
482
+ const user = row['user'];
483
+ const [account] = await rows(ref(su, 'res.users').browse([user]), [
484
+ 'active',
485
+ 'company',
486
+ 'login',
487
+ ]);
488
+ if (account?.['active'] !== true)
489
+ throw invalid();
490
+ await auth.setPassword(su, user, password);
491
+ await found.write({ usedAt: new Date() });
492
+ await audit(su, {
493
+ kind: 'people.activated',
494
+ summary: 'An invited person chose their password.',
495
+ actor: user,
496
+ company: account['company'] ?? null,
497
+ targetModel: 'res.users',
498
+ targetId: user,
499
+ ip: request.ip,
500
+ });
501
+ return String(account['login']);
502
+ });
503
+ return { login };
504
+ });
505
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Settings › Security (RFC 0019 §3.5), administrators only: the installation's policy — two-factor sign-in for
3
+ * administrators only (the default) or for everyone, and how long a sign-in lasts — with who it would stop until
4
+ * they set two-factor up; and who is signed in where, with sign-out of any session. The audit log has its own route
5
+ * (`/api/v1/admin/audit`, `shutro-erp/permissions`). Everything is audited.
6
+ *
7
+ * - `GET /api/v1/settings/security` the policy; the people without two-factor sign-in
8
+ * - `PATCH /api/v1/settings/security` `{ twoFactor?: 'administrators' | 'everyone', sessionDays?: 1–90 | null }`
9
+ * - `GET /api/v1/settings/sessions` every active session (the person, device, address, last use)
10
+ * - `DELETE /api/v1/settings/sessions/:id` signs that session out
11
+ */
12
+ import { type ModuleContext } from '../server/index.js';
13
+ export declare function registerSecurityRoutes(context: ModuleContext): void;