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.
- package/dist/account_portal/.kosh/bindings.js +1 -1
- package/dist/account_portal/.kosh/schema.d.ts +29 -1
- package/dist/accounting/.kosh/bindings.js +1 -1
- package/dist/accounting/.kosh/schema.d.ts +29 -1
- package/dist/assistant/index.js +1 -1
- package/dist/contacts/.kosh/bindings.js +1 -1
- package/dist/contacts/.kosh/schema.d.ts +29 -1
- 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/payment/.kosh/bindings.js +1 -1
- package/dist/payment/.kosh/schema.d.ts +30 -2
- package/dist/payment/bkash.d.ts +28 -0
- package/dist/payment/bkash.js +169 -0
- package/dist/payment/index.d.ts +4 -0
- package/dist/payment/index.js +80 -24
- package/dist/payment/module.d.ts +7 -4
- package/dist/payment/module.js +3 -0
- package/dist/payment/money.d.ts +7 -0
- package/dist/payment/money.js +37 -0
- package/dist/payment/providers.d.ts +13 -2
- package/dist/payment/razorpay.d.ts +12 -0
- package/dist/payment/razorpay.js +149 -0
- package/dist/payment/sslcommerz.js +1 -1
- 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/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/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 +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 +2 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// GENERATED by `pnpm generate:models` (Shutro’s generator). Do not edit. definitions-hash:
|
|
1
|
+
// GENERATED by `pnpm generate:models` (Shutro’s generator). Do not edit. definitions-hash: 2d9d91e59673d178
|
|
2
2
|
// Schema view of module `sales` (RFC 0002 §7.7): the models it owns or depends on, with the extensions it can see.
|
|
3
3
|
import type { F } from '../../server/orm.js';
|
|
4
4
|
|
|
@@ -8,6 +8,7 @@ export interface SchemaView {
|
|
|
8
8
|
readonly 'audit.event': AuditEvent;
|
|
9
9
|
readonly 'auth.api_key': AuthApiKey;
|
|
10
10
|
readonly 'auth.credential': AuthCredential;
|
|
11
|
+
readonly 'auth.policy': AuthPolicy;
|
|
11
12
|
readonly 'auth.session': AuthSession;
|
|
12
13
|
readonly 'auth.user_grant': AuthUserGrant;
|
|
13
14
|
readonly 'auth.user_group': AuthUserGroup;
|
|
@@ -136,6 +137,26 @@ export interface AuthCredential {
|
|
|
136
137
|
export interface AuthCredentialMethods extends F.Methods<typeof import('../../server/index.js').baseShutro, 'auth.credential'> {
|
|
137
138
|
}
|
|
138
139
|
|
|
140
|
+
/** `auth.policy` (owner: base). */
|
|
141
|
+
export interface AuthPolicy {
|
|
142
|
+
readonly name: 'auth.policy';
|
|
143
|
+
readonly fields: {
|
|
144
|
+
readonly id: F.Id<'auth.policy'>;
|
|
145
|
+
readonly createdAt: F.Datetime<{ readonly required: true; readonly readonly: true }>;
|
|
146
|
+
readonly updatedAt: F.Datetime<{ readonly required: true; readonly readonly: true }>;
|
|
147
|
+
readonly createdBy: F.Many2one<'res.users', { readonly readonly: true }>;
|
|
148
|
+
readonly updatedBy: F.Many2one<'res.users', { readonly readonly: true }>;
|
|
149
|
+
readonly displayName: F.Char<{ readonly required: true; readonly readonly: true; readonly compute: true }>;
|
|
150
|
+
readonly twoFactor: F.Selection<'administrators' | 'everyone', { readonly required: true; readonly default: true }>;
|
|
151
|
+
readonly sessionDays: F.Integer;
|
|
152
|
+
};
|
|
153
|
+
readonly methods: AuthPolicyMethods;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Methods of `auth.policy`: declared ones and transitions. */
|
|
157
|
+
export interface AuthPolicyMethods extends F.Methods<typeof import('../../server/index.js').baseShutro, 'auth.policy'> {
|
|
158
|
+
}
|
|
159
|
+
|
|
139
160
|
/** `auth.session` (owner: base). */
|
|
140
161
|
export interface AuthSession {
|
|
141
162
|
readonly name: 'auth.session';
|
|
@@ -477,6 +498,13 @@ export interface ResCompany {
|
|
|
477
498
|
readonly name: F.Char<{ readonly required: true }>;
|
|
478
499
|
readonly currency: F.Many2one<'res.currency', { readonly required: true }>;
|
|
479
500
|
readonly numberGrouping: F.Selection<'international' | 'lakh', { readonly required: true; readonly default: true }>;
|
|
501
|
+
readonly accentColor: F.Char;
|
|
502
|
+
readonly street: F.Char;
|
|
503
|
+
readonly city: F.Char;
|
|
504
|
+
readonly zip: F.Char;
|
|
505
|
+
readonly email: F.Char;
|
|
506
|
+
readonly phone: F.Char;
|
|
507
|
+
readonly website: F.Char;
|
|
480
508
|
};
|
|
481
509
|
readonly methods: ResCompanyMethods;
|
|
482
510
|
}
|
package/dist/server/auth/base.js
CHANGED
|
@@ -22,6 +22,15 @@ const ResCompany = defineModel('res.company', {
|
|
|
22
22
|
currency: f.many2one('res.currency', { required: true }),
|
|
23
23
|
/** How numbers are grouped on screen and in documents (RFC 0016): 1,234,567 or 12,34,567 (lakh and crore). */
|
|
24
24
|
numberGrouping: f.selection({ international: 'International (1,234,567)', lakh: 'Lakh and crore (12,34,567)' }, { required: true, default: 'international', label: 'Number grouping' }),
|
|
25
|
+
/** The company's brand colour (`#4f46e5`), the back-office's accent (RFC 0018); the default when empty. */
|
|
26
|
+
accentColor: f.char({ size: 7, label: 'Brand colour' }),
|
|
27
|
+
/** Its address and how to reach it (RFC 0019 §3.4): on documents, e-invoices and the portal. */
|
|
28
|
+
street: f.char({ label: 'Address' }),
|
|
29
|
+
city: f.char({ label: 'City' }),
|
|
30
|
+
zip: f.char({ size: 12, label: 'Postal code' }),
|
|
31
|
+
email: f.char({ label: 'Email' }),
|
|
32
|
+
phone: f.char({ label: 'Phone' }),
|
|
33
|
+
website: f.char({ label: 'Website' }),
|
|
25
34
|
},
|
|
26
35
|
});
|
|
27
36
|
const readRows = async (env, model, ids, keys) => env.asSuperuser({ reason: 'check administrators' }, async (su) => {
|
|
@@ -127,6 +136,17 @@ const AuthUserGrant = defineModel('auth.user_grant', {
|
|
|
127
136
|
reason: f.char({ required: true }),
|
|
128
137
|
},
|
|
129
138
|
});
|
|
139
|
+
/**
|
|
140
|
+
* The installation's security policy (RFC 0019 §3.5): one row, read by the auth service, changed in Settings ›
|
|
141
|
+
* Security. Without a row: two-factor sign-in for administrators only, sessions as the server is configured.
|
|
142
|
+
*/
|
|
143
|
+
const AuthPolicy = defineModel('auth.policy', {
|
|
144
|
+
fields: {
|
|
145
|
+
twoFactor: f.selection({ administrators: 'Administrators', everyone: 'Everyone' }, { required: true, default: 'administrators', label: 'Two-factor sign-in required for' }),
|
|
146
|
+
/** Days a sign-in lasts at most; empty: the server's setting. */
|
|
147
|
+
sessionDays: f.integer({ label: 'Session lifetime (days)' }),
|
|
148
|
+
},
|
|
149
|
+
});
|
|
130
150
|
/**
|
|
131
151
|
* The security audit log (CLAUDE.md: logins, permission changes, exports, sensitive actions). Append-only: no one
|
|
132
152
|
* may change or delete events (administrators included: the REST API never serves it, and the admin API only reads).
|
|
@@ -196,6 +216,7 @@ export const baseShutro = defineModule({
|
|
|
196
216
|
AuthApiKey,
|
|
197
217
|
AuthUserGroup,
|
|
198
218
|
AuthUserGrant,
|
|
219
|
+
AuthPolicy,
|
|
199
220
|
AuditEvent,
|
|
200
221
|
SavedView,
|
|
201
222
|
],
|
|
@@ -214,6 +235,7 @@ export const base = defineKoshModule({
|
|
|
214
235
|
'auth.api_key',
|
|
215
236
|
'auth.user_group',
|
|
216
237
|
'auth.user_grant',
|
|
238
|
+
'auth.policy',
|
|
217
239
|
'audit.event',
|
|
218
240
|
],
|
|
219
241
|
});
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
* - `POST /api/v1/auth/2fa/verify` `{ code }` (TOTP or recovery code) completes a pending sign-in
|
|
11
11
|
* - `POST /api/v1/auth/2fa/disable` `{ password, code }`
|
|
12
12
|
* - `POST /api/v1/auth/password` `{ current, next }`; other sessions are revoked
|
|
13
|
+
* - `GET /api/v1/auth/profile` your details (RFC 0019); `PATCH` `{ name?, email?, phone?, lang?, tz?, company? }`
|
|
14
|
+
* - `GET /api/v1/auth/sessions` your signed-in devices; `DELETE …/sessions/:id` signs one out
|
|
15
|
+
* - `POST /api/v1/auth/sessions/sign-out-others`
|
|
13
16
|
*/
|
|
14
17
|
import type { FastifyInstance } from 'fastify';
|
|
15
18
|
import type { AuthService } from './service.js';
|
|
@@ -52,4 +52,33 @@ export function registerAuthRoutes(app, auth, options = {}) {
|
|
|
52
52
|
await auth.changePassword(await auth.requireSession(request), text(request, 'current'), text(request, 'next'));
|
|
53
53
|
return { ok: true };
|
|
54
54
|
});
|
|
55
|
+
// Your own profile and sessions (RFC 0019 §3.6): a session's, never an API key's.
|
|
56
|
+
app.get('/api/v1/auth/profile', { config: { auth: false } }, async (request) => auth.profile(await auth.requireSession(request)));
|
|
57
|
+
app.patch('/api/v1/auth/profile', { config: { auth: false } }, async (request) => {
|
|
58
|
+
const body = (request.body ?? {});
|
|
59
|
+
const values = {};
|
|
60
|
+
for (const key of ['name', 'email', 'phone', 'lang', 'tz'])
|
|
61
|
+
if (body[key] !== undefined) {
|
|
62
|
+
if (body[key] !== null &&
|
|
63
|
+
(typeof body[key] !== 'string' || body[key].length > 200))
|
|
64
|
+
throw new HttpProblem(400, 'invalid-body', 'Invalid body', `'${key}' is a short text.`);
|
|
65
|
+
values[key] = body[key];
|
|
66
|
+
}
|
|
67
|
+
if (body['company'] !== undefined) {
|
|
68
|
+
if (!Number.isSafeInteger(body['company']))
|
|
69
|
+
throw new HttpProblem(400, 'invalid-body', 'Invalid body', "'company' is a company id.");
|
|
70
|
+
values['company'] = body['company'];
|
|
71
|
+
}
|
|
72
|
+
return auth.updateProfile(await auth.requireSession(request), values);
|
|
73
|
+
});
|
|
74
|
+
app.get('/api/v1/auth/sessions', { config: { auth: false } }, async (request) => ({
|
|
75
|
+
sessions: await auth.sessions(await auth.requireSession(request)),
|
|
76
|
+
}));
|
|
77
|
+
app.delete('/api/v1/auth/sessions/:id', { config: { auth: false } }, async (request) => {
|
|
78
|
+
const id = Number(request.params.id);
|
|
79
|
+
if (!Number.isSafeInteger(id) || id <= 0)
|
|
80
|
+
throw new HttpProblem(400, 'invalid-id', 'Invalid id', 'The id is a positive integer.');
|
|
81
|
+
return auth.revokeSessions(await auth.requireSession(request), id);
|
|
82
|
+
});
|
|
83
|
+
app.post('/api/v1/auth/sessions/sign-out-others', { config: { auth: false } }, async (request) => auth.revokeSessions(await auth.requireSession(request), 'others'));
|
|
55
84
|
}
|
|
@@ -39,11 +39,22 @@ 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>>;
|
|
42
44
|
readonly twoFactorEnabled: boolean;
|
|
43
45
|
/** Holds an administrator role (in some company). */
|
|
44
46
|
readonly administrator: boolean;
|
|
45
47
|
/** Administrator rights are withheld until this session has a second factor (enable or verify 2FA). */
|
|
46
48
|
readonly administratorNeedsSecondFactor: boolean;
|
|
49
|
+
/** The policy requires two-factor sign-in of everyone and it is not on: nothing but setting it up is allowed. */
|
|
50
|
+
readonly twoFactorSetupRequired: boolean;
|
|
51
|
+
}
|
|
52
|
+
/** The installation's security policy (RFC 0019 §3.5). */
|
|
53
|
+
export interface SecurityPolicy {
|
|
54
|
+
/** Who must sign in with a second factor: administrators only (the default), or everyone. */
|
|
55
|
+
readonly twoFactor: 'administrators' | 'everyone';
|
|
56
|
+
/** Days a sign-in lasts at most; null: the server's `lifetimeMs`. */
|
|
57
|
+
readonly sessionDays: number | null;
|
|
47
58
|
}
|
|
48
59
|
/** An API key as listed (never its secret). */
|
|
49
60
|
export interface ApiKeyInfo {
|
|
@@ -162,4 +173,45 @@ export declare class AuthService {
|
|
|
162
173
|
disableTwoFactor(session: SessionInfo, password: string, code: string): Promise<void>;
|
|
163
174
|
/** Changes the password; every other session of the user is revoked. */
|
|
164
175
|
changePassword(session: SessionInfo, current: string, next: string): Promise<void>;
|
|
176
|
+
/** The security policy (cached like sessions; {@link forgetPolicy} after changing it). */
|
|
177
|
+
policy(): Promise<SecurityPolicy>;
|
|
178
|
+
/** Forgets the cached policy: after Settings › Security changed it. */
|
|
179
|
+
forgetPolicy(): void;
|
|
180
|
+
/** Forgets what is cached about `user` (their identity and sessions): after their roles or account changed. */
|
|
181
|
+
forget(user: number): void;
|
|
182
|
+
/** The signed-in user's own details (RFC 0019 §3.6). */
|
|
183
|
+
profile(session: SessionInfo): Promise<Profile>;
|
|
184
|
+
/**
|
|
185
|
+
* Changes the user's own details: name, email, phone, language, time zone, and the default company (one of
|
|
186
|
+
* theirs). Audited.
|
|
187
|
+
*/
|
|
188
|
+
updateProfile(session: SessionInfo, values: Partial<Omit<Profile, 'login'>>): Promise<Profile>;
|
|
189
|
+
/** The user's active sessions, newest use first; the current one marked. */
|
|
190
|
+
sessions(session: SessionInfo): Promise<SessionEntry[]>;
|
|
191
|
+
/** Signs out one of the user's own sessions (or all but this one with `'others'`). Audited. */
|
|
192
|
+
revokeSessions(session: SessionInfo, which: number | 'others'): Promise<{
|
|
193
|
+
revoked: number;
|
|
194
|
+
}>;
|
|
195
|
+
}
|
|
196
|
+
/** The user's own details (RFC 0019 §3.6). */
|
|
197
|
+
export interface Profile {
|
|
198
|
+
readonly login: string;
|
|
199
|
+
readonly name: string;
|
|
200
|
+
readonly email: string | null;
|
|
201
|
+
readonly phone: string | null;
|
|
202
|
+
readonly lang: string;
|
|
203
|
+
readonly tz: string;
|
|
204
|
+
/** The default company. */
|
|
205
|
+
readonly company: number | null;
|
|
206
|
+
}
|
|
207
|
+
/** One of the user's sessions (a signed-in device). */
|
|
208
|
+
export interface SessionEntry {
|
|
209
|
+
readonly id: number;
|
|
210
|
+
readonly ip: string | null;
|
|
211
|
+
readonly userAgent: string | null;
|
|
212
|
+
readonly lastSeenAt: string;
|
|
213
|
+
readonly createdAt: string | null;
|
|
214
|
+
readonly secondFactor: boolean;
|
|
215
|
+
/** This browser. */
|
|
216
|
+
readonly current: boolean;
|
|
165
217
|
}
|
|
@@ -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
|
@@ -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/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;
|