@unchainedshop/core-users 5.0.0-alpha.1 → 5.0.0-alpha.11
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/README.md +9 -130
- package/lib/db/UsersCollection.js +51 -29
- package/lib/db/WebAuthnCredentialsCreationRequestsCollection.d.ts +1 -0
- package/lib/db/WebAuthnCredentialsCreationRequestsCollection.js +2 -5
- package/lib/migrations/20260625120200-normalize-profile-phone.d.ts +2 -0
- package/lib/migrations/20260625120200-normalize-profile-phone.js +52 -0
- package/lib/module/configureUsersModule.d.ts +17 -0
- package/lib/module/configureUsersModule.js +119 -45
- package/lib/module/configureUsersWebAuthnModule.js +3 -1
- package/lib/users-settings.d.ts +1 -0
- package/lib/users-settings.js +4 -1
- package/package.json +10 -8
- package/lib/domain/model/Email.d.ts +0 -17
- package/lib/domain/model/Email.js +0 -21
- package/lib/domain/model/PasswordHash.d.ts +0 -16
- package/lib/domain/model/PasswordHash.js +0 -31
- package/lib/domain/model/PushSubscription.d.ts +0 -23
- package/lib/domain/model/PushSubscription.js +0 -25
- package/lib/domain/model/User.d.ts +0 -116
- package/lib/domain/model/User.js +0 -386
- package/lib/domain/model/UserProfile.d.ts +0 -14
- package/lib/domain/model/UserProfile.js +0 -29
- package/lib/domain/model/Web3Address.d.ts +0 -16
- package/lib/domain/model/Web3Address.js +0 -32
- package/lib/domain/model/WebAuthnCredential.d.ts +0 -22
- package/lib/domain/model/WebAuthnCredential.js +0 -28
- package/lib/domain/model/index.d.ts +0 -14
- package/lib/domain/model/index.js +0 -7
- package/lib/domain/repository/UserRepository.d.ts +0 -48
- package/lib/domain/repository/UserRepository.js +0 -1
- package/lib/domain/services/PasswordService.d.ts +0 -5
- package/lib/domain/services/PasswordService.js +0 -1
- package/lib/domain/services/TokenService.d.ts +0 -8
- package/lib/domain/services/TokenService.js +0 -1
- package/lib/domain/types.d.ts +0 -28
- package/lib/domain/types.js +0 -1
- package/lib/infrastructure/persistence/MongoUserRepository.d.ts +0 -4
- package/lib/infrastructure/persistence/MongoUserRepository.js +0 -159
- package/lib/infrastructure/persistence/buildUserSelector.d.ts +0 -4
- package/lib/infrastructure/persistence/buildUserSelector.js +0 -54
- package/lib/infrastructure/services/CryptoTokenService.d.ts +0 -2
- package/lib/infrastructure/services/CryptoTokenService.js +0 -16
- package/lib/infrastructure/services/Pbkdf2PasswordService.d.ts +0 -2
- package/lib/infrastructure/services/Pbkdf2PasswordService.js +0 -23
package/README.md
CHANGED
|
@@ -13,143 +13,22 @@ npm install @unchainedshop/core-users
|
|
|
13
13
|
|
|
14
14
|
## Usage
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
import { configureUsersModule } from '@unchainedshop/core-users';
|
|
18
|
-
|
|
19
|
-
const usersModule = await configureUsersModule({ db });
|
|
20
|
-
|
|
21
|
-
// Find users
|
|
22
|
-
const users = await usersModule.findUsers({
|
|
23
|
-
includeGuests: false,
|
|
24
|
-
limit: 50,
|
|
25
|
-
});
|
|
16
|
+
The platform initializes this module as `platform.unchainedAPI.modules.users`.
|
|
26
17
|
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
password: 'securePassword',
|
|
31
|
-
});
|
|
18
|
+
```typescript
|
|
19
|
+
const { users } = platform.unchainedAPI.modules;
|
|
20
|
+
const customers = await users.findUsers({ includeGuests: false, limit: 50 });
|
|
32
21
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
displayName: 'John Doe',
|
|
22
|
+
await users.updateProfile('user-123', {
|
|
23
|
+
profile: { displayName: 'Jane Doe' },
|
|
36
24
|
});
|
|
37
25
|
```
|
|
38
26
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
### Module Configuration
|
|
42
|
-
|
|
43
|
-
| Export | Description |
|
|
44
|
-
|--------|-------------|
|
|
45
|
-
| `configureUsersModule` | Configure and return the users module |
|
|
46
|
-
| `configureUsersWebAuthnModule` | Configure WebAuthn submodule |
|
|
47
|
-
|
|
48
|
-
### Queries
|
|
49
|
-
|
|
50
|
-
| Method | Description |
|
|
51
|
-
|--------|-------------|
|
|
52
|
-
| `findUser` | Find user by ID, email, or username |
|
|
53
|
-
| `findUsers` | Find users with filtering, sorting, and pagination |
|
|
54
|
-
| `count` | Count users matching query |
|
|
55
|
-
| `userExists` | Check if a user exists |
|
|
56
|
-
|
|
57
|
-
### Mutations
|
|
58
|
-
|
|
59
|
-
| Method | Description |
|
|
60
|
-
|--------|-------------|
|
|
61
|
-
| `createUser` | Create a new user account |
|
|
62
|
-
| `updateUser` | Update user data |
|
|
63
|
-
| `updateProfile` | Update user profile |
|
|
64
|
-
| `updateRoles` | Update user roles |
|
|
65
|
-
| `updateTags` | Update user tags |
|
|
66
|
-
| `updateAvatar` | Set user avatar |
|
|
67
|
-
| `updateBillingAddress` | Update billing address |
|
|
68
|
-
| `updatePassword` | Change user password |
|
|
69
|
-
| `updateUsername` | Change username |
|
|
70
|
-
| `delete` | Soft delete a user |
|
|
71
|
-
| `replaceUserId` | Migrate data between users |
|
|
72
|
-
|
|
73
|
-
### Authentication
|
|
74
|
-
|
|
75
|
-
| Method | Description |
|
|
76
|
-
|--------|-------------|
|
|
77
|
-
| `verifyPassword` | Verify password against stored hash |
|
|
78
|
-
| `hashPassword` | Hash a password for storage |
|
|
79
|
-
| `addEmail` | Add email address to user |
|
|
80
|
-
| `removeEmail` | Remove email address |
|
|
81
|
-
| `verifyEmail` | Mark email as verified |
|
|
82
|
-
| `updateHeartbeat` | Update last activity timestamp |
|
|
83
|
-
| `updateLastLogin` | Record login event |
|
|
84
|
-
|
|
85
|
-
### WebAuthn Submodule
|
|
86
|
-
|
|
87
|
-
| Method | Description |
|
|
88
|
-
|--------|-------------|
|
|
89
|
-
| `webAuthn.findCredentials` | Find WebAuthn credentials for user |
|
|
90
|
-
| `webAuthn.createCredential` | Register new WebAuthn credential |
|
|
91
|
-
| `webAuthn.removeCredential` | Remove WebAuthn credential |
|
|
92
|
-
|
|
93
|
-
### Settings
|
|
94
|
-
|
|
95
|
-
| Export | Description |
|
|
96
|
-
|--------|-------------|
|
|
97
|
-
| `userSettings` | Access user module settings |
|
|
98
|
-
| `UserAccountAction` | Account action types enum |
|
|
99
|
-
|
|
100
|
-
### Types
|
|
101
|
-
|
|
102
|
-
| Export | Description |
|
|
103
|
-
|--------|-------------|
|
|
104
|
-
| `User` | User document type |
|
|
105
|
-
| `UserQuery` | Query parameters type |
|
|
106
|
-
| `UserProfile` | User profile type |
|
|
107
|
-
| `Email` | Email address type |
|
|
108
|
-
| `UsersModule` | Module interface type |
|
|
109
|
-
| `UserSettingsOptions` | Configuration options type |
|
|
110
|
-
|
|
111
|
-
## Events
|
|
112
|
-
|
|
113
|
-
| Event | Description |
|
|
114
|
-
|-------|-------------|
|
|
115
|
-
| `USER_CREATE` | User created |
|
|
116
|
-
| `USER_UPDATE` | User updated |
|
|
117
|
-
| `USER_REMOVE` | User deleted |
|
|
118
|
-
| `USER_UPDATE_PROFILE` | Profile updated |
|
|
119
|
-
| `USER_UPDATE_PASSWORD` | Password changed |
|
|
120
|
-
| `USER_UPDATE_ROLES` | Roles changed |
|
|
121
|
-
| `USER_ACCOUNT_ACTION` | Account action triggered |
|
|
122
|
-
|
|
123
|
-
## Security
|
|
124
|
-
|
|
125
|
-
This module implements security best practices for user authentication and data protection.
|
|
126
|
-
|
|
127
|
-
### Password Security
|
|
128
|
-
|
|
129
|
-
- **Algorithm**: PBKDF2 with SHA-512
|
|
130
|
-
- **Iterations**: 300,000 (exceeds OWASP recommendation)
|
|
131
|
-
- **Salt**: 16 bytes, cryptographically random per password
|
|
132
|
-
- **Key Length**: 256 bytes
|
|
133
|
-
- **FIPS 140-3**: Compatible when running on FIPS-enabled Node.js
|
|
134
|
-
|
|
135
|
-
### Token Security
|
|
136
|
-
|
|
137
|
-
- **Generation**: `crypto.randomUUID()` (CSPRNG-based)
|
|
138
|
-
- **Storage**: SHA-256 hashed before database storage
|
|
139
|
-
- **Expiration**: Time-limited (configurable, default 1 hour)
|
|
140
|
-
- **Single-use**: Tokens invalidated after verification
|
|
141
|
-
|
|
142
|
-
### WebAuthn/FIDO2
|
|
143
|
-
|
|
144
|
-
Full support for passwordless authentication via hardware security keys and platform authenticators, providing phishing-resistant authentication.
|
|
145
|
-
|
|
146
|
-
### Data Protection
|
|
27
|
+
`updateProfile` accepts an object containing `profile` and optional `meta`. Account creation uses `createUser`; account deletion uses `markDeleted` or `deletePermanently`. Authentication flows and permissions are coordinated by the API layer.
|
|
147
28
|
|
|
148
|
-
|
|
149
|
-
- Soft delete preserves audit trail while removing PII
|
|
150
|
-
- Email addresses and profile data access-controlled via RBAC
|
|
29
|
+
See the [module settings](https://docs.unchained.shop/platform-configuration/modules/users), [authentication guide](https://docs.unchained.shop/concepts/authentication), [public exports](src/users-index.ts), and [module implementation](src/module/configureUsersModule.ts).
|
|
151
30
|
|
|
152
|
-
|
|
31
|
+
For security reporting and the implementation's security properties, see [SECURITY.md](../../SECURITY.md).
|
|
153
32
|
|
|
154
33
|
## License
|
|
155
34
|
|
|
@@ -1,36 +1,34 @@
|
|
|
1
|
-
import { buildDbIndexes,
|
|
1
|
+
import { buildDbIndexes, } from '@unchainedshop/mongodb';
|
|
2
2
|
export const UsersCollection = async (db) => {
|
|
3
3
|
const Users = db.collection('users');
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
{
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
'lastBillingAddress.addressLine2': 1,
|
|
28
|
-
},
|
|
29
|
-
name: 'user_fulltext_search',
|
|
4
|
+
await buildDbIndexes(Users, [
|
|
5
|
+
{
|
|
6
|
+
index: {
|
|
7
|
+
_id: 'text',
|
|
8
|
+
username: 'text',
|
|
9
|
+
'emails.address': 'text',
|
|
10
|
+
'profile.displayName': 'text',
|
|
11
|
+
'lastBillingAddress.firstName': 'text',
|
|
12
|
+
'lastBillingAddress.lastName': 'text',
|
|
13
|
+
'lastBillingAddress.company': 'text',
|
|
14
|
+
'lastBillingAddress.addressLine': 'text',
|
|
15
|
+
'lastBillingAddress.addressLine2': 'text',
|
|
16
|
+
},
|
|
17
|
+
options: {
|
|
18
|
+
weights: {
|
|
19
|
+
_id: 9,
|
|
20
|
+
'emails.address': 7,
|
|
21
|
+
'profile.displayName': 5,
|
|
22
|
+
'lastBillingAddress.firstName': 3,
|
|
23
|
+
'lastBillingAddress.lastName': 3,
|
|
24
|
+
'lastBillingAddress.company': 1,
|
|
25
|
+
'lastBillingAddress.addressLine': 1,
|
|
26
|
+
'lastBillingAddress.addressLine2': 1,
|
|
30
27
|
},
|
|
28
|
+
name: 'user_fulltext_search',
|
|
31
29
|
},
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
},
|
|
31
|
+
]);
|
|
34
32
|
await buildDbIndexes(Users, [
|
|
35
33
|
{
|
|
36
34
|
index: {
|
|
@@ -96,6 +94,30 @@ export const UsersCollection = async (db) => {
|
|
|
96
94
|
sparse: true,
|
|
97
95
|
},
|
|
98
96
|
},
|
|
97
|
+
{
|
|
98
|
+
index: {
|
|
99
|
+
'services.web3.verified': 1,
|
|
100
|
+
},
|
|
101
|
+
options: {
|
|
102
|
+
sparse: true,
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
index: {
|
|
107
|
+
tags: 1,
|
|
108
|
+
},
|
|
109
|
+
options: {
|
|
110
|
+
sparse: true,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
index: {
|
|
115
|
+
'lastLogin.timestamp': 1,
|
|
116
|
+
},
|
|
117
|
+
options: {
|
|
118
|
+
sparse: true,
|
|
119
|
+
},
|
|
120
|
+
},
|
|
99
121
|
]);
|
|
100
122
|
return Users;
|
|
101
123
|
};
|
|
@@ -2,11 +2,8 @@ import { buildDbIndexes } from '@unchainedshop/mongodb';
|
|
|
2
2
|
export const WebAuthnCredentialsCreationRequestsCollection = async (db) => {
|
|
3
3
|
const WebAuthnCredentialsCreationRequests = db.collection('accounts_webauthn_credentials_creation_requests');
|
|
4
4
|
await buildDbIndexes(WebAuthnCredentialsCreationRequests, [
|
|
5
|
-
{
|
|
6
|
-
|
|
7
|
-
username: 1,
|
|
8
|
-
},
|
|
9
|
-
},
|
|
5
|
+
{ index: { username: 1 } },
|
|
6
|
+
{ index: { created: 1 }, options: { expireAfterSeconds: 15 * 60 } },
|
|
10
7
|
]);
|
|
11
8
|
return WebAuthnCredentialsCreationRequests;
|
|
12
9
|
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { normalizePhoneNumber } from '@unchainedshop/utils';
|
|
2
|
+
import { UsersCollection } from "../db/UsersCollection.js";
|
|
3
|
+
export default function normalizeProfilePhone(repository) {
|
|
4
|
+
repository?.register({
|
|
5
|
+
id: 20260625120200,
|
|
6
|
+
name: 'Normalize user.profile.phoneMobile and user.lastContact.telNumber to E.164 format',
|
|
7
|
+
up: async ({ logger }) => {
|
|
8
|
+
const Users = await UsersCollection(repository.db);
|
|
9
|
+
const users = await Users.find({
|
|
10
|
+
$or: [
|
|
11
|
+
{ 'profile.phoneMobile': { $exists: true, $nin: [null, ''] } },
|
|
12
|
+
{ 'lastContact.telNumber': { $exists: true, $nin: [null, ''] } },
|
|
13
|
+
],
|
|
14
|
+
}, {
|
|
15
|
+
projection: {
|
|
16
|
+
_id: true,
|
|
17
|
+
profile: true,
|
|
18
|
+
lastContact: true,
|
|
19
|
+
lastBillingAddress: true,
|
|
20
|
+
lastLogin: true,
|
|
21
|
+
},
|
|
22
|
+
}).toArray();
|
|
23
|
+
let changed = 0;
|
|
24
|
+
let skipped = 0;
|
|
25
|
+
for (const user of users) {
|
|
26
|
+
const defaultCountry = user.lastBillingAddress?.countryCode ||
|
|
27
|
+
user.profile?.address?.countryCode ||
|
|
28
|
+
user.lastLogin?.countryCode;
|
|
29
|
+
const $set = {};
|
|
30
|
+
const phoneMobile = user.profile?.phoneMobile;
|
|
31
|
+
const normalizedPhoneMobile = normalizePhoneNumber(phoneMobile, defaultCountry);
|
|
32
|
+
if (normalizedPhoneMobile && normalizedPhoneMobile !== phoneMobile) {
|
|
33
|
+
$set['profile.phoneMobile'] = normalizedPhoneMobile;
|
|
34
|
+
}
|
|
35
|
+
const telNumber = user.lastContact?.telNumber;
|
|
36
|
+
const normalizedTelNumber = normalizePhoneNumber(telNumber, defaultCountry);
|
|
37
|
+
if (normalizedTelNumber && normalizedTelNumber !== telNumber) {
|
|
38
|
+
$set['lastContact.telNumber'] = normalizedTelNumber;
|
|
39
|
+
}
|
|
40
|
+
if (Object.keys($set).length === 0) {
|
|
41
|
+
if ((phoneMobile && !normalizedPhoneMobile) || (telNumber && !normalizedTelNumber)) {
|
|
42
|
+
skipped += 1;
|
|
43
|
+
}
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
await Users.updateOne({ _id: user._id }, { $set });
|
|
47
|
+
changed += 1;
|
|
48
|
+
}
|
|
49
|
+
logger?.info(`Normalize user phone numbers: ${changed} updated, ${skipped} left unchanged (unparseable)`);
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -64,6 +64,12 @@ export declare const configureUsersModule: (moduleInput: ModuleInput<UserSetting
|
|
|
64
64
|
limit?: number;
|
|
65
65
|
offset?: number;
|
|
66
66
|
}): Promise<User[]>;
|
|
67
|
+
findGuestUserIds({ before }: {
|
|
68
|
+
before: Date;
|
|
69
|
+
}): Promise<string[]>;
|
|
70
|
+
findExistingUserIds({ userIds }: {
|
|
71
|
+
userIds: string[];
|
|
72
|
+
}): Promise<string[]>;
|
|
67
73
|
userExists({ userId }: {
|
|
68
74
|
userId: string;
|
|
69
75
|
}): Promise<boolean>;
|
|
@@ -136,5 +142,16 @@ export declare const configureUsersModule: (moduleInput: ModuleInput<UserSetting
|
|
|
136
142
|
}) => Promise<void>;
|
|
137
143
|
removePushSubscription: (userId: string, p256dh: string) => Promise<void>;
|
|
138
144
|
existingTags: () => Promise<string[]>;
|
|
145
|
+
bulkUpdateTags: (userIds: string[], { add, remove }: {
|
|
146
|
+
add?: string[];
|
|
147
|
+
remove?: string[];
|
|
148
|
+
}) => Promise<{
|
|
149
|
+
successIds: string[];
|
|
150
|
+
failedIds: string[];
|
|
151
|
+
}>;
|
|
152
|
+
bulkUpdateRoles: (userIds: string[], roles: string[]) => Promise<{
|
|
153
|
+
successIds: string[];
|
|
154
|
+
failedIds: string[];
|
|
155
|
+
}>;
|
|
139
156
|
}>;
|
|
140
157
|
export type UsersModule = Awaited<ReturnType<typeof configureUsersModule>>;
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import * as bcrypt from 'bcryptjs';
|
|
2
|
-
import { generateDbFilterById, buildSortOptions, generateDbObjectId, insensitiveTrimmedRegexOperator,
|
|
2
|
+
import { generateDbFilterById, buildSortOptions, generateDbObjectId, insensitiveTrimmedRegexOperator, } from '@unchainedshop/mongodb';
|
|
3
3
|
import { UsersCollection, } from "../db/UsersCollection.js";
|
|
4
4
|
import { emit, registerEvents } from '@unchainedshop/events';
|
|
5
|
-
import { systemLocale, SortDirection, sha256 } from '@unchainedshop/utils';
|
|
5
|
+
import { executeBulkOperation, systemLocale, SortDirection, sha256, normalizePhoneNumber, } from '@unchainedshop/utils';
|
|
6
6
|
import { UserAccountAction, userSettings, } from "../users-settings.js";
|
|
7
7
|
import { configureUsersWebAuthnModule } from "./configureUsersWebAuthnModule.js";
|
|
8
8
|
import * as pbkdf2 from "./pbkdf2.js";
|
|
9
9
|
import { verifyWeb3Signature } from "../utils/web3-verification.js";
|
|
10
10
|
import convertUserLocale from "../migrations/20241218092300-convert-locale.js";
|
|
11
|
+
import normalizeProfilePhone from "../migrations/20260625120200-normalize-profile-phone.js";
|
|
11
12
|
const USER_EVENTS = [
|
|
12
13
|
'USER_ACCOUNT_ACTION',
|
|
13
14
|
'USER_CREATE',
|
|
@@ -26,6 +27,7 @@ const USER_EVENTS = [
|
|
|
26
27
|
'USER_UPDATE_WEB3_ADDRESS',
|
|
27
28
|
'USER_REMOVE',
|
|
28
29
|
];
|
|
30
|
+
const ADMIN_ROLE = 'admin';
|
|
29
31
|
export const removeConfidentialServiceHashes = (rawUser) => {
|
|
30
32
|
const user = { ...rawUser };
|
|
31
33
|
delete user?.services;
|
|
@@ -45,12 +47,12 @@ export const buildFindSelector = ({ includeGuests, includeDeleted, queryString,
|
|
|
45
47
|
}
|
|
46
48
|
if (usernames?.length) {
|
|
47
49
|
selector.username = {
|
|
48
|
-
$in: usernames.map((u) => insensitiveTrimmedRegexOperator(u)),
|
|
50
|
+
$in: usernames.map((u) => insensitiveTrimmedRegexOperator(u).$regex),
|
|
49
51
|
};
|
|
50
52
|
}
|
|
51
53
|
if (emails?.length) {
|
|
52
54
|
selector['emails.address'] = {
|
|
53
|
-
$in: emails.map((e) => insensitiveTrimmedRegexOperator(e)),
|
|
55
|
+
$in: emails.map((e) => insensitiveTrimmedRegexOperator(e).$regex),
|
|
54
56
|
};
|
|
55
57
|
}
|
|
56
58
|
if (emailVerified === true) {
|
|
@@ -75,7 +77,6 @@ export const buildFindSelector = ({ includeGuests, includeDeleted, queryString,
|
|
|
75
77
|
selector['lastLogin.timestamp'].$gte = new Date(lastLogin.start);
|
|
76
78
|
}
|
|
77
79
|
if (queryString) {
|
|
78
|
-
assertDocumentDBCompatMode();
|
|
79
80
|
selector.$text = { $search: queryString };
|
|
80
81
|
}
|
|
81
82
|
return selector;
|
|
@@ -83,10 +84,49 @@ export const buildFindSelector = ({ includeGuests, includeDeleted, queryString,
|
|
|
83
84
|
export const configureUsersModule = async (moduleInput) => {
|
|
84
85
|
const { db, options, migrationRepository } = moduleInput;
|
|
85
86
|
convertUserLocale(migrationRepository);
|
|
87
|
+
normalizeProfilePhone(migrationRepository);
|
|
86
88
|
userSettings.configureSettings(options || {}, db);
|
|
87
89
|
registerEvents(USER_EVENTS);
|
|
88
90
|
const Users = await UsersCollection(db);
|
|
89
91
|
const webAuthn = await configureUsersWebAuthnModule(moduleInput);
|
|
92
|
+
const assertAnotherActiveAdminExists = async (userId) => {
|
|
93
|
+
const user = await Users.findOne({ _id: userId, deleted: null });
|
|
94
|
+
if (!user?.roles?.includes(ADMIN_ROLE))
|
|
95
|
+
return;
|
|
96
|
+
const activeAdminCount = await Users.countDocuments({ deleted: null, roles: ADMIN_ROLE }, { limit: 2 });
|
|
97
|
+
if (activeAdminCount <= 1)
|
|
98
|
+
throw new Error('At least one active administrator is required. Assign another administrator before removing this account or revoking its admin role.', { cause: 'LAST_ADMIN' });
|
|
99
|
+
};
|
|
100
|
+
const updateUserRoles = async (_id, roles) => {
|
|
101
|
+
if (!roles.includes(ADMIN_ROLE))
|
|
102
|
+
await assertAnotherActiveAdminExists(_id);
|
|
103
|
+
const user = await Users.findOneAndUpdate(generateDbFilterById(_id), {
|
|
104
|
+
$set: {
|
|
105
|
+
updated: new Date(),
|
|
106
|
+
roles,
|
|
107
|
+
},
|
|
108
|
+
}, { returnDocument: 'after' });
|
|
109
|
+
if (!user)
|
|
110
|
+
return null;
|
|
111
|
+
await emit('USER_UPDATE_ROLE', {
|
|
112
|
+
user: removeConfidentialServiceHashes(user),
|
|
113
|
+
});
|
|
114
|
+
return user;
|
|
115
|
+
};
|
|
116
|
+
const updateUserTags = async (_id, tags) => {
|
|
117
|
+
const user = await Users.findOneAndUpdate(generateDbFilterById(_id), {
|
|
118
|
+
$set: {
|
|
119
|
+
updated: new Date(),
|
|
120
|
+
tags,
|
|
121
|
+
},
|
|
122
|
+
}, { returnDocument: 'after' });
|
|
123
|
+
if (!user)
|
|
124
|
+
return null;
|
|
125
|
+
await emit('USER_UPDATE_TAGS', {
|
|
126
|
+
user: removeConfidentialServiceHashes(user),
|
|
127
|
+
});
|
|
128
|
+
return user;
|
|
129
|
+
};
|
|
90
130
|
return {
|
|
91
131
|
webAuthn,
|
|
92
132
|
async count(query) {
|
|
@@ -198,6 +238,23 @@ export const configureUsersModule = async (moduleInput) => {
|
|
|
198
238
|
sort: buildSortOptions(query.sort || defaultSort),
|
|
199
239
|
}).toArray();
|
|
200
240
|
},
|
|
241
|
+
async findGuestUserIds({ before }) {
|
|
242
|
+
const users = await Users.find({
|
|
243
|
+
guest: true,
|
|
244
|
+
deleted: null,
|
|
245
|
+
created: { $lte: before },
|
|
246
|
+
$or: [
|
|
247
|
+
{ 'lastLogin.timestamp': { $exists: false } },
|
|
248
|
+
{ 'lastLogin.timestamp': { $lte: before } },
|
|
249
|
+
],
|
|
250
|
+
}, { projection: { _id: 1 } }).toArray();
|
|
251
|
+
return users.map((u) => u._id);
|
|
252
|
+
},
|
|
253
|
+
async findExistingUserIds({ userIds }) {
|
|
254
|
+
if (!userIds.length)
|
|
255
|
+
return [];
|
|
256
|
+
return Users.distinct('_id', { _id: { $in: userIds } });
|
|
257
|
+
},
|
|
201
258
|
async userExists({ userId }) {
|
|
202
259
|
const userCount = await Users.countDocuments({ _id: userId, deleted: null }, { limit: 1 });
|
|
203
260
|
return userCount === 1;
|
|
@@ -588,6 +645,7 @@ export const configureUsersModule = async (moduleInput) => {
|
|
|
588
645
|
return user;
|
|
589
646
|
},
|
|
590
647
|
markDeleted: async (userId) => {
|
|
648
|
+
await assertAnotherActiveAdminExists(userId);
|
|
591
649
|
await Users.updateOne({ _id: userId }, { $inc: { tokenVersion: 1 } });
|
|
592
650
|
const user = await Users.findOneAndUpdate({ _id: userId }, {
|
|
593
651
|
$set: {
|
|
@@ -615,6 +673,7 @@ export const configureUsersModule = async (moduleInput) => {
|
|
|
615
673
|
return user;
|
|
616
674
|
},
|
|
617
675
|
deletePermanently: async ({ userId }) => {
|
|
676
|
+
await assertAnotherActiveAdminExists(userId);
|
|
618
677
|
return Users.findOneAndDelete({ _id: userId });
|
|
619
678
|
},
|
|
620
679
|
updateProfile: async (userId, updatedData) => {
|
|
@@ -624,11 +683,28 @@ export const configureUsersModule = async (moduleInput) => {
|
|
|
624
683
|
return Users.findOne(userFilter, {});
|
|
625
684
|
}
|
|
626
685
|
const modifier = { $set: {} };
|
|
627
|
-
|
|
628
|
-
|
|
686
|
+
let normalizedProfile = profile;
|
|
687
|
+
if (profile?.phoneMobile) {
|
|
688
|
+
let defaultCountry = profile.address?.countryCode;
|
|
689
|
+
if (!defaultCountry) {
|
|
690
|
+
const existing = await Users.findOne(userFilter, {
|
|
691
|
+
projection: { lastBillingAddress: true, profile: true, lastLogin: true },
|
|
692
|
+
});
|
|
693
|
+
defaultCountry =
|
|
694
|
+
existing?.lastBillingAddress?.countryCode ||
|
|
695
|
+
existing?.profile?.address?.countryCode ||
|
|
696
|
+
existing?.lastLogin?.countryCode;
|
|
697
|
+
}
|
|
698
|
+
normalizedProfile = {
|
|
699
|
+
...profile,
|
|
700
|
+
phoneMobile: normalizePhoneNumber(profile.phoneMobile, defaultCountry) || profile.phoneMobile,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
if (normalizedProfile) {
|
|
704
|
+
modifier.$set = Object.keys(normalizedProfile).reduce((acc, profileKey) => {
|
|
629
705
|
return {
|
|
630
706
|
...acc,
|
|
631
|
-
[`profile.${profileKey}`]:
|
|
707
|
+
[`profile.${profileKey}`]: normalizedProfile[profileKey],
|
|
632
708
|
};
|
|
633
709
|
}, {});
|
|
634
710
|
}
|
|
@@ -685,14 +761,23 @@ export const configureUsersModule = async (moduleInput) => {
|
|
|
685
761
|
return null;
|
|
686
762
|
const profile = user.profile || {};
|
|
687
763
|
const isGuest = !!user.guest;
|
|
764
|
+
const defaultCountry = user.lastBillingAddress?.countryCode ||
|
|
765
|
+
profile.address?.countryCode ||
|
|
766
|
+
user.lastLogin?.countryCode;
|
|
767
|
+
const normalizedContact = lastContact?.telNumber
|
|
768
|
+
? {
|
|
769
|
+
...lastContact,
|
|
770
|
+
telNumber: normalizePhoneNumber(lastContact.telNumber, defaultCountry) || lastContact.telNumber,
|
|
771
|
+
}
|
|
772
|
+
: lastContact;
|
|
688
773
|
const modifier = {
|
|
689
774
|
$set: {
|
|
690
775
|
updated: new Date(),
|
|
691
|
-
lastContact,
|
|
776
|
+
lastContact: normalizedContact,
|
|
692
777
|
},
|
|
693
778
|
};
|
|
694
|
-
if ((!profile.phoneMobile || isGuest) &&
|
|
695
|
-
modifier.$set['profile.phoneMobile'] =
|
|
779
|
+
if ((!profile.phoneMobile || isGuest) && normalizedContact.telNumber) {
|
|
780
|
+
modifier.$set['profile.phoneMobile'] = normalizedContact.telNumber;
|
|
696
781
|
}
|
|
697
782
|
const updatedUser = await Users.findOneAndUpdate(userFilter, modifier, {
|
|
698
783
|
returnDocument: 'after',
|
|
@@ -704,23 +789,7 @@ export const configureUsersModule = async (moduleInput) => {
|
|
|
704
789
|
});
|
|
705
790
|
return updatedUser;
|
|
706
791
|
},
|
|
707
|
-
updateRoles:
|
|
708
|
-
const modifier = {
|
|
709
|
-
$set: {
|
|
710
|
-
updated: new Date(),
|
|
711
|
-
roles,
|
|
712
|
-
},
|
|
713
|
-
};
|
|
714
|
-
const user = await Users.findOneAndUpdate(generateDbFilterById(_id), modifier, {
|
|
715
|
-
returnDocument: 'after',
|
|
716
|
-
});
|
|
717
|
-
if (!user)
|
|
718
|
-
return null;
|
|
719
|
-
await emit('USER_UPDATE_ROLE', {
|
|
720
|
-
user: removeConfidentialServiceHashes(user),
|
|
721
|
-
});
|
|
722
|
-
return user;
|
|
723
|
-
},
|
|
792
|
+
updateRoles: updateUserRoles,
|
|
724
793
|
incrementTokenVersion: async (userId) => {
|
|
725
794
|
const user = await Users.findOneAndUpdate(generateDbFilterById(userId), {
|
|
726
795
|
$inc: { tokenVersion: 1 },
|
|
@@ -728,7 +797,7 @@ export const configureUsersModule = async (moduleInput) => {
|
|
|
728
797
|
}, { returnDocument: 'after' });
|
|
729
798
|
if (!user)
|
|
730
799
|
return null;
|
|
731
|
-
return { tokenVersion: user.tokenVersion ??
|
|
800
|
+
return { tokenVersion: user.tokenVersion ?? 0 };
|
|
732
801
|
},
|
|
733
802
|
updateOidcLogoutAt: async (userId, date) => {
|
|
734
803
|
const user = await Users.findOneAndUpdate(generateDbFilterById(userId), {
|
|
@@ -740,22 +809,7 @@ export const configureUsersModule = async (moduleInput) => {
|
|
|
740
809
|
}, { returnDocument: 'after' });
|
|
741
810
|
return user;
|
|
742
811
|
},
|
|
743
|
-
updateTags:
|
|
744
|
-
const user = await Users.findOneAndUpdate(generateDbFilterById(_id), {
|
|
745
|
-
$set: {
|
|
746
|
-
updated: new Date(),
|
|
747
|
-
tags,
|
|
748
|
-
},
|
|
749
|
-
}, {
|
|
750
|
-
returnDocument: 'after',
|
|
751
|
-
});
|
|
752
|
-
if (!user)
|
|
753
|
-
return null;
|
|
754
|
-
await emit('USER_UPDATE_TAGS', {
|
|
755
|
-
user: removeConfidentialServiceHashes(user),
|
|
756
|
-
});
|
|
757
|
-
return user;
|
|
758
|
-
},
|
|
812
|
+
updateTags: updateUserTags,
|
|
759
813
|
updateUser: async (selector, modifier, updateOptions) => {
|
|
760
814
|
const user = await Users.findOneAndUpdate(selector, modifier, {
|
|
761
815
|
...updateOptions,
|
|
@@ -799,5 +853,25 @@ export const configureUsersModule = async (moduleInput) => {
|
|
|
799
853
|
}));
|
|
800
854
|
return tags.filter(Boolean).toSorted();
|
|
801
855
|
},
|
|
856
|
+
bulkUpdateTags: async (userIds, { add = [], remove = [] }) => {
|
|
857
|
+
const tagsToRemove = new Set(remove);
|
|
858
|
+
return executeBulkOperation(userIds, async (userId) => {
|
|
859
|
+
const user = await Users.findOne({ _id: userId, deleted: null }, {});
|
|
860
|
+
if (!user)
|
|
861
|
+
throw new Error('not-found');
|
|
862
|
+
const tags = (user.tags || []).filter((tag) => !tagsToRemove.has(tag));
|
|
863
|
+
for (const tag of add) {
|
|
864
|
+
if (!tags.includes(tag))
|
|
865
|
+
tags.push(tag);
|
|
866
|
+
}
|
|
867
|
+
await updateUserTags(userId, tags);
|
|
868
|
+
});
|
|
869
|
+
},
|
|
870
|
+
bulkUpdateRoles: async (userIds, roles) => executeBulkOperation(userIds, async (userId) => {
|
|
871
|
+
const user = await Users.findOne({ _id: userId, deleted: null }, { projection: { _id: 1 } });
|
|
872
|
+
if (!user)
|
|
873
|
+
throw new Error('not-found');
|
|
874
|
+
await updateUserRoles(userId, roles);
|
|
875
|
+
}),
|
|
802
876
|
};
|
|
803
877
|
};
|
|
@@ -36,7 +36,7 @@ async function fetchMDSEntriesImpl() {
|
|
|
36
36
|
}
|
|
37
37
|
return cache;
|
|
38
38
|
}
|
|
39
|
-
const fetchMDSEntries = memoizeWithTTL(fetchMDSEntriesImpl, ONE_DAY_MS);
|
|
39
|
+
const fetchMDSEntries = memoizeWithTTL(fetchMDSEntriesImpl, { ttl: ONE_DAY_MS });
|
|
40
40
|
export function toArrayBuffer(buffer) {
|
|
41
41
|
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
|
|
42
42
|
}
|
|
@@ -64,6 +64,7 @@ export const configureUsersWebAuthnModule = async ({ db }) => {
|
|
|
64
64
|
origin,
|
|
65
65
|
factor: 'either',
|
|
66
66
|
username,
|
|
67
|
+
created: new Date(),
|
|
67
68
|
});
|
|
68
69
|
return {
|
|
69
70
|
challenge,
|
|
@@ -96,6 +97,7 @@ export const configureUsersWebAuthnModule = async ({ db }) => {
|
|
|
96
97
|
origin,
|
|
97
98
|
factor: 'either',
|
|
98
99
|
username,
|
|
100
|
+
created: new Date(),
|
|
99
101
|
});
|
|
100
102
|
return {
|
|
101
103
|
challenge,
|
package/lib/users-settings.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ export type UserAccountAction = (typeof UserAccountAction)[keyof typeof UserAcco
|
|
|
16
16
|
export interface UserSettings {
|
|
17
17
|
mergeUserCartsOnLogin: boolean;
|
|
18
18
|
autoMessagingAfterUserCreation: boolean;
|
|
19
|
+
guestUserMaxAgeInDays: number;
|
|
19
20
|
earliestValidTokenDate: (type: typeof UserAccountAction.VERIFY_EMAIL | typeof UserAccountAction.RESET_PASSWORD) => Date;
|
|
20
21
|
validateEmail: (email: string) => Promise<boolean>;
|
|
21
22
|
validateUsername: (username: string) => Promise<boolean>;
|