shutro-erp 0.3.2 → 0.3.4

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 (156) hide show
  1. package/dist/account_portal/.kosh/bindings.js +1 -1
  2. package/dist/account_portal/.kosh/schema.d.ts +156 -1
  3. package/dist/accounting/.kosh/bindings.js +1 -1
  4. package/dist/accounting/.kosh/schema.d.ts +156 -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 +156 -1
  8. package/dist/integrations/index.js +12 -0
  9. package/dist/integrations/web/OAuthAppsPage.d.ts +9 -0
  10. package/dist/integrations/web/OAuthAppsPage.js +79 -0
  11. package/dist/integrations/web/index.d.ts +1 -0
  12. package/dist/integrations/web/index.js +3 -0
  13. package/dist/integrations/web/messages.js +58 -0
  14. package/dist/l10n_bd/.kosh/bindings.js +1 -1
  15. package/dist/l10n_bd/.kosh/schema.d.ts +156 -1
  16. package/dist/l10n_in/.kosh/bindings.js +1 -1
  17. package/dist/l10n_in/.kosh/schema.d.ts +156 -4
  18. package/dist/l10n_in/module.d.ts +0 -62
  19. package/dist/l10n_in/module.js +2 -4
  20. package/dist/messaging/web/index.js +1 -1
  21. package/dist/notify/web/index.js +1 -1
  22. package/dist/payment/.kosh/bindings.js +1 -1
  23. package/dist/payment/.kosh/schema.d.ts +157 -2
  24. package/dist/payment/bkash.d.ts +28 -0
  25. package/dist/payment/bkash.js +169 -0
  26. package/dist/payment/index.d.ts +4 -0
  27. package/dist/payment/index.js +80 -24
  28. package/dist/payment/module.d.ts +7 -4
  29. package/dist/payment/module.js +3 -0
  30. package/dist/payment/money.d.ts +7 -0
  31. package/dist/payment/money.js +37 -0
  32. package/dist/payment/providers.d.ts +13 -2
  33. package/dist/payment/razorpay.d.ts +12 -0
  34. package/dist/payment/razorpay.js +149 -0
  35. package/dist/payment/sslcommerz.js +1 -1
  36. package/dist/payment/stripe.d.ts +15 -0
  37. package/dist/payment/stripe.js +182 -0
  38. package/dist/permissions/admin.d.ts +3 -0
  39. package/dist/permissions/admin.js +108 -10
  40. package/dist/permissions/index.d.ts +1 -0
  41. package/dist/permissions/index.js +3 -0
  42. package/dist/permissions/roles.d.ts +2 -0
  43. package/dist/permissions/roles.js +236 -0
  44. package/dist/portal/.kosh/bindings.js +1 -1
  45. package/dist/portal/.kosh/schema.d.ts +156 -1
  46. package/dist/product/.kosh/bindings.js +1 -1
  47. package/dist/product/.kosh/schema.d.ts +156 -1
  48. package/dist/project/.kosh/bindings.js +1 -1
  49. package/dist/project/.kosh/schema.d.ts +156 -1
  50. package/dist/project_portal/.kosh/bindings.js +1 -1
  51. package/dist/project_portal/.kosh/schema.d.ts +156 -1
  52. package/dist/purchase/.kosh/bindings.js +1 -1
  53. package/dist/purchase/.kosh/schema.d.ts +156 -1
  54. package/dist/purchase_account/.kosh/bindings.js +1 -1
  55. package/dist/purchase_account/.kosh/schema.d.ts +156 -1
  56. package/dist/purchase_portal/.kosh/bindings.js +1 -1
  57. package/dist/purchase_portal/.kosh/schema.d.ts +156 -1
  58. package/dist/sale_account/.kosh/bindings.js +1 -1
  59. package/dist/sale_account/.kosh/schema.d.ts +156 -1
  60. package/dist/sale_portal/.kosh/bindings.js +1 -1
  61. package/dist/sale_portal/.kosh/schema.d.ts +156 -1
  62. package/dist/sale_project/.kosh/bindings.js +1 -1
  63. package/dist/sale_project/.kosh/schema.d.ts +156 -1
  64. package/dist/sales/.kosh/bindings.js +1 -1
  65. package/dist/sales/.kosh/schema.d.ts +156 -1
  66. package/dist/server/auth/base.js +98 -0
  67. package/dist/server/auth/oauth.d.ts +21 -0
  68. package/dist/server/auth/oauth.js +579 -0
  69. package/dist/server/auth/routes.d.ts +3 -0
  70. package/dist/server/auth/routes.js +29 -0
  71. package/dist/server/auth/service.d.ts +56 -0
  72. package/dist/server/auth/service.js +185 -3
  73. package/dist/server/identity.d.ts +4 -0
  74. package/dist/server/images.d.ts +12 -0
  75. package/dist/server/images.js +139 -0
  76. package/dist/server/index.d.ts +2 -1
  77. package/dist/server/index.js +1 -0
  78. package/dist/server/server.d.ts +4 -0
  79. package/dist/server/server.js +20 -4
  80. package/dist/settings/company.d.ts +34 -0
  81. package/dist/settings/company.js +189 -0
  82. package/dist/settings/index.d.ts +10 -10
  83. package/dist/settings/index.js +159 -30
  84. package/dist/settings/people.d.ts +16 -0
  85. package/dist/settings/people.js +507 -0
  86. package/dist/settings/security.d.ts +13 -0
  87. package/dist/settings/security.js +158 -0
  88. package/dist/settings/web/AuditPage.d.ts +8 -0
  89. package/dist/settings/web/AuditPage.js +74 -0
  90. package/dist/settings/web/CompaniesPage.d.ts +8 -0
  91. package/dist/settings/web/CompaniesPage.js +52 -0
  92. package/dist/settings/web/CompanyPage.d.ts +9 -0
  93. package/dist/settings/web/CompanyPage.js +105 -0
  94. package/dist/settings/web/ConnectedAppsPage.d.ts +7 -0
  95. package/dist/settings/web/ConnectedAppsPage.js +33 -0
  96. package/dist/settings/web/OverviewPage.d.ts +9 -0
  97. package/dist/settings/web/OverviewPage.js +77 -0
  98. package/dist/settings/web/PeoplePage.d.ts +9 -0
  99. package/dist/settings/web/PeoplePage.js +154 -0
  100. package/dist/settings/web/ProfilePage.d.ts +14 -0
  101. package/dist/settings/web/ProfilePage.js +196 -0
  102. package/dist/settings/web/RolesEditor.d.ts +35 -0
  103. package/dist/settings/web/RolesEditor.js +49 -0
  104. package/dist/settings/web/RolesPage.d.ts +9 -0
  105. package/dist/settings/web/RolesPage.js +296 -0
  106. package/dist/settings/web/SecurityPage.d.ts +8 -0
  107. package/dist/settings/web/SecurityPage.js +81 -0
  108. package/dist/settings/web/index.d.ts +10 -0
  109. package/dist/settings/web/index.js +31 -1
  110. package/dist/settings/web/messages.js +594 -0
  111. package/dist/ui/api.d.ts +8 -0
  112. package/dist/ui/app.d.ts +5 -0
  113. package/dist/ui/app.js +143 -33
  114. package/dist/ui/components/AppIcon.js +11 -1
  115. package/dist/ui/components/Avatar.d.ts +21 -0
  116. package/dist/ui/components/Avatar.js +71 -0
  117. package/dist/ui/components/primitives.d.ts +9 -1
  118. package/dist/ui/components/primitives.js +12 -4
  119. package/dist/ui/context.d.ts +10 -1
  120. package/dist/ui/context.js +60 -2
  121. package/dist/ui/i18n/messages.d.ts +76 -0
  122. package/dist/ui/i18n/messages.js +152 -0
  123. package/dist/ui/index.d.ts +3 -1
  124. package/dist/ui/index.js +3 -1
  125. package/dist/ui/lib/accent.d.ts +13 -0
  126. package/dist/ui/lib/accent.js +36 -0
  127. package/dist/ui/registry.d.ts +7 -1
  128. package/dist/ui/registry.js +10 -1
  129. package/dist/ui/search/ViewTabs.d.ts +14 -0
  130. package/dist/ui/search/ViewTabs.js +42 -0
  131. package/dist/ui/shell/Activate.d.ts +6 -0
  132. package/dist/ui/shell/Activate.js +39 -0
  133. package/dist/ui/shell/CommandPalette.d.ts +7 -1
  134. package/dist/ui/shell/CommandPalette.js +16 -2
  135. package/dist/ui/shell/Consent.d.ts +5 -0
  136. package/dist/ui/shell/Consent.js +35 -0
  137. package/dist/ui/shell/Home.js +85 -16
  138. package/dist/ui/shell/Sidebar.d.ts +27 -0
  139. package/dist/ui/shell/Sidebar.js +77 -0
  140. package/dist/ui/shell/TwoFactorSetup.d.ts +5 -0
  141. package/dist/ui/shell/TwoFactorSetup.js +32 -0
  142. package/dist/ui/shell/shortcuts.d.ts +3 -0
  143. package/dist/ui/shell/shortcuts.js +13 -4
  144. package/dist/ui/styles.css +70 -28
  145. package/dist/ui/views/DateField.d.ts +10 -0
  146. package/dist/ui/views/DateField.js +129 -0
  147. package/dist/ui/views/FormView.js +161 -21
  148. package/dist/ui/views/ListView.d.ts +2 -0
  149. package/dist/ui/views/ListView.js +57 -25
  150. package/dist/ui/views/StatusChip.d.ts +10 -0
  151. package/dist/ui/views/StatusChip.js +22 -0
  152. package/dist/ui/views/widgets.d.ts +3 -0
  153. package/dist/ui/views/widgets.js +26 -8
  154. package/dist/views/types.d.ts +7 -0
  155. package/dist/views/validate.js +4 -0
  156. package/package.json +2 -2
@@ -39,11 +39,26 @@ export interface UserInfo {
39
39
  readonly companies: readonly number[];
40
40
  /** The number grouping of each of the user's companies (RFC 0016): `lakh` or `international`. */
41
41
  readonly groupings: Readonly<Record<string, 'international' | 'lakh'>>;
42
+ /** The brand colour of each of the user's companies that has one (RFC 0018): `#rrggbb`. */
43
+ readonly accents: Readonly<Record<string, string>>;
44
+ /** The user's photo version (`/api/v1/images/user/<id>?v=<version>`), or null without one. */
45
+ readonly avatar: string | null;
46
+ /** Each of the user's companies' logo version, when it has one. */
47
+ readonly logos: Readonly<Record<string, string>>;
42
48
  readonly twoFactorEnabled: boolean;
43
49
  /** Holds an administrator role (in some company). */
44
50
  readonly administrator: boolean;
45
51
  /** Administrator rights are withheld until this session has a second factor (enable or verify 2FA). */
46
52
  readonly administratorNeedsSecondFactor: boolean;
53
+ /** The policy requires two-factor sign-in of everyone and it is not on: nothing but setting it up is allowed. */
54
+ readonly twoFactorSetupRequired: boolean;
55
+ }
56
+ /** The installation's security policy (RFC 0019 §3.5). */
57
+ export interface SecurityPolicy {
58
+ /** Who must sign in with a second factor: administrators only (the default), or everyone. */
59
+ readonly twoFactor: 'administrators' | 'everyone';
60
+ /** Days a sign-in lasts at most; null: the server's `lifetimeMs`. */
61
+ readonly sessionDays: number | null;
47
62
  }
48
63
  /** An API key as listed (never its secret). */
49
64
  export interface ApiKeyInfo {
@@ -162,4 +177,45 @@ export declare class AuthService {
162
177
  disableTwoFactor(session: SessionInfo, password: string, code: string): Promise<void>;
163
178
  /** Changes the password; every other session of the user is revoked. */
164
179
  changePassword(session: SessionInfo, current: string, next: string): Promise<void>;
180
+ /** The security policy (cached like sessions; {@link forgetPolicy} after changing it). */
181
+ policy(): Promise<SecurityPolicy>;
182
+ /** Forgets the cached policy: after Settings › Security changed it. */
183
+ forgetPolicy(): void;
184
+ /** Forgets what is cached about `user` (their identity and sessions): after their roles or account changed. */
185
+ forget(user: number): void;
186
+ /** The signed-in user's own details (RFC 0019 §3.6). */
187
+ profile(session: SessionInfo): Promise<Profile>;
188
+ /**
189
+ * Changes the user's own details: name, email, phone, language, time zone, and the default company (one of
190
+ * theirs). Audited.
191
+ */
192
+ updateProfile(session: SessionInfo, values: Partial<Omit<Profile, 'login'>>): Promise<Profile>;
193
+ /** The user's active sessions, newest use first; the current one marked. */
194
+ sessions(session: SessionInfo): Promise<SessionEntry[]>;
195
+ /** Signs out one of the user's own sessions (or all but this one with `'others'`). Audited. */
196
+ revokeSessions(session: SessionInfo, which: number | 'others'): Promise<{
197
+ revoked: number;
198
+ }>;
199
+ }
200
+ /** The user's own details (RFC 0019 §3.6). */
201
+ export interface Profile {
202
+ readonly login: string;
203
+ readonly name: string;
204
+ readonly email: string | null;
205
+ readonly phone: string | null;
206
+ readonly lang: string;
207
+ readonly tz: string;
208
+ /** The default company. */
209
+ readonly company: number | null;
210
+ }
211
+ /** One of the user's sessions (a signed-in device). */
212
+ export interface SessionEntry {
213
+ readonly id: number;
214
+ readonly ip: string | null;
215
+ readonly userAgent: string | null;
216
+ readonly lastSeenAt: string;
217
+ readonly createdAt: string | null;
218
+ readonly secondFactor: boolean;
219
+ /** This browser. */
220
+ readonly current: boolean;
165
221
  }
@@ -1,7 +1,9 @@
1
1
  import { audit } from '../audit.js';
2
2
  import { HttpProblem } from '../errors.js';
3
+ import { imageVersions } from '../images.js';
3
4
  import { keepingAdministrator } from './admins.js';
4
5
  import { digest, hashPassword, newTotpSecret, otpauthUri, randomToken, verifyPassword, verifyTotp, } from './crypto.js';
6
+ import { resolveAccessToken } from './oauth.js';
5
7
  export const SESSION_COOKIE = 'kosh_session';
6
8
  const MIN_PASSWORD = 12;
7
9
  const model = (env, name) => env.model(name);
@@ -38,6 +40,7 @@ export class AuthService {
38
40
  #cache = new Map();
39
41
  #keyCache = new Map();
40
42
  #dummy = hashPassword(randomToken());
43
+ #policy;
41
44
  constructor(shutro, options = {}) {
42
45
  this.#shutro = shutro;
43
46
  this.#options = {
@@ -95,6 +98,8 @@ export class AuthService {
95
98
  }
96
99
  /** Signs in: a new session (pending second factor when 2FA is on). @throws {HttpProblem} 401 */
97
100
  async login(request, login, password) {
101
+ const { sessionDays } = await this.policy();
102
+ const lifetimeMs = sessionDays === null ? this.#options.lifetimeMs : sessionDays * 86_400_000;
98
103
  const outcome = await this.#system(`login ${login}`, async (env) => {
99
104
  const user = one(await fetch(model(env, 'res.users').search({ where: { login } }), ['login']));
100
105
  const credential = user === undefined
@@ -119,7 +124,7 @@ export class AuthService {
119
124
  csrfHash: digest(csrfToken),
120
125
  user: user['id'],
121
126
  twoFactorVerified: !twoFactorRequired,
122
- expiresAt: new Date(now + this.#options.lifetimeMs),
127
+ expiresAt: new Date(now + lifetimeMs),
123
128
  lastSeenAt: new Date(now),
124
129
  ip: request.ip,
125
130
  userAgent: String(request.headers['user-agent'] ?? '').slice(0, 300),
@@ -225,12 +230,27 @@ export class AuthService {
225
230
  scopes: key.scopes,
226
231
  apiKey: key.id,
227
232
  });
233
+ // An OAuth access token (RFC 0017 §5.2): the consenting user, within the grant's scopes.
234
+ const bearer = /^Bearer\s+(oat_\S+)$/i.exec(String(request.headers.authorization ?? ''))?.[1];
235
+ if (bearer !== undefined) {
236
+ const access = await this.#system('oauth token', (env) => resolveAccessToken(env, bearer));
237
+ if (access === null)
238
+ throw unauthenticated('invalid-token', 'The access token is unknown, expired or revoked.', 'Refresh it, or ask the user to allow the app again.');
239
+ return this.#identity(request, access.user, {
240
+ elevated: false,
241
+ scopes: access.scopes,
242
+ oauthToken: access.token,
243
+ });
244
+ }
228
245
  const session = await this.session(request);
229
246
  if (session === null)
230
247
  return null;
231
248
  if (session.twoFactorRequired)
232
249
  throw unauthenticated('two-factor-required', 'Enter the code from your authenticator app first.', 'POST /api/v1/auth/2fa/verify.');
233
- return this.#identity(request, session.user, { elevated: session.secondFactor });
250
+ return this.#identity(request, session.user, {
251
+ elevated: session.secondFactor,
252
+ session: true,
253
+ });
234
254
  }
235
255
  async #identity(request, user, options) {
236
256
  const info = await this.#identityOf(user);
@@ -264,6 +284,14 @@ export class AuthService {
264
284
  tz: info.tz,
265
285
  ...(options.scopes === undefined ? {} : { scopes: options.scopes }),
266
286
  ...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }),
287
+ ...(options.oauthToken === undefined ? {} : { oauthToken: options.oauthToken }),
288
+ // A session without the second factor the policy asks of everyone (RFC 0019 §3.5): the server allows only
289
+ // setting it up. API keys are not sessions: their owner signed in to make them.
290
+ ...(options.session === true &&
291
+ !info.twoFactorEnabled &&
292
+ (await this.policy()).twoFactor === 'everyone'
293
+ ? { twoFactorMissing: true }
294
+ : {}),
267
295
  };
268
296
  }
269
297
  /**
@@ -546,9 +574,13 @@ export class AuthService {
546
574
  const twoFactorEnabled = credential?.['totpEnabled'] === true;
547
575
  const companyIds = user?.['companies'] ?? [];
548
576
  const groupings = {};
577
+ const accents = {};
549
578
  if (companyIds.length > 0)
550
- for (const c of await fetch(model(env, 'res.company').search({ where: { id: { in: companyIds } } }), ['numberGrouping']))
579
+ for (const c of await fetch(model(env, 'res.company').search({ where: { id: { in: companyIds } } }), ['numberGrouping', 'accentColor'])) {
551
580
  groupings[String(c['id'])] = c['numberGrouping'] === 'lakh' ? 'lakh' : 'international';
581
+ if (typeof c['accentColor'] === 'string' && /^#[0-9a-f]{6}$/i.test(c['accentColor']))
582
+ accents[String(c['id'])] = c['accentColor'];
583
+ }
552
584
  return {
553
585
  id: session.user,
554
586
  login: String(user?.['login']),
@@ -556,9 +588,13 @@ export class AuthService {
556
588
  company: typeof user?.['company'] === 'number' ? user['company'] : null,
557
589
  companies: companyIds,
558
590
  groupings,
591
+ accents,
592
+ avatar: (await imageVersions(env, 'user', [session.user]))[String(session.user)] ?? null,
593
+ logos: await imageVersions(env, 'company', companyIds),
559
594
  twoFactorEnabled,
560
595
  administrator,
561
596
  administratorNeedsSecondFactor: administrator && !(session.secondFactor && twoFactorEnabled),
597
+ twoFactorSetupRequired: !twoFactorEnabled && (await this.policy()).twoFactor === 'everyone',
562
598
  };
563
599
  });
564
600
  }
@@ -667,6 +703,8 @@ export class AuthService {
667
703
  }
668
704
  /** Turns 2FA off (password and a current code required). */
669
705
  async disableTwoFactor(session, password, code) {
706
+ if ((await this.policy()).twoFactor === 'everyone')
707
+ throw new HttpProblem(409, 'two-factor-required', 'Two-factor sign-in is required', 'This installation requires two-factor sign-in of everyone: it cannot be turned off.');
670
708
  await this.#system('2fa disable', async (env) => {
671
709
  const credentials = await model(env, 'auth.credential').search({
672
710
  where: { user: session.user },
@@ -708,4 +746,148 @@ export class AuthService {
708
746
  this.#forgetUser(session.user);
709
747
  this.#cache.clear(); // other sessions of this user, cached by token
710
748
  }
749
+ /** The security policy (cached like sessions; {@link forgetPolicy} after changing it). */
750
+ async policy() {
751
+ const cached = this.#policy;
752
+ if (cached !== undefined && Date.now() - cached.at < this.#options.cacheMs)
753
+ return cached.policy;
754
+ const policy = await this.#system('policy', async (env) => {
755
+ const [row] = await fetch(model(env, 'auth.policy').search({ where: {}, limit: 1 }), [
756
+ 'twoFactor',
757
+ 'sessionDays',
758
+ ]);
759
+ const days = row?.['sessionDays'];
760
+ return {
761
+ twoFactor: row?.['twoFactor'] === 'everyone' ? 'everyone' : 'administrators',
762
+ sessionDays: typeof days === 'number' && days > 0 ? days : null,
763
+ };
764
+ });
765
+ this.#policy = { at: Date.now(), policy };
766
+ return policy;
767
+ }
768
+ /** Forgets the cached policy: after Settings › Security changed it. */
769
+ forgetPolicy() {
770
+ this.#policy = undefined;
771
+ }
772
+ /** Forgets what is cached about `user` (their identity and sessions): after their roles or account changed. */
773
+ forget(user) {
774
+ this.#forgetUser(user);
775
+ this.#cache.clear();
776
+ }
777
+ /** The signed-in user's own details (RFC 0019 §3.6). */
778
+ async profile(session) {
779
+ return this.#system('profile', async (env) => {
780
+ const [row] = await fetch(model(env, 'res.users').browse([session.user]), PROFILE_KEYS);
781
+ return {
782
+ login: String(row?.['login'] ?? ''),
783
+ name: String(row?.['name'] ?? ''),
784
+ email: typeof row?.['email'] === 'string' ? row['email'] : null,
785
+ phone: typeof row?.['phone'] === 'string' ? row['phone'] : null,
786
+ lang: String(row?.['lang'] ?? 'en_US'),
787
+ tz: String(row?.['tz'] ?? 'UTC'),
788
+ company: typeof row?.['company'] === 'number' ? row['company'] : null,
789
+ };
790
+ });
791
+ }
792
+ /**
793
+ * Changes the user's own details: name, email, phone, language, time zone, and the default company (one of
794
+ * theirs). Audited.
795
+ */
796
+ async updateProfile(session, values) {
797
+ const changes = {};
798
+ if (values.name !== undefined) {
799
+ const name = values.name.trim();
800
+ if (name === '' || name.length > 200)
801
+ throw new HttpProblem(422, 'invalid-name', 'Invalid name', 'Your name is 1–200 characters.');
802
+ changes['name'] = name;
803
+ }
804
+ if (values.email !== undefined) {
805
+ const email = values.email === null ? '' : values.email.trim();
806
+ if (email !== '' && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email))
807
+ throw new HttpProblem(422, 'invalid-email', 'Invalid email', `'${email}' is not an email address.`);
808
+ changes['email'] = email === '' ? null : email;
809
+ }
810
+ if (values.phone !== undefined)
811
+ changes['phone'] = values.phone?.trim() || null;
812
+ if (values.lang !== undefined) {
813
+ if (!/^[a-z]{2,3}(_[A-Z]{2})?$/.test(values.lang))
814
+ throw new HttpProblem(422, 'invalid-lang', 'Invalid language', 'A language code such as en_US or bn_BD.');
815
+ changes['lang'] = values.lang;
816
+ }
817
+ if (values.tz !== undefined) {
818
+ try {
819
+ new Intl.DateTimeFormat('en', { timeZone: values.tz });
820
+ }
821
+ catch {
822
+ throw new HttpProblem(422, 'invalid-tz', 'Invalid time zone', `'${values.tz}' is not a time zone.`);
823
+ }
824
+ changes['tz'] = values.tz;
825
+ }
826
+ if (values.company !== undefined && values.company !== null) {
827
+ const info = await this.#identityOf(session.user);
828
+ if (info === null || !info.companies.includes(values.company))
829
+ throw new HttpProblem(422, 'invalid-company', 'Invalid company', 'Choose one of your companies.');
830
+ changes['company'] = values.company;
831
+ }
832
+ if (Object.keys(changes).length > 0)
833
+ await this.#system('profile change', async (env) => {
834
+ await model(env, 'res.users').browse([session.user]).write(changes);
835
+ await audit(env, {
836
+ kind: 'auth.profile_changed',
837
+ summary: `Changed ${Object.keys(changes).join(', ')}.`,
838
+ actor: session.user,
839
+ });
840
+ });
841
+ this.#forgetUser(session.user);
842
+ return this.profile(session);
843
+ }
844
+ /** The user's active sessions, newest use first; the current one marked. */
845
+ async sessions(session) {
846
+ return this.#system('sessions', async (env) => {
847
+ const rows = await fetch(model(env, 'auth.session').search({
848
+ where: { user: session.user, revoked: false, expiresAt: { gt: new Date() } },
849
+ order: ['lastSeenAt desc', 'id desc'],
850
+ limit: 50,
851
+ }), ['id', 'ip', 'userAgent', 'lastSeenAt', 'createdAt', 'secondFactor']);
852
+ return rows.map((r) => ({
853
+ id: r['id'],
854
+ ip: typeof r['ip'] === 'string' ? r['ip'] : null,
855
+ userAgent: typeof r['userAgent'] === 'string' ? r['userAgent'] : null,
856
+ lastSeenAt: new Date(timeOf(r['lastSeenAt']) ?? 0).toISOString(),
857
+ createdAt: timeOf(r['createdAt']) === null
858
+ ? null
859
+ : new Date(timeOf(r['createdAt'])).toISOString(),
860
+ secondFactor: r['secondFactor'] === true,
861
+ current: r['id'] === session.id,
862
+ }));
863
+ });
864
+ }
865
+ /** Signs out one of the user's own sessions (or all but this one with `'others'`). Audited. */
866
+ async revokeSessions(session, which) {
867
+ const revoked = await this.#system('sign out a session', async (env) => {
868
+ const set = await model(env, 'auth.session').search({
869
+ where: {
870
+ user: session.user,
871
+ revoked: false,
872
+ ...(which === 'others' ? { id: { ne: session.id } } : { id: which }),
873
+ },
874
+ });
875
+ if (which !== 'others' && set.length === 0)
876
+ throw new HttpProblem(404, 'missing-record', 'Not found', `You have no session ${which}.`);
877
+ if (set.length > 0)
878
+ await set.write({ revoked: true });
879
+ await audit(env, {
880
+ kind: 'auth.session_revoked',
881
+ summary: which === 'others'
882
+ ? `Signed out ${set.length} other session(s).`
883
+ : `Signed out session ${which}.`,
884
+ actor: session.user,
885
+ });
886
+ return set.length;
887
+ });
888
+ this.#forgetUser(session.user);
889
+ this.#cache.clear();
890
+ return { revoked };
891
+ }
711
892
  }
893
+ const PROFILE_KEYS = ['login', 'name', 'email', 'phone', 'lang', 'tz', 'company'];
@@ -24,6 +24,10 @@ export interface RequestIdentity {
24
24
  readonly scopes?: readonly string[];
25
25
  /** The API key the request carries. */
26
26
  readonly apiKey?: number;
27
+ /** The OAuth access token the request carries (RFC 0017 §5.2): its app acts for the user, within `scopes`. */
28
+ readonly oauthToken?: number;
29
+ /** A session without the second factor the security policy asks of everyone (RFC 0019 §3.5). */
30
+ readonly twoFactorMissing?: true;
27
31
  }
28
32
  /**
29
33
  * The single explicit bypass (CLAUDE.md "superuser mode", RFC 0003 §5.2): the user holds an administrator role
@@ -0,0 +1,12 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { AuthService } from './auth/service.js';
3
+ import type { Env } from './orm.js';
4
+ declare const KINDS: {
5
+ readonly user: 'res.users';
6
+ readonly company: 'res.company';
7
+ };
8
+ type Kind = keyof typeof KINDS;
9
+ /** The image versions of `ids` of a kind (a short digest), for URLs that change when the image does. */
10
+ export declare function imageVersions(env: Env, kind: Kind, ids: readonly number[]): Promise<Record<string, string>>;
11
+ export declare function registerImageRoutes(app: FastifyInstance, auth: AuthService): void;
12
+ export {};
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Profile photos and company logos: small images (resized to 256 px in the browser) kept in the database, so they
3
+ * work with or without file storage. PNG, JPEG or WebP only — checked by their bytes, never trusted from the name or
4
+ * the type given; at most 300 KB. Served to signed-in users with a version in the URL, so browsers cache them.
5
+ *
6
+ * - `GET /api/v1/images/:kind/:id` `kind`: `user` or `company`; the image, or 404
7
+ * - `PUT /api/v1/auth/profile/avatar` `{ image: 'data:image/…;base64,…' }`: your photo; `DELETE` removes it
8
+ * - `PUT /api/v1/images/company/:id` a company's logo (Administrator or Settings / Company, one of your
9
+ * companies); `DELETE` removes it
10
+ */
11
+ import { createHash } from 'node:crypto';
12
+ import { audit } from './audit.js';
13
+ import { HttpProblem } from './errors.js';
14
+ const MAX_BYTES = 300 * 1024;
15
+ const KINDS = { user: 'res.users', company: 'res.company' };
16
+ const images = (env) => env.model('kosh.image');
17
+ /** The image's type from its first bytes: PNG, JPEG or WebP; anything else (SVG included) is refused. */
18
+ function sniff(bytes) {
19
+ if (bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
20
+ return 'image/png';
21
+ if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff)
22
+ return 'image/jpeg';
23
+ if (bytes.subarray(0, 4).toString('latin1') === 'RIFF' &&
24
+ bytes.subarray(8, 12).toString('latin1') === 'WEBP')
25
+ return 'image/webp';
26
+ return null;
27
+ }
28
+ /** The bytes of a `data:` URL, checked. @throws {HttpProblem} 422 */
29
+ function decode(input) {
30
+ const bad = (detail) => new HttpProblem(422, 'invalid-image', 'Invalid image', detail);
31
+ const match = typeof input === 'string'
32
+ ? /^data:image\/[a-z+.-]+;base64,([A-Za-z0-9+/=]+)$/.exec(input)
33
+ : null;
34
+ if (match === null)
35
+ throw bad('Send the image as a data URL (PNG, JPEG or WebP).');
36
+ const bytes = Buffer.from(match[1], 'base64');
37
+ if (bytes.length === 0 || bytes.length > MAX_BYTES)
38
+ throw bad('The image must be under 300 KB.');
39
+ const mime = sniff(bytes);
40
+ if (mime === null)
41
+ throw bad('Only PNG, JPEG or WebP images.');
42
+ return { bytes, mime };
43
+ }
44
+ /** The image versions of `ids` of a kind (a short digest), for URLs that change when the image does. */
45
+ export async function imageVersions(env, kind, ids) {
46
+ if (ids.length === 0)
47
+ return {};
48
+ const found = await images(env).search({
49
+ where: { ownerModel: KINDS[kind], ownerId: { in: [...ids] } },
50
+ });
51
+ const rows = found.length === 0 ? [] : (await found.load(['ownerId', 'hash'])).read(['ownerId', 'hash']);
52
+ return Object.fromEntries(rows.map((r) => [String(r['ownerId']), String(r['hash'])]));
53
+ }
54
+ async function store(env, kind, id, input) {
55
+ const existing = await images(env).search({ where: { ownerModel: KINDS[kind], ownerId: id } });
56
+ if (input === null) {
57
+ if (existing.length > 0)
58
+ await existing.unlink();
59
+ return null;
60
+ }
61
+ const { bytes, mime } = decode(input);
62
+ const hash = createHash('sha256').update(bytes).digest('hex').slice(0, 16);
63
+ const values = { mime, data: bytes.toString('base64'), hash };
64
+ if (existing.length > 0)
65
+ await existing.write(values);
66
+ else
67
+ await images(env).create([{ ownerModel: KINDS[kind], ownerId: id, ...values }]);
68
+ return hash;
69
+ }
70
+ export function registerImageRoutes(app, auth) {
71
+ app.get('/api/v1/images/:kind/:id', async (request, reply) => {
72
+ const { kind, id } = request.params;
73
+ const owner = Number(id);
74
+ if (!(kind === 'user' || kind === 'company') || !Number.isSafeInteger(owner) || owner <= 0)
75
+ throw new HttpProblem(404, 'not-found', 'Not found', 'No such image.');
76
+ const row = await request.env((env) => env.asSuperuser({ reason: 'image' }, async (su) => {
77
+ const found = await images(su).search({
78
+ where: { ownerModel: KINDS[kind], ownerId: owner },
79
+ });
80
+ return found.length === 0
81
+ ? undefined
82
+ : (await found.load(['mime', 'data', 'hash'])).read(['mime', 'data', 'hash'])[0];
83
+ }), { write: true });
84
+ if (row === undefined)
85
+ throw new HttpProblem(404, 'not-found', 'Not found', 'No image.');
86
+ const etag = `"${String(row['hash'])}"`;
87
+ reply
88
+ .header('content-type', String(row['mime']))
89
+ .header('etag', etag)
90
+ .header('x-content-type-options', 'nosniff')
91
+ // Versioned URLs (`?v=<hash>`) never change; plain ones are checked again after a minute.
92
+ .header('cache-control', request.query.v === undefined
93
+ ? 'private, max-age=60'
94
+ : 'private, max-age=31536000, immutable');
95
+ if (request.headers['if-none-match'] === etag)
96
+ return reply.status(304).send();
97
+ return reply.send(Buffer.from(String(row['data']), 'base64'));
98
+ });
99
+ const own = async (request, image) => {
100
+ const session = await auth.requireSession(request);
101
+ const hash = await request.env((env) => env.asSuperuser({ reason: 'profile photo' }, async (su) => {
102
+ const out = await store(su, 'user', session.user, image);
103
+ await audit(env, {
104
+ kind: 'auth.avatar',
105
+ summary: image === null ? 'Profile photo removed.' : 'Profile photo changed.',
106
+ targetModel: 'res.users',
107
+ targetId: session.user,
108
+ });
109
+ await su.flush();
110
+ return out;
111
+ }), { write: true });
112
+ auth.forget(session.user);
113
+ return { avatar: hash };
114
+ };
115
+ app.put('/api/v1/auth/profile/avatar', async (request) => own(request, request.body?.['image'] ?? ''));
116
+ app.delete('/api/v1/auth/profile/avatar', async (request) => own(request, null));
117
+ const logo = async (request, image) => {
118
+ const company = Number(request.params.id);
119
+ return request.env(async (env) => {
120
+ if (!env.administrator && !env.groups.has('settings.company'))
121
+ throw new HttpProblem(403, 'access', 'Company settings', 'Changing the logo needs the Administrator role or the Settings / Company access.');
122
+ if (!env.administrator && !env.companyIds.includes(company))
123
+ throw new HttpProblem(403, 'access', 'Company settings', 'Not one of your companies.');
124
+ return env.asSuperuser({ reason: 'company logo' }, async (su) => {
125
+ const hash = await store(su, 'company', company, image);
126
+ await audit(env, {
127
+ kind: 'settings.logo',
128
+ summary: image === null ? 'Company logo removed.' : 'Company logo changed.',
129
+ targetModel: 'res.company',
130
+ targetId: company,
131
+ });
132
+ await su.flush();
133
+ return { logo: hash };
134
+ });
135
+ }, { write: true });
136
+ };
137
+ app.put('/api/v1/images/company/:id', async (request) => logo(request, request.body?.['image'] ?? ''));
138
+ app.delete('/api/v1/images/company/:id', async (request) => logo(request, null));
139
+ }
@@ -18,12 +18,13 @@ export { base, baseShutro } from './auth/base.js';
18
18
  export { digest, hashPassword, newTotpSecret, otpauthUri, randomToken, totp, verifyPassword, verifyTotp, } from './auth/crypto.js';
19
19
  export { type InstallBaseOptions, installBase } from './auth/install.js';
20
20
  export { registerAuthRoutes } from './auth/routes.js';
21
- export { type AuthOptions, AuthService, cookieOf, SESSION_COOKIE, type SessionInfo, timeOf, type UserInfo, } from './auth/service.js';
21
+ export { type AuthOptions, AuthService, cookieOf, SESSION_COOKIE, type SecurityPolicy, type SessionInfo, timeOf, type UserInfo, } from './auth/service.js';
22
22
  export { DEMO_PASSWORD, type DemoResult, installDemo } from './demo.js';
23
23
  export { applyEnvironmentNames } from './env-names.js';
24
24
  export { HttpProblem, type Problem, problemOf, ServerConfigError } from './errors.js';
25
25
  export { type AppHost, type AppHostOptions, createAppHost, startAppHost } from './host.js';
26
26
  export { type Authenticator, denyAll, isSuperuser, languageOf, type RequestIdentity, type RequestRunOptions, runOptionsOf, } from './identity.js';
27
+ export { imageVersions, registerImageRoutes } from './images.js';
27
28
  export { migrate, orm, pg } from './orm.js';
28
29
  export { buildKoshRegistry, KOSH_VERSION, type KoshRegistry } from './registry.js';
29
30
  export { type AppsContext, createServer, type KoshServer, type ModuleContext, type ServerOptions, startServer, } from './server.js';
@@ -24,6 +24,7 @@ export { applyEnvironmentNames } from './env-names.js';
24
24
  export { HttpProblem, problemOf, ServerConfigError } from './errors.js';
25
25
  export { createAppHost, startAppHost } from './host.js';
26
26
  export { denyAll, isSuperuser, languageOf, runOptionsOf, } from './identity.js';
27
+ export { imageVersions, registerImageRoutes } from './images.js';
27
28
  export { migrate, orm, pg } from './orm.js';
28
29
  export { buildKoshRegistry, KOSH_VERSION } from './registry.js';
29
30
  export { createServer, startServer, } from './server.js';
@@ -47,6 +47,10 @@ export interface ServerOptions {
47
47
  /** Sign-in attempts per 15 minutes per IP and login (default 10; 0 turns the limit off). */
48
48
  readonly loginLimit?: number;
49
49
  };
50
+ /** OAuth 2.0 provider (RFC 0017 §5.2): the back-office's address, where the consent page is (`KOSH_BASE_URL`). */
51
+ readonly oauth?: {
52
+ readonly baseUrl?: string;
53
+ };
50
54
  /**
51
55
  * Shutro's identity loader checks every request's user (active, companies, groups) against `base`'s data. Default:
52
56
  * on with the built-in authentication; custom authenticators opt in.
@@ -13,12 +13,14 @@ import { openApiDocument } from './api/openapi.js';
13
13
  import { registerRestApi } from './api/rest.js';
14
14
  import { ViewRegistry } from './api/views.js';
15
15
  import { registerApiKeyRoutes } from './auth/api-keys.js';
16
+ import { registerOAuthRoutes } from './auth/oauth.js';
16
17
  import { registerAuthRoutes } from './auth/routes.js';
17
18
  import { enforceScopes } from './auth/scopes.js';
18
19
  import { AuthService } from './auth/service.js';
19
20
  import { applyEnvironmentNames } from './env-names.js';
20
21
  import { HttpProblem, problemOf, ServerConfigError } from './errors.js';
21
22
  import { denyAll, runOptionsOf } from './identity.js';
23
+ import { registerImageRoutes } from './images.js';
22
24
  import { orm, pg } from './orm.js';
23
25
  import { buildKoshRegistry, KOSH_VERSION } from './registry.js';
24
26
  import { endSpan, tracer } from './telemetry.js';
@@ -142,11 +144,19 @@ export async function createServer(options) {
142
144
  : null);
143
145
  enforceScopes(scopes, request.method, route, owner);
144
146
  }
145
- // Customers and vendors of the portal (RFC 0015 §5.1): the portal API and signing in, nothing else.
146
- if (request.identity !== null &&
147
+ const externalUser = request.identity !== null &&
147
148
  external.size > 0 &&
148
149
  request.identity.groups.length > 0 &&
149
- request.identity.groups.every((g) => external.has(g)) &&
150
+ request.identity.groups.every((g) => external.has(g));
151
+ // Two-factor sign-in required of everyone (RFC 0019 §3.5): until it is set up, only the sign-in routes (setting
152
+ // it up, signing out, who am I). Portal users are not the company's people: the policy is not theirs.
153
+ if (request.identity?.twoFactorMissing === true &&
154
+ !externalUser &&
155
+ request.url.startsWith('/api/') &&
156
+ !request.url.startsWith('/api/v1/auth/'))
157
+ throw new HttpProblem(403, 'two-factor-setup-required', 'Two-factor sign-in required', 'This installation requires two-factor sign-in: set it up first.', 'POST /api/v1/auth/2fa/setup, then POST /api/v1/auth/2fa/enable.');
158
+ // Customers and vendors of the portal (RFC 0015 §5.1): the portal API and signing in, nothing else.
159
+ if (externalUser &&
150
160
  !PORTAL_PATHS.some((p) => request.url.startsWith(p)) &&
151
161
  request.url.startsWith('/api/'))
152
162
  throw new HttpProblem(403, 'internal-only', 'For the company’s users only', 'Portal users reach the portal only.');
@@ -161,7 +171,9 @@ export async function createServer(options) {
161
171
  ? `ip:${request.ip}`
162
172
  : request.identity.apiKey !== undefined
163
173
  ? `key:${request.identity.apiKey}`
164
- : `user:${request.identity.user}`,
174
+ : request.identity.oauthToken !== undefined
175
+ ? `oauth:${request.identity.oauthToken}`
176
+ : `user:${request.identity.user}`,
165
177
  allowList: (request) => request.routeOptions.config.public === true,
166
178
  errorResponseBuilder: (_request, context) => new HttpProblem(429, 'rate-limited', 'Too many requests', `Limit of ${context.max} requests per ${context.after} reached.`, `Retry after ${context.after}.`),
167
179
  });
@@ -201,6 +213,10 @@ export async function createServer(options) {
201
213
  if (auth !== undefined) {
202
214
  registerAuthRoutes(app, auth, { loginLimit: options.auth?.loginLimit ?? 10 });
203
215
  registerApiKeyRoutes(app, auth, registry);
216
+ registerImageRoutes(app, auth);
217
+ registerOAuthRoutes(app, auth, () => shutro, registry, {
218
+ baseUrl: options.oauth?.baseUrl ?? process.env['KOSH_BASE_URL'] ?? 'http://localhost:5173',
219
+ });
204
220
  }
205
221
  // The generated REST API and its OpenAPI document (RFC 0002 §6.3–6.4).
206
222
  for (const statement of idempotencyTableStatements())
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The companies (RFC 0018, RFC 0019 §3.4). The current company's name, number grouping and brand colour (the
3
+ * back-office's accent), and its details: every simple field the installed apps add to the company (address,
4
+ * contacts, tax ids, fiscal year…). Administrators and holders of the Settings / Company access level change them;
5
+ * administrators also add companies, whose defaults (journals, sequences, taxes…) the apps then install.
6
+ *
7
+ * - `GET /api/v1/settings/company`
8
+ * - `PATCH /api/v1/settings/company` `{ name?, numberGrouping?, accentColor?, details? }` (`accentColor`:
9
+ * `#rrggbb` or null; `details`: `{ field: value }`)
10
+ * - `GET /api/v1/settings/companies` every company with its currency and people, the currencies
11
+ * - `POST /api/v1/settings/companies` `{ name, currency }`: a new company (administrators)
12
+ */
13
+ import { type ModuleContext } from '../server/index.js';
14
+ /** One of the company's details: a field an installed app adds. */
15
+ export interface CompanyDetail {
16
+ readonly key: string;
17
+ readonly label: string;
18
+ readonly kind: string;
19
+ readonly readonly: boolean;
20
+ readonly options: readonly {
21
+ readonly value: string;
22
+ readonly label: string;
23
+ }[] | null;
24
+ readonly value: string | number | boolean | null;
25
+ }
26
+ export interface CompanySettings {
27
+ readonly id: number;
28
+ readonly name: string;
29
+ readonly numberGrouping: 'international' | 'lakh';
30
+ readonly accentColor: string | null;
31
+ readonly currency: string;
32
+ readonly details: readonly CompanyDetail[];
33
+ }
34
+ export declare function registerCompanyRoutes(context: ModuleContext): void;