shutro-erp 0.3.1 → 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.
- package/dist/account_portal/.kosh/bindings.js +1 -1
- package/dist/account_portal/.kosh/schema.d.ts +29 -1
- package/dist/account_portal/index.js +3 -2
- package/dist/accounting/.kosh/bindings.js +1 -1
- package/dist/accounting/.kosh/schema.d.ts +29 -1
- package/dist/accounting/index.js +4 -0
- package/dist/assistant/index.js +1 -1
- package/dist/cli/init.js +2 -0
- package/dist/cli/launch.js +8 -5
- package/dist/cli/templates.js +4 -1
- package/dist/cli/ui.js +19 -1
- package/dist/contacts/.kosh/bindings.js +1 -1
- package/dist/contacts/.kosh/schema.d.ts +29 -1
- package/dist/integrations/inbound.d.ts +36 -0
- package/dist/integrations/inbound.js +171 -0
- package/dist/integrations/index.d.ts +18 -0
- package/dist/integrations/index.js +400 -0
- package/dist/integrations/models.d.ts +6 -0
- package/dist/integrations/models.js +119 -0
- package/dist/integrations/web/DeliveryTools.d.ts +4 -0
- package/dist/integrations/web/DeliveryTools.js +15 -0
- package/dist/integrations/web/EndpointTools.d.ts +7 -0
- package/dist/integrations/web/EndpointTools.js +25 -0
- package/dist/integrations/web/InboundTools.d.ts +4 -0
- package/dist/integrations/web/InboundTools.js +15 -0
- package/dist/integrations/web/WebhookTools.d.ts +7 -0
- package/dist/integrations/web/WebhookTools.js +25 -0
- package/dist/integrations/web/index.d.ts +16 -0
- package/dist/integrations/web/index.js +27 -0
- package/dist/integrations/web/messages.d.ts +2 -0
- package/dist/integrations/web/messages.js +53 -0
- package/dist/integrations/webhooks.d.ts +63 -0
- package/dist/integrations/webhooks.js +217 -0
- package/dist/l10n_bd/.kosh/bindings.js +1 -1
- package/dist/l10n_bd/.kosh/schema.d.ts +29 -1
- package/dist/l10n_in/.kosh/bindings.js +1 -1
- package/dist/l10n_in/.kosh/schema.d.ts +29 -4
- package/dist/l10n_in/module.d.ts +0 -62
- package/dist/l10n_in/module.js +2 -4
- package/dist/modules-sdk/index.d.ts +1 -1
- package/dist/modules-sdk/manifest.d.ts +48 -0
- package/dist/payment/.kosh/bindings.d.ts +22 -0
- package/dist/payment/.kosh/bindings.js +15 -0
- package/dist/payment/.kosh/schema.d.ts +1581 -0
- package/dist/payment/bkash.d.ts +28 -0
- package/dist/payment/bkash.js +169 -0
- package/dist/payment/index.d.ts +38 -0
- package/dist/payment/index.js +431 -0
- package/dist/payment/module.d.ts +6856 -0
- package/dist/payment/module.js +77 -0
- package/dist/payment/money.d.ts +7 -0
- package/dist/payment/money.js +37 -0
- package/dist/payment/providers.d.ts +75 -0
- package/dist/payment/providers.js +77 -0
- package/dist/payment/razorpay.d.ts +12 -0
- package/dist/payment/razorpay.js +149 -0
- package/dist/payment/settle.d.ts +11 -0
- package/dist/payment/settle.js +59 -0
- package/dist/payment/sslcommerz.d.ts +12 -0
- package/dist/payment/sslcommerz.js +185 -0
- package/dist/payment/stripe.d.ts +15 -0
- package/dist/payment/stripe.js +182 -0
- package/dist/permissions/admin.d.ts +3 -0
- package/dist/permissions/admin.js +108 -10
- package/dist/permissions/index.d.ts +1 -0
- package/dist/permissions/index.js +3 -0
- package/dist/permissions/roles.d.ts +2 -0
- package/dist/permissions/roles.js +236 -0
- package/dist/portal/.kosh/bindings.js +1 -1
- package/dist/portal/.kosh/schema.d.ts +29 -1
- package/dist/portal/routes-documents.js +5 -0
- package/dist/product/.kosh/bindings.js +1 -1
- package/dist/product/.kosh/schema.d.ts +29 -1
- package/dist/project/.kosh/bindings.js +1 -1
- package/dist/project/.kosh/schema.d.ts +29 -1
- package/dist/project_portal/.kosh/bindings.js +1 -1
- package/dist/project_portal/.kosh/schema.d.ts +29 -1
- package/dist/purchase/.kosh/bindings.js +1 -1
- package/dist/purchase/.kosh/schema.d.ts +29 -1
- package/dist/purchase_account/.kosh/bindings.js +1 -1
- package/dist/purchase_account/.kosh/schema.d.ts +29 -1
- package/dist/purchase_portal/.kosh/bindings.js +1 -1
- package/dist/purchase_portal/.kosh/schema.d.ts +29 -1
- package/dist/sale_account/.kosh/bindings.js +1 -1
- package/dist/sale_account/.kosh/schema.d.ts +29 -1
- package/dist/sale_portal/.kosh/bindings.js +1 -1
- package/dist/sale_portal/.kosh/schema.d.ts +29 -1
- package/dist/sale_project/.kosh/bindings.js +1 -1
- package/dist/sale_project/.kosh/schema.d.ts +29 -1
- package/dist/sales/.kosh/bindings.js +1 -1
- package/dist/sales/.kosh/schema.d.ts +29 -1
- package/dist/sales/index.js +2 -0
- package/dist/server/api/rest.d.ts +6 -1
- package/dist/server/api/rest.js +11 -0
- package/dist/server/auth/base.js +22 -0
- package/dist/server/auth/routes.d.ts +3 -0
- package/dist/server/auth/routes.js +29 -0
- package/dist/server/auth/service.d.ts +52 -0
- package/dist/server/auth/service.js +168 -3
- package/dist/server/identity.d.ts +2 -0
- package/dist/server/index.d.ts +2 -2
- package/dist/server/index.js +1 -1
- package/dist/server/server.js +11 -3
- package/dist/settings/company.d.ts +34 -0
- package/dist/settings/company.js +187 -0
- package/dist/settings/index.d.ts +10 -10
- package/dist/settings/index.js +159 -30
- package/dist/settings/people.d.ts +16 -0
- package/dist/settings/people.js +505 -0
- package/dist/settings/security.d.ts +13 -0
- package/dist/settings/security.js +158 -0
- package/dist/settings/web/AuditPage.d.ts +8 -0
- package/dist/settings/web/AuditPage.js +74 -0
- package/dist/settings/web/CompaniesPage.d.ts +8 -0
- package/dist/settings/web/CompaniesPage.js +52 -0
- package/dist/settings/web/CompanyPage.d.ts +9 -0
- package/dist/settings/web/CompanyPage.js +94 -0
- package/dist/settings/web/OverviewPage.d.ts +9 -0
- package/dist/settings/web/OverviewPage.js +66 -0
- package/dist/settings/web/PeoplePage.d.ts +9 -0
- package/dist/settings/web/PeoplePage.js +163 -0
- package/dist/settings/web/ProfilePage.d.ts +14 -0
- package/dist/settings/web/ProfilePage.js +194 -0
- package/dist/settings/web/RolesEditor.d.ts +35 -0
- package/dist/settings/web/RolesEditor.js +49 -0
- package/dist/settings/web/RolesPage.d.ts +9 -0
- package/dist/settings/web/RolesPage.js +296 -0
- package/dist/settings/web/SecurityPage.d.ts +8 -0
- package/dist/settings/web/SecurityPage.js +81 -0
- package/dist/settings/web/index.d.ts +9 -0
- package/dist/settings/web/index.js +26 -1
- package/dist/settings/web/messages.js +566 -0
- package/dist/ui/api.d.ts +4 -0
- package/dist/ui/app.d.ts +5 -0
- package/dist/ui/app.js +108 -31
- package/dist/ui/components/primitives.d.ts +9 -1
- package/dist/ui/components/primitives.js +12 -4
- package/dist/ui/context.d.ts +10 -1
- package/dist/ui/context.js +60 -2
- package/dist/ui/i18n/messages.d.ts +46 -0
- package/dist/ui/i18n/messages.js +92 -0
- package/dist/ui/index.d.ts +2 -1
- package/dist/ui/index.js +2 -1
- package/dist/ui/lib/accent.d.ts +13 -0
- package/dist/ui/lib/accent.js +36 -0
- package/dist/ui/shell/Activate.d.ts +6 -0
- package/dist/ui/shell/Activate.js +39 -0
- package/dist/ui/shell/CommandPalette.d.ts +7 -1
- package/dist/ui/shell/CommandPalette.js +16 -2
- package/dist/ui/shell/Sidebar.d.ts +27 -0
- package/dist/ui/shell/Sidebar.js +84 -0
- package/dist/ui/shell/TwoFactorSetup.d.ts +5 -0
- package/dist/ui/shell/TwoFactorSetup.js +32 -0
- package/dist/ui/shell/shortcuts.d.ts +3 -0
- package/dist/ui/shell/shortcuts.js +13 -4
- package/dist/ui/styles.css +70 -28
- package/dist/ui/views/DateField.d.ts +10 -0
- package/dist/ui/views/DateField.js +129 -0
- package/dist/ui/views/FormView.js +161 -21
- package/dist/ui/views/ListView.d.ts +2 -0
- package/dist/ui/views/ListView.js +8 -3
- package/dist/ui/views/StatusChip.d.ts +10 -0
- package/dist/ui/views/StatusChip.js +22 -0
- package/dist/ui/views/widgets.d.ts +3 -0
- package/dist/ui/views/widgets.js +26 -8
- package/dist/views/types.d.ts +7 -0
- package/dist/views/validate.js +4 -0
- package/package.json +14 -2
|
@@ -38,6 +38,7 @@ export class AuthService {
|
|
|
38
38
|
#cache = new Map();
|
|
39
39
|
#keyCache = new Map();
|
|
40
40
|
#dummy = hashPassword(randomToken());
|
|
41
|
+
#policy;
|
|
41
42
|
constructor(shutro, options = {}) {
|
|
42
43
|
this.#shutro = shutro;
|
|
43
44
|
this.#options = {
|
|
@@ -95,6 +96,8 @@ export class AuthService {
|
|
|
95
96
|
}
|
|
96
97
|
/** Signs in: a new session (pending second factor when 2FA is on). @throws {HttpProblem} 401 */
|
|
97
98
|
async login(request, login, password) {
|
|
99
|
+
const { sessionDays } = await this.policy();
|
|
100
|
+
const lifetimeMs = sessionDays === null ? this.#options.lifetimeMs : sessionDays * 86_400_000;
|
|
98
101
|
const outcome = await this.#system(`login ${login}`, async (env) => {
|
|
99
102
|
const user = one(await fetch(model(env, 'res.users').search({ where: { login } }), ['login']));
|
|
100
103
|
const credential = user === undefined
|
|
@@ -119,7 +122,7 @@ export class AuthService {
|
|
|
119
122
|
csrfHash: digest(csrfToken),
|
|
120
123
|
user: user['id'],
|
|
121
124
|
twoFactorVerified: !twoFactorRequired,
|
|
122
|
-
expiresAt: new Date(now +
|
|
125
|
+
expiresAt: new Date(now + lifetimeMs),
|
|
123
126
|
lastSeenAt: new Date(now),
|
|
124
127
|
ip: request.ip,
|
|
125
128
|
userAgent: String(request.headers['user-agent'] ?? '').slice(0, 300),
|
|
@@ -230,7 +233,10 @@ export class AuthService {
|
|
|
230
233
|
return null;
|
|
231
234
|
if (session.twoFactorRequired)
|
|
232
235
|
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, {
|
|
236
|
+
return this.#identity(request, session.user, {
|
|
237
|
+
elevated: session.secondFactor,
|
|
238
|
+
session: true,
|
|
239
|
+
});
|
|
234
240
|
}
|
|
235
241
|
async #identity(request, user, options) {
|
|
236
242
|
const info = await this.#identityOf(user);
|
|
@@ -264,6 +270,13 @@ export class AuthService {
|
|
|
264
270
|
tz: info.tz,
|
|
265
271
|
...(options.scopes === undefined ? {} : { scopes: options.scopes }),
|
|
266
272
|
...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }),
|
|
273
|
+
// A session without the second factor the policy asks of everyone (RFC 0019 §3.5): the server allows only
|
|
274
|
+
// setting it up. API keys are not sessions: their owner signed in to make them.
|
|
275
|
+
...(options.session === true &&
|
|
276
|
+
!info.twoFactorEnabled &&
|
|
277
|
+
(await this.policy()).twoFactor === 'everyone'
|
|
278
|
+
? { twoFactorMissing: true }
|
|
279
|
+
: {}),
|
|
267
280
|
};
|
|
268
281
|
}
|
|
269
282
|
/**
|
|
@@ -546,9 +559,13 @@ export class AuthService {
|
|
|
546
559
|
const twoFactorEnabled = credential?.['totpEnabled'] === true;
|
|
547
560
|
const companyIds = user?.['companies'] ?? [];
|
|
548
561
|
const groupings = {};
|
|
562
|
+
const accents = {};
|
|
549
563
|
if (companyIds.length > 0)
|
|
550
|
-
for (const c of await fetch(model(env, 'res.company').search({ where: { id: { in: companyIds } } }), ['numberGrouping']))
|
|
564
|
+
for (const c of await fetch(model(env, 'res.company').search({ where: { id: { in: companyIds } } }), ['numberGrouping', 'accentColor'])) {
|
|
551
565
|
groupings[String(c['id'])] = c['numberGrouping'] === 'lakh' ? 'lakh' : 'international';
|
|
566
|
+
if (typeof c['accentColor'] === 'string' && /^#[0-9a-f]{6}$/i.test(c['accentColor']))
|
|
567
|
+
accents[String(c['id'])] = c['accentColor'];
|
|
568
|
+
}
|
|
552
569
|
return {
|
|
553
570
|
id: session.user,
|
|
554
571
|
login: String(user?.['login']),
|
|
@@ -556,9 +573,11 @@ export class AuthService {
|
|
|
556
573
|
company: typeof user?.['company'] === 'number' ? user['company'] : null,
|
|
557
574
|
companies: companyIds,
|
|
558
575
|
groupings,
|
|
576
|
+
accents,
|
|
559
577
|
twoFactorEnabled,
|
|
560
578
|
administrator,
|
|
561
579
|
administratorNeedsSecondFactor: administrator && !(session.secondFactor && twoFactorEnabled),
|
|
580
|
+
twoFactorSetupRequired: !twoFactorEnabled && (await this.policy()).twoFactor === 'everyone',
|
|
562
581
|
};
|
|
563
582
|
});
|
|
564
583
|
}
|
|
@@ -667,6 +686,8 @@ export class AuthService {
|
|
|
667
686
|
}
|
|
668
687
|
/** Turns 2FA off (password and a current code required). */
|
|
669
688
|
async disableTwoFactor(session, password, code) {
|
|
689
|
+
if ((await this.policy()).twoFactor === 'everyone')
|
|
690
|
+
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
691
|
await this.#system('2fa disable', async (env) => {
|
|
671
692
|
const credentials = await model(env, 'auth.credential').search({
|
|
672
693
|
where: { user: session.user },
|
|
@@ -708,4 +729,148 @@ export class AuthService {
|
|
|
708
729
|
this.#forgetUser(session.user);
|
|
709
730
|
this.#cache.clear(); // other sessions of this user, cached by token
|
|
710
731
|
}
|
|
732
|
+
/** The security policy (cached like sessions; {@link forgetPolicy} after changing it). */
|
|
733
|
+
async policy() {
|
|
734
|
+
const cached = this.#policy;
|
|
735
|
+
if (cached !== undefined && Date.now() - cached.at < this.#options.cacheMs)
|
|
736
|
+
return cached.policy;
|
|
737
|
+
const policy = await this.#system('policy', async (env) => {
|
|
738
|
+
const [row] = await fetch(model(env, 'auth.policy').search({ where: {}, limit: 1 }), [
|
|
739
|
+
'twoFactor',
|
|
740
|
+
'sessionDays',
|
|
741
|
+
]);
|
|
742
|
+
const days = row?.['sessionDays'];
|
|
743
|
+
return {
|
|
744
|
+
twoFactor: row?.['twoFactor'] === 'everyone' ? 'everyone' : 'administrators',
|
|
745
|
+
sessionDays: typeof days === 'number' && days > 0 ? days : null,
|
|
746
|
+
};
|
|
747
|
+
});
|
|
748
|
+
this.#policy = { at: Date.now(), policy };
|
|
749
|
+
return policy;
|
|
750
|
+
}
|
|
751
|
+
/** Forgets the cached policy: after Settings › Security changed it. */
|
|
752
|
+
forgetPolicy() {
|
|
753
|
+
this.#policy = undefined;
|
|
754
|
+
}
|
|
755
|
+
/** Forgets what is cached about `user` (their identity and sessions): after their roles or account changed. */
|
|
756
|
+
forget(user) {
|
|
757
|
+
this.#forgetUser(user);
|
|
758
|
+
this.#cache.clear();
|
|
759
|
+
}
|
|
760
|
+
/** The signed-in user's own details (RFC 0019 §3.6). */
|
|
761
|
+
async profile(session) {
|
|
762
|
+
return this.#system('profile', async (env) => {
|
|
763
|
+
const [row] = await fetch(model(env, 'res.users').browse([session.user]), PROFILE_KEYS);
|
|
764
|
+
return {
|
|
765
|
+
login: String(row?.['login'] ?? ''),
|
|
766
|
+
name: String(row?.['name'] ?? ''),
|
|
767
|
+
email: typeof row?.['email'] === 'string' ? row['email'] : null,
|
|
768
|
+
phone: typeof row?.['phone'] === 'string' ? row['phone'] : null,
|
|
769
|
+
lang: String(row?.['lang'] ?? 'en_US'),
|
|
770
|
+
tz: String(row?.['tz'] ?? 'UTC'),
|
|
771
|
+
company: typeof row?.['company'] === 'number' ? row['company'] : null,
|
|
772
|
+
};
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Changes the user's own details: name, email, phone, language, time zone, and the default company (one of
|
|
777
|
+
* theirs). Audited.
|
|
778
|
+
*/
|
|
779
|
+
async updateProfile(session, values) {
|
|
780
|
+
const changes = {};
|
|
781
|
+
if (values.name !== undefined) {
|
|
782
|
+
const name = values.name.trim();
|
|
783
|
+
if (name === '' || name.length > 200)
|
|
784
|
+
throw new HttpProblem(422, 'invalid-name', 'Invalid name', 'Your name is 1–200 characters.');
|
|
785
|
+
changes['name'] = name;
|
|
786
|
+
}
|
|
787
|
+
if (values.email !== undefined) {
|
|
788
|
+
const email = values.email === null ? '' : values.email.trim();
|
|
789
|
+
if (email !== '' && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email))
|
|
790
|
+
throw new HttpProblem(422, 'invalid-email', 'Invalid email', `'${email}' is not an email address.`);
|
|
791
|
+
changes['email'] = email === '' ? null : email;
|
|
792
|
+
}
|
|
793
|
+
if (values.phone !== undefined)
|
|
794
|
+
changes['phone'] = values.phone?.trim() || null;
|
|
795
|
+
if (values.lang !== undefined) {
|
|
796
|
+
if (!/^[a-z]{2,3}(_[A-Z]{2})?$/.test(values.lang))
|
|
797
|
+
throw new HttpProblem(422, 'invalid-lang', 'Invalid language', 'A language code such as en_US or bn_BD.');
|
|
798
|
+
changes['lang'] = values.lang;
|
|
799
|
+
}
|
|
800
|
+
if (values.tz !== undefined) {
|
|
801
|
+
try {
|
|
802
|
+
new Intl.DateTimeFormat('en', { timeZone: values.tz });
|
|
803
|
+
}
|
|
804
|
+
catch {
|
|
805
|
+
throw new HttpProblem(422, 'invalid-tz', 'Invalid time zone', `'${values.tz}' is not a time zone.`);
|
|
806
|
+
}
|
|
807
|
+
changes['tz'] = values.tz;
|
|
808
|
+
}
|
|
809
|
+
if (values.company !== undefined && values.company !== null) {
|
|
810
|
+
const info = await this.#identityOf(session.user);
|
|
811
|
+
if (info === null || !info.companies.includes(values.company))
|
|
812
|
+
throw new HttpProblem(422, 'invalid-company', 'Invalid company', 'Choose one of your companies.');
|
|
813
|
+
changes['company'] = values.company;
|
|
814
|
+
}
|
|
815
|
+
if (Object.keys(changes).length > 0)
|
|
816
|
+
await this.#system('profile change', async (env) => {
|
|
817
|
+
await model(env, 'res.users').browse([session.user]).write(changes);
|
|
818
|
+
await audit(env, {
|
|
819
|
+
kind: 'auth.profile_changed',
|
|
820
|
+
summary: `Changed ${Object.keys(changes).join(', ')}.`,
|
|
821
|
+
actor: session.user,
|
|
822
|
+
});
|
|
823
|
+
});
|
|
824
|
+
this.#forgetUser(session.user);
|
|
825
|
+
return this.profile(session);
|
|
826
|
+
}
|
|
827
|
+
/** The user's active sessions, newest use first; the current one marked. */
|
|
828
|
+
async sessions(session) {
|
|
829
|
+
return this.#system('sessions', async (env) => {
|
|
830
|
+
const rows = await fetch(model(env, 'auth.session').search({
|
|
831
|
+
where: { user: session.user, revoked: false, expiresAt: { gt: new Date() } },
|
|
832
|
+
order: ['lastSeenAt desc', 'id desc'],
|
|
833
|
+
limit: 50,
|
|
834
|
+
}), ['id', 'ip', 'userAgent', 'lastSeenAt', 'createdAt', 'secondFactor']);
|
|
835
|
+
return rows.map((r) => ({
|
|
836
|
+
id: r['id'],
|
|
837
|
+
ip: typeof r['ip'] === 'string' ? r['ip'] : null,
|
|
838
|
+
userAgent: typeof r['userAgent'] === 'string' ? r['userAgent'] : null,
|
|
839
|
+
lastSeenAt: new Date(timeOf(r['lastSeenAt']) ?? 0).toISOString(),
|
|
840
|
+
createdAt: timeOf(r['createdAt']) === null
|
|
841
|
+
? null
|
|
842
|
+
: new Date(timeOf(r['createdAt'])).toISOString(),
|
|
843
|
+
secondFactor: r['secondFactor'] === true,
|
|
844
|
+
current: r['id'] === session.id,
|
|
845
|
+
}));
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
/** Signs out one of the user's own sessions (or all but this one with `'others'`). Audited. */
|
|
849
|
+
async revokeSessions(session, which) {
|
|
850
|
+
const revoked = await this.#system('sign out a session', async (env) => {
|
|
851
|
+
const set = await model(env, 'auth.session').search({
|
|
852
|
+
where: {
|
|
853
|
+
user: session.user,
|
|
854
|
+
revoked: false,
|
|
855
|
+
...(which === 'others' ? { id: { ne: session.id } } : { id: which }),
|
|
856
|
+
},
|
|
857
|
+
});
|
|
858
|
+
if (which !== 'others' && set.length === 0)
|
|
859
|
+
throw new HttpProblem(404, 'missing-record', 'Not found', `You have no session ${which}.`);
|
|
860
|
+
if (set.length > 0)
|
|
861
|
+
await set.write({ revoked: true });
|
|
862
|
+
await audit(env, {
|
|
863
|
+
kind: 'auth.session_revoked',
|
|
864
|
+
summary: which === 'others'
|
|
865
|
+
? `Signed out ${set.length} other session(s).`
|
|
866
|
+
: `Signed out session ${which}.`,
|
|
867
|
+
actor: session.user,
|
|
868
|
+
});
|
|
869
|
+
return set.length;
|
|
870
|
+
});
|
|
871
|
+
this.#forgetUser(session.user);
|
|
872
|
+
this.#cache.clear();
|
|
873
|
+
return { revoked };
|
|
874
|
+
}
|
|
711
875
|
}
|
|
876
|
+
const PROFILE_KEYS = ['login', 'name', 'email', 'phone', 'lang', 'tz', 'company'];
|
|
@@ -24,6 +24,8 @@ export interface RequestIdentity {
|
|
|
24
24
|
readonly scopes?: readonly string[];
|
|
25
25
|
/** The API key the request carries. */
|
|
26
26
|
readonly apiKey?: number;
|
|
27
|
+
/** A session without the second factor the security policy asks of everyone (RFC 0019 §3.5). */
|
|
28
|
+
readonly twoFactorMissing?: true;
|
|
27
29
|
}
|
|
28
30
|
/**
|
|
29
31
|
* The single explicit bypass (CLAUDE.md "superuser mode", RFC 0003 §5.2): the user holds an administrator role
|
package/dist/server/index.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export { IDEMPOTENCY_TABLE, idempotencyTableStatements, idempotent, type Outcome
|
|
|
9
9
|
export { type ApiField, type ApiModel, apiModels, defaultFields, publicApiModels, schemaName, } from './api/metadata.js';
|
|
10
10
|
export { type FoundRecord, type MenuNode, moduleVisible, Navigation } from './api/navigation.js';
|
|
11
11
|
export { fieldSchema, openApiDocument, openApiOf } from './api/openapi.js';
|
|
12
|
-
export { registerRestApi } from './api/rest.js';
|
|
12
|
+
export { readApiRecord, registerRestApi } from './api/rest.js';
|
|
13
13
|
export { type ServedView, type ViewField, ViewRegistry } from './api/views.js';
|
|
14
14
|
export { APPS_CHANNEL, APPS_LOG_TABLE, APPS_TABLE, type AppLoadContext, type ApplyAppsOptions, type AppsResult, applyApps, appsDigest, appTableStatements, FRAMEWORK_TABLES, installDefaults, installedApps, installedModules, type PrepareAppsOptions, type PreparedApps, prepareApps, readInstalledApps, recordInstalledApps, restoreAppMigrations, } from './apps.js';
|
|
15
15
|
export { type AuditInput, audit } from './audit.js';
|
|
@@ -18,7 +18,7 @@ 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';
|
package/dist/server/index.js
CHANGED
|
@@ -9,7 +9,7 @@ export { IDEMPOTENCY_TABLE, idempotencyTableStatements, idempotent, } from './ap
|
|
|
9
9
|
export { apiModels, defaultFields, publicApiModels, schemaName, } from './api/metadata.js';
|
|
10
10
|
export { moduleVisible, Navigation } from './api/navigation.js';
|
|
11
11
|
export { fieldSchema, openApiDocument, openApiOf } from './api/openapi.js';
|
|
12
|
-
export { registerRestApi } from './api/rest.js';
|
|
12
|
+
export { readApiRecord, registerRestApi } from './api/rest.js';
|
|
13
13
|
export { ViewRegistry } from './api/views.js';
|
|
14
14
|
export { APPS_CHANNEL, APPS_LOG_TABLE, APPS_TABLE, applyApps, appsDigest, appTableStatements, FRAMEWORK_TABLES, installDefaults, installedApps, installedModules, prepareApps, readInstalledApps, recordInstalledApps, restoreAppMigrations, } from './apps.js';
|
|
15
15
|
export { audit } from './audit.js';
|
package/dist/server/server.js
CHANGED
|
@@ -142,11 +142,19 @@ export async function createServer(options) {
|
|
|
142
142
|
: null);
|
|
143
143
|
enforceScopes(scopes, request.method, route, owner);
|
|
144
144
|
}
|
|
145
|
-
|
|
146
|
-
if (request.identity !== null &&
|
|
145
|
+
const externalUser = request.identity !== null &&
|
|
147
146
|
external.size > 0 &&
|
|
148
147
|
request.identity.groups.length > 0 &&
|
|
149
|
-
request.identity.groups.every((g) => external.has(g))
|
|
148
|
+
request.identity.groups.every((g) => external.has(g));
|
|
149
|
+
// Two-factor sign-in required of everyone (RFC 0019 §3.5): until it is set up, only the sign-in routes (setting
|
|
150
|
+
// it up, signing out, who am I). Portal users are not the company's people: the policy is not theirs.
|
|
151
|
+
if (request.identity?.twoFactorMissing === true &&
|
|
152
|
+
!externalUser &&
|
|
153
|
+
request.url.startsWith('/api/') &&
|
|
154
|
+
!request.url.startsWith('/api/v1/auth/'))
|
|
155
|
+
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.');
|
|
156
|
+
// Customers and vendors of the portal (RFC 0015 §5.1): the portal API and signing in, nothing else.
|
|
157
|
+
if (externalUser &&
|
|
150
158
|
!PORTAL_PATHS.some((p) => request.url.startsWith(p)) &&
|
|
151
159
|
request.url.startsWith('/api/'))
|
|
152
160
|
throw new HttpProblem(403, 'internal-only', 'For the company’s users only', 'Portal users reach the portal only.');
|
|
@@ -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;
|
|
@@ -0,0 +1,187 @@
|
|
|
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 { audit, HttpProblem, installDefaults, publicApiModels, } from '../server/index.js';
|
|
14
|
+
const model = (env, name) => env.model(name);
|
|
15
|
+
const find = async (env, name, where, keys) => {
|
|
16
|
+
const set = await model(env, name).search({ where, order: ['id asc'] });
|
|
17
|
+
return set.length === 0 ? [] : (await set.load(keys)).read(['id', ...keys]);
|
|
18
|
+
};
|
|
19
|
+
const KEYS = ['name', 'numberGrouping', 'accentColor', 'currency'];
|
|
20
|
+
/** Fields with controls of their own, or not the person's to edit. */
|
|
21
|
+
const OWN = new Set([...KEYS, 'id', 'displayName']);
|
|
22
|
+
const SIMPLE = new Set(['char', 'text', 'integer', 'boolean', 'selection']);
|
|
23
|
+
/** Shown, not changed here: an app's own flow sets them (the chart of accounts of a localization). */
|
|
24
|
+
const SHOWN_ONLY = new Set(['chartTemplate']);
|
|
25
|
+
export function registerCompanyRoutes(context) {
|
|
26
|
+
const { app, registry } = context;
|
|
27
|
+
/** The company's details: its stored simple fields, as the generated API serves them. */
|
|
28
|
+
const detailFields = () => (publicApiModels(registry).get('res.company')?.fields ?? []).filter((f) => f.stored && SIMPLE.has(f.kind) && !OWN.has(f.key));
|
|
29
|
+
const read = async (env, id) => {
|
|
30
|
+
const fields = detailFields();
|
|
31
|
+
const keys = [...KEYS, ...fields.map((f) => f.key)];
|
|
32
|
+
const [row] = (await model(env, 'res.company').browse([id]).load(keys)).read(['id', ...keys]);
|
|
33
|
+
const currency = row?.['currency'];
|
|
34
|
+
const [money] = typeof currency === 'number'
|
|
35
|
+
? await find(env, 'res.currency', { id: currency }, ['name'])
|
|
36
|
+
: [];
|
|
37
|
+
return {
|
|
38
|
+
id,
|
|
39
|
+
name: String(row?.['name'] ?? ''),
|
|
40
|
+
numberGrouping: row?.['numberGrouping'] === 'lakh' ? 'lakh' : 'international',
|
|
41
|
+
accentColor: typeof row?.['accentColor'] === 'string' ? row['accentColor'] : null,
|
|
42
|
+
currency: String(money?.['name'] ?? ''),
|
|
43
|
+
details: fields.map((f) => ({
|
|
44
|
+
key: f.key,
|
|
45
|
+
label: f.label,
|
|
46
|
+
kind: f.kind,
|
|
47
|
+
readonly: f.readonly || SHOWN_ONLY.has(f.key),
|
|
48
|
+
options: f.options?.map((o) => ({ value: String(o[0]), label: String(o[1]) })) ?? null,
|
|
49
|
+
value: (row?.[f.key] ?? null),
|
|
50
|
+
})),
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
const guarded = (request, fn, write = false) => request.env(async (env) => {
|
|
54
|
+
if (!env.administrator && !env.groups.has('settings.company'))
|
|
55
|
+
throw new HttpProblem(403, 'access', 'Company settings', 'Changing the company needs the Administrator role or the Settings / Company access.');
|
|
56
|
+
if (env.companyId === null || env.companyId === undefined)
|
|
57
|
+
throw new HttpProblem(409, 'no-company', 'No company', 'Choose a company first.');
|
|
58
|
+
return env.asSuperuser({ reason: 'company settings' }, async (su) => {
|
|
59
|
+
const out = await fn(su, env.companyId, env);
|
|
60
|
+
if (write)
|
|
61
|
+
await su.flush();
|
|
62
|
+
return out;
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
// The superuser scope is audited: even a read writes its audit record.
|
|
66
|
+
{ write: true });
|
|
67
|
+
app.get('/api/v1/settings/company', async (request) => guarded(request, (su, company) => read(su, company)));
|
|
68
|
+
app.patch('/api/v1/settings/company', async (request) => {
|
|
69
|
+
const body = (request.body ?? {});
|
|
70
|
+
const values = {};
|
|
71
|
+
const invalid = (code, title, detail) => new HttpProblem(422, code, title, detail);
|
|
72
|
+
if (body['name'] !== undefined) {
|
|
73
|
+
const name = String(body['name']).trim();
|
|
74
|
+
if (name === '')
|
|
75
|
+
throw invalid('invalid-name', 'Invalid name', 'The company needs a name.');
|
|
76
|
+
values['name'] = name;
|
|
77
|
+
}
|
|
78
|
+
if (body['numberGrouping'] !== undefined) {
|
|
79
|
+
if (body['numberGrouping'] !== 'international' && body['numberGrouping'] !== 'lakh')
|
|
80
|
+
throw invalid('invalid-grouping', 'Invalid grouping', "Number grouping is 'international' or 'lakh'.");
|
|
81
|
+
values['numberGrouping'] = body['numberGrouping'];
|
|
82
|
+
}
|
|
83
|
+
if (body['accentColor'] !== undefined) {
|
|
84
|
+
const color = body['accentColor'];
|
|
85
|
+
if (color !== null && !(typeof color === 'string' && /^#[0-9a-fA-F]{6}$/.test(color)))
|
|
86
|
+
throw invalid('invalid-color', 'Invalid colour', 'The brand colour is a hex colour such as #4f46e5, or empty for the default.');
|
|
87
|
+
values['accentColor'] = color === null ? null : color.toLowerCase();
|
|
88
|
+
}
|
|
89
|
+
// The details: only the fields offered, each with a value of its kind (Shutro checks sizes and constraints).
|
|
90
|
+
const details = body['details'];
|
|
91
|
+
if (details !== undefined) {
|
|
92
|
+
if (typeof details !== 'object' || details === null || Array.isArray(details))
|
|
93
|
+
throw invalid('invalid-details', 'Invalid details', 'details is { field: value }.');
|
|
94
|
+
const offered = new Map(detailFields().map((f) => [f.key, f]));
|
|
95
|
+
for (const [key, value] of Object.entries(details)) {
|
|
96
|
+
const f = offered.get(key);
|
|
97
|
+
if (f === undefined || f.readonly || SHOWN_ONLY.has(key))
|
|
98
|
+
throw invalid('invalid-details', 'Invalid details', `'${key}' is not changed here.`);
|
|
99
|
+
const fits = value === null ||
|
|
100
|
+
(f.kind === 'boolean'
|
|
101
|
+
? typeof value === 'boolean'
|
|
102
|
+
: f.kind === 'integer'
|
|
103
|
+
? Number.isSafeInteger(value)
|
|
104
|
+
: f.kind === 'selection'
|
|
105
|
+
? (f.options ?? []).some((o) => o[0] === value)
|
|
106
|
+
: typeof value === 'string' && value.length <= 4000);
|
|
107
|
+
if (!fits)
|
|
108
|
+
throw invalid('invalid-details', 'Invalid details', `'${f.label}' has an invalid value.`);
|
|
109
|
+
values[key] = typeof value === 'string' && value.trim() === '' ? null : value;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return guarded(request, async (su, company) => {
|
|
113
|
+
if (Object.keys(values).length > 0)
|
|
114
|
+
await model(su, 'res.company').browse([company]).write(values);
|
|
115
|
+
return read(su, company);
|
|
116
|
+
}, true);
|
|
117
|
+
});
|
|
118
|
+
app.get('/api/v1/settings/companies', async (request) => guarded(request, async (su, current, env) => {
|
|
119
|
+
const all = await find(su, 'res.company', {}, ['name', 'currency']);
|
|
120
|
+
const currencies = await find(su, 'res.currency', {}, ['name']);
|
|
121
|
+
const currencyName = new Map(currencies.map((c) => [c['id'], String(c['name'])]));
|
|
122
|
+
return {
|
|
123
|
+
canCreate: env.administrator,
|
|
124
|
+
companies: (await Promise.all(all.map(async (c) => ({
|
|
125
|
+
id: c['id'],
|
|
126
|
+
name: String(c['name']),
|
|
127
|
+
currency: currencyName.get(c['currency']) ?? '',
|
|
128
|
+
people: (await model(su, 'res.users').search({
|
|
129
|
+
where: { isService: { ne: true }, companies: { any: { id: c['id'] } } },
|
|
130
|
+
})).length,
|
|
131
|
+
current: c['id'] === current,
|
|
132
|
+
// The person can switch to it: it is one of their companies.
|
|
133
|
+
member: (await model(su, 'res.users').search({
|
|
134
|
+
where: { id: env.uid, companies: { any: { id: c['id'] } } },
|
|
135
|
+
})).length > 0,
|
|
136
|
+
})))).sort((a, b) => a.name.localeCompare(b.name)),
|
|
137
|
+
currencies: currencies
|
|
138
|
+
.map((c) => ({ id: c['id'], name: String(c['name']) }))
|
|
139
|
+
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
140
|
+
};
|
|
141
|
+
}));
|
|
142
|
+
app.post('/api/v1/settings/companies', async (request, reply) => {
|
|
143
|
+
const body = (request.body ?? {});
|
|
144
|
+
const name = String(body['name'] ?? '').trim();
|
|
145
|
+
const currency = Number(body['currency']);
|
|
146
|
+
if (name === '' || name.length > 200)
|
|
147
|
+
throw new HttpProblem(422, 'invalid-name', 'Invalid name', 'The company needs a name.');
|
|
148
|
+
if (!Number.isSafeInteger(currency) || currency <= 0)
|
|
149
|
+
throw new HttpProblem(422, 'invalid-currency', 'Invalid currency', 'Choose its currency.');
|
|
150
|
+
const made = await request.env(async (env) => {
|
|
151
|
+
if (!env.administrator)
|
|
152
|
+
throw new HttpProblem(403, 'access', 'Administrators only', 'Only an administrator adds companies.');
|
|
153
|
+
return env.asSuperuser({ reason: 'new company' }, async (su) => {
|
|
154
|
+
if ((await find(su, 'res.currency', { id: currency }, ['name'])).length === 0)
|
|
155
|
+
throw new HttpProblem(422, 'invalid-currency', 'Invalid currency', 'Choose its currency.');
|
|
156
|
+
const company = (await model(su, 'res.company').create([{ name, currency }]))
|
|
157
|
+
.ids[0];
|
|
158
|
+
// The administrators and the person who made it can switch to it at once.
|
|
159
|
+
const admins = [...su.access.administratorGroups()];
|
|
160
|
+
const holders = admins.length === 0
|
|
161
|
+
? []
|
|
162
|
+
: await find(su, 'auth.user_group', { group: { in: admins } }, ['user']);
|
|
163
|
+
const users = [...new Set([env.uid, ...holders.map((h) => h['user'])])];
|
|
164
|
+
await model(su, 'res.users')
|
|
165
|
+
.browse(users)
|
|
166
|
+
.write({ companies: { link: [company] } });
|
|
167
|
+
await audit(env, {
|
|
168
|
+
kind: 'settings.company_created',
|
|
169
|
+
summary: `Company ${name} created.`,
|
|
170
|
+
targetModel: 'res.company',
|
|
171
|
+
targetId: company,
|
|
172
|
+
ip: request.ip,
|
|
173
|
+
});
|
|
174
|
+
await su.flush();
|
|
175
|
+
return { company, users };
|
|
176
|
+
});
|
|
177
|
+
}, { write: true });
|
|
178
|
+
for (const user of made.users)
|
|
179
|
+
context.auth?.forget(user);
|
|
180
|
+
// The apps' defaults for it, once it exists (after the commit), as for a company made at install.
|
|
181
|
+
const defaults = await installDefaults(context.shutro, registry.loaded.modules, {
|
|
182
|
+
company: made.company,
|
|
183
|
+
reason: `defaults of company ${name}`,
|
|
184
|
+
});
|
|
185
|
+
return reply.code(201).send({ id: made.company, name, defaults });
|
|
186
|
+
});
|
|
187
|
+
}
|
package/dist/settings/index.d.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
|
|
2
|
-
* @packageDocumentation
|
|
3
|
-
* `shutro-erp/settings` (RFC 0008): the Settings app. Its App manager lists the installed, disabled and available apps and
|
|
4
|
-
* installs, enables or disables them (`/api/v1/settings/apps`, the Settings → Apps page). Every installation has it;
|
|
5
|
-
* it cannot be disabled. Changing apps needs the Administrator role or the `settings.apps` access level.
|
|
6
|
-
*/
|
|
7
|
-
import { defineModule } from '../modules-sdk/index.js';
|
|
1
|
+
import { type KoshModule } from '../modules-sdk/index.js';
|
|
8
2
|
import { orm } from '../server/orm.js';
|
|
3
|
+
import { type PeopleOptions } from './people.js';
|
|
4
|
+
export { type CompanyDetail, type CompanySettings, registerCompanyRoutes } from './company.js';
|
|
5
|
+
export { type Assignment, INVITATION_DAYS, type PeopleOptions, registerPeopleRoutes, } from './people.js';
|
|
9
6
|
export { type AppInfo, type PlanInfo, PROTECTED_APPS, registerAppRoutes } from './routes.js';
|
|
7
|
+
export { registerSecurityRoutes } from './security.js';
|
|
10
8
|
/** Package identity. */
|
|
11
9
|
export declare const PACKAGE: {
|
|
12
10
|
readonly name: string;
|
|
13
11
|
readonly rfc: string;
|
|
14
12
|
};
|
|
15
|
-
/** The Shutro module: the
|
|
13
|
+
/** The Shutro module: the Settings groups and invitations. */
|
|
16
14
|
export declare const settingsShutro: ReturnType<typeof orm.defineModule>;
|
|
17
|
-
/** The Settings app. */
|
|
18
|
-
export declare
|
|
15
|
+
/** The Settings app with explicit options (tests): the back-office's address and the mailer of invitations. */
|
|
16
|
+
export declare function settingsModule(options?: Partial<PeopleOptions>): KoshModule;
|
|
17
|
+
/** The Settings app (the back-office's address and the mailer from the environment). */
|
|
18
|
+
export declare const settings: KoshModule;
|