@adminforth/open-signup 1.0.2

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/index.ts ADDED
@@ -0,0 +1,309 @@
1
+ import AdminForth, { AdminForthPlugin, Filters, suggestIfTypo, AdminForthDataTypes } from "adminforth";
2
+ import type { IAdminForth, IHttpServer, AdminForthComponentDeclaration, AdminForthResourceColumn, AdminForthResource, BeforeLoginConfirmationFunction } from "adminforth";
3
+ import type { PluginOptions } from './types.js';
4
+ import { SendEmailCommand, SESClient } from '@aws-sdk/client-ses';
5
+
6
+
7
+ export default class OpenSignupPlugin extends AdminForthPlugin {
8
+ options: PluginOptions;
9
+ emailField: AdminForthResourceColumn;
10
+ passwordField: AdminForthResourceColumn;
11
+ authResource: AdminForthResource;
12
+ emailConfirmedField?: AdminForthResourceColumn;
13
+
14
+ adminforth: IAdminForth;
15
+
16
+ constructor(options: PluginOptions) {
17
+ super(options, import.meta.url);
18
+ this.options = options;
19
+ }
20
+
21
+ async modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
22
+ super.modifyResourceConfig(adminforth, resourceConfig);
23
+
24
+ if (!this.options.emailField) {
25
+ throw new Error(`emailField is required and should be a name of field in auth resource`);
26
+ }
27
+
28
+ if (this.options.confirmEmails) {
29
+ if (!this.options.confirmEmails.emailProvider) {
30
+ throw new Error(`confirmEmails.emailProvider is required and should be a name of field in auth resource`);
31
+ }
32
+ if (!this.options.confirmEmails.emailConfirmedField) {
33
+ throw new Error(`confirmEmails.emailConfirmedField is required and should be a name of field in auth resource`);
34
+ }
35
+ const emailConfirmedField = this.authResource.columns.find(f => f.name === this.options.confirmEmails.emailConfirmedField);
36
+ if (!emailConfirmedField) {
37
+ const similar = suggestIfTypo(this.authResource.columns.map(f => f.name), this.options.confirmEmails.emailConfirmedField);
38
+ throw new Error(`Field with name ${this.options.confirmEmails.emailConfirmedField} not found in resource ${this.authResource.resourceId}.
39
+ ${similar ? `Did you mean ${similar}?` : ''}
40
+ `);
41
+ }
42
+ this.emailConfirmedField = emailConfirmedField;
43
+ if (this.emailConfirmedField.type !== AdminForthDataTypes.BOOLEAN) {
44
+ throw new Error(`Field ${this.emailConfirmedField.name} must be of type boolean`);
45
+ }
46
+ }
47
+
48
+ // find field with name resourceConfig.emailField in adminforth.auth.usersResourceId and show error if it doesn't exist
49
+ const authResource = adminforth.config.resources.find(r => r.resourceId === adminforth.config.auth.usersResourceId);
50
+ if (!authResource) {
51
+ throw new Error(`Resource with id config.auth.usersResourceId=${adminforth.config.auth.usersResourceId} not found`);
52
+ }
53
+ this.authResource = authResource;
54
+
55
+ const emailField = authResource.columns.find(f => f.name === this.options.emailField);
56
+ if (!emailField) {
57
+ const similar = suggestIfTypo(authResource.columns.map(f => f.name), this.options.emailField);
58
+
59
+ throw new Error(`Field with name ${this.options.emailField} not found in resource ${authResource.resourceId}.
60
+ ${similar ? `Did you mean ${similar}?` : ''}
61
+ `);
62
+ }
63
+ this.emailField = emailField;
64
+
65
+ if (!this.options.passwordField) {
66
+ throw new Error(`passwordField is required to get password constraints and should be a name of virtual field in auth resource`);
67
+ }
68
+
69
+ const passwordField: AdminForthResourceColumn = authResource.columns.find(f => f.name === this.options.passwordField);
70
+ if (!passwordField) {
71
+ const similar = suggestIfTypo(authResource.columns.map(f => f.name), this.options.passwordField);
72
+ throw new Error(`Field with name ${this.options.passwordField} not found in resource ${authResource.resourceId}.
73
+ ${similar ? `Did you mean ${similar}?` : ''}
74
+ `);
75
+ }
76
+ this.passwordField = passwordField;
77
+
78
+ (adminforth.config.customization.loginPageInjections.underInputs as AdminForthComponentDeclaration[]).push({
79
+ file: this.componentPath('SignupUnderLogin.vue'),
80
+ });
81
+ adminforth.config.customization.customPages.push({
82
+ path: '/signup',
83
+ component: {
84
+ file: this.componentPath('SignupPage.vue'),
85
+ meta: {
86
+ customLayout: true,
87
+ pluginInstanceId: this.pluginInstanceId,
88
+ passwordField: {
89
+ minLength: passwordField.minLength,
90
+ maxLength: passwordField.maxLength,
91
+ validation: passwordField.validation
92
+ }
93
+ }
94
+ }
95
+ });
96
+
97
+ // for confirmation disable login if email is not confirmed
98
+ if (this.options.confirmEmails) {
99
+ if (!adminforth.config.auth.beforeLoginConfirmation) {
100
+ adminforth.config.auth.beforeLoginConfirmation = [];
101
+ }
102
+ // unshift because if e.g. 2fa set it's hook, this one should be first
103
+ (adminforth.config.auth.beforeLoginConfirmation as BeforeLoginConfirmationFunction[]).unshift(
104
+ async ({ adminUser }) => {
105
+ if (!adminUser.dbUser[this.emailConfirmedField.name]) {
106
+ return { body: { allowedLogin: false }, error: 'You need to confirm your email to be able to login' };
107
+ }
108
+ }
109
+ );
110
+ }
111
+ }
112
+
113
+ validateConfigAfterDiscover(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
114
+ // optional method where you can safely check field types after database discovery was performed
115
+ }
116
+
117
+ instanceUniqueRepresentation(pluginOptions: any) : string {
118
+ // optional method to return unique string representation of plugin instance.
119
+ // Needed if plugin can have multiple instances on one resource
120
+ return `single`;
121
+ }
122
+
123
+ async doLogin(email: string, response: any): Promise<{ error?: string; allowedLogin: boolean; redirectTo?: string; }> {
124
+
125
+ const username = email;
126
+ const userRecord = await this.adminforth.resource(this.authResource.resourceId).get(Filters.EQ(this.emailField.name, email));
127
+ const adminUser = {
128
+ dbUser: userRecord,
129
+ pk: userRecord[this.authResource.columns.find((col) => col.primaryKey).name],
130
+ username,
131
+ };
132
+ const toReturn = { allowedLogin: true, error: '' };
133
+
134
+ await this.adminforth.restApi.processLoginCallbacks(adminUser, toReturn, response);
135
+ if (toReturn.allowedLogin) {
136
+ this.adminforth.auth.setAuthCookie({
137
+ response,
138
+ username,
139
+ pk: userRecord[
140
+ this.authResource.columns.find((col) => col.primaryKey).name
141
+ ]
142
+ });
143
+ }
144
+
145
+ return toReturn;
146
+ }
147
+
148
+
149
+ setupEndpoints(server: IHttpServer) {
150
+ server.endpoint({
151
+ method: 'POST',
152
+ path: `/plugin/${this.pluginInstanceId}/completeVerifiedSignup`,
153
+ noAuth: true,
154
+ handler: async ({ body, response }) => {
155
+ const { token } = body;
156
+ const { email } = await this.adminforth.auth.verify(token, 'tempVerifyEmailToken');
157
+ if (!email) {
158
+ return { error: 'Invalid token', ok: false };
159
+ }
160
+ const userRecord = await this.adminforth.resource(this.authResource.resourceId).get(Filters.EQ(this.emailField.name, email));
161
+ if (!userRecord) {
162
+ return { error: 'User not found', ok: false };
163
+ }
164
+ await this.adminforth.resource(this.authResource.resourceId).update(userRecord[this.authResource.columns.find((col) => col.primaryKey).name], {
165
+ [this.options.confirmEmails.emailConfirmedField]: true
166
+ });
167
+ return await this.doLogin(email, response);
168
+ }
169
+ });
170
+
171
+ server.endpoint({
172
+ method: 'POST',
173
+ path: `/plugin/${this.pluginInstanceId}/signup`,
174
+ noAuth: true,
175
+ handler: async ({ body, response }) => {
176
+ const { email, url, password } = body;
177
+
178
+ // validate email
179
+ if (this.emailField.validation) {
180
+ for (const { regExp, message } of this.emailField.validation) {
181
+ if (!new RegExp(regExp).test(password)) {
182
+ return { error: message, ok: false };
183
+ }
184
+ }
185
+ }
186
+
187
+ // validate password
188
+ if (password.length < this.passwordField.minLength) {
189
+ return { error: `Password must be at least ${this.passwordField.minLength} characters long`, ok: false };
190
+ }
191
+ if (password.length > this.passwordField.maxLength) {
192
+ return { error: `Password must be at most ${this.passwordField.maxLength} characters long`, ok: false };
193
+ }
194
+ if (this.passwordField.validation) {
195
+ for (const { regExp, message } of this.passwordField.validation) {
196
+ if (!new RegExp(regExp).test(password)) {
197
+ return { error: message, ok: false };
198
+ }
199
+ }
200
+ }
201
+
202
+ // first check again if email already exists
203
+ const existing = await this.adminforth.resource(this.authResource.resourceId).count(Filters.EQ(this.emailField.name, email));
204
+ if (existing > 0) {
205
+ return { error: 'Email already exists', ok: false };
206
+ }
207
+
208
+ // create user
209
+ const created = await this.adminforth.resource(this.authResource.resourceId).create({
210
+ ...(this.options.defaultFieldValues || {}),
211
+ ...(this.options.confirmEmails ? { [this.options.confirmEmails.emailConfirmedField]: false } : {}),
212
+ [this.emailField.name]: email,
213
+ [this.options.passwordHashField]: await AdminForth.Utils.generatePasswordHash(password),
214
+ });
215
+
216
+ if (!this.options.confirmEmails) {
217
+ const resp = await this.doLogin(email, response);
218
+ return resp;
219
+ }
220
+
221
+ // send confirmation email
222
+
223
+ const brandName = this.adminforth.config.customization.brandName;
224
+
225
+ const verifyToken = this.adminforth.auth.issueJWT({email, issuer: brandName }, 'tempVerifyEmailToken', '2h');
226
+
227
+ // send email with AWS SES this.options.providerOptions.AWS_SES
228
+ const ses = new SESClient({
229
+ region: this.options.confirmEmails.providerOptions.AWS_SES.region,
230
+ credentials: {
231
+ accessKeyId: this.options.confirmEmails.providerOptions.AWS_SES.accessKeyId,
232
+ secretAccessKey: this.options.confirmEmails.providerOptions.AWS_SES.secretAccessKey
233
+ }
234
+ });
235
+
236
+ process.env.HEAVY_DEBUG && console.log('🐛Sending reset tok to', verifyToken);
237
+
238
+ const emailCommand = new SendEmailCommand({
239
+ Destination: {
240
+ ToAddresses: [email]
241
+ },
242
+ Message: {
243
+ Body: {
244
+ Text: {
245
+ Charset: "UTF-8",
246
+ Data: `
247
+ Dear user,
248
+ Welcome to ${brandName}!
249
+
250
+ To confirm your email, click the link below:\n\n
251
+
252
+ ${url}?verifyToken=${verifyToken}\n\n
253
+
254
+ If you didn't request this, please ignore this email.\n\n
255
+ Link is valid for 2 hours.\n\n
256
+
257
+ Thanks,
258
+ The ${brandName} Team
259
+
260
+ `
261
+ },
262
+ Html: {
263
+ Charset: "UTF-8",
264
+ Data: `
265
+ <html>
266
+ <head></head>
267
+ <body>
268
+ <p>Dear user,</p>
269
+ <p>Welcome to ${brandName}!</p>
270
+ <p>To confirm your email, click the link below:</p>
271
+ <a href="${url}?token=${verifyToken}">Confirm email</a>
272
+ <p>If you didn't request this, please ignore this email.</p>
273
+ <p>Link is valid for 2 hours.</p>
274
+ <p>Thanks,</p>
275
+ <p>The ${brandName} Team</p>
276
+ </body>
277
+ </html>
278
+
279
+
280
+ `
281
+ }
282
+ },
283
+ Subject: {
284
+ Charset: "UTF-8",
285
+ Data: `Signup request at ${brandName}`
286
+ }
287
+ },
288
+ Source: `${this.options.confirmEmails.sendFrom}`
289
+ });
290
+
291
+ try {
292
+ const sRes = await ses.send(emailCommand);
293
+ } catch (e) {
294
+ console.error('Error sending email', e);
295
+ if (process.env.NODE_ENV === 'development') {
296
+ return { error: 'Some thing went wrong, please check the console', ok: false };
297
+ }
298
+ return { error: 'Something went wrong, please contact support', ok: false };
299
+ }
300
+
301
+ return { ok: true };
302
+ }
303
+ });
304
+
305
+
306
+
307
+ }
308
+
309
+ }
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@adminforth/open-signup",
3
+ "version": "1.0.2",
4
+ "main": "dist/index.js",
5
+ "types": "dist/index.d.ts",
6
+ "type": "module",
7
+ "scripts": {
8
+ "build": "tsc && rsync -av --exclude 'node_modules' custom dist/ && npm version patch",
9
+ "rollout": "npm run build && npm publish --access public",
10
+ "prepare": "npm link adminforth"
11
+ },
12
+ "keywords": [],
13
+ "author": "",
14
+ "license": "ISC",
15
+ "description": "",
16
+ "dependencies": {
17
+ "@aws-sdk/client-ses": "^3.654.0"
18
+ }
19
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include*/
4
+ "module": "node16", /* Specify what module code is generated. */
5
+ "outDir": "./dist", /* Specify an output folder for all emitted files. */
6
+ "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. */
7
+ "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
8
+ "strict": false, /* Enable all strict type-checking options. */
9
+ "skipLibCheck": true, /* Skip type checking all .d.ts files. */
10
+ },
11
+ "exclude": ["node_modules", "dist", "custom"], /* Exclude files from compilation. */
12
+ }
package/types.ts ADDED
@@ -0,0 +1,68 @@
1
+ export interface PluginOptions {
2
+
3
+ /**
4
+ * Field name in auth resource which contains email
5
+ */
6
+ emailField: string;
7
+
8
+ /**
9
+ * To work properly, this plugin requires a virtual password field in auth resource,
10
+ * which will define any constraints which determine the password strength (regex, minLength).
11
+ * When user will enter passowrd, it will be validated against this field constraints
12
+ * If you don't need it in backoffice itself, you can add it and set showIn = []
13
+ */
14
+ passwordField: string;
15
+
16
+ /**
17
+ * Non-virtual field name in auth resource which contains password hash
18
+ */
19
+ passwordHashField: string;
20
+
21
+ /**
22
+ * Default which will be assigned to user by default
23
+ * e.g. can be used to set default role
24
+ */
25
+ defaultFieldValues?: {
26
+ [key: string]: any;
27
+ }
28
+
29
+
30
+
31
+
32
+ /**
33
+ * If not set user will not be asked for email confirmation (might be dangerous)
34
+ */
35
+ confirmEmails?: {
36
+
37
+ /**
38
+ * A boolean field in auth resource which will be set to true when email is confirmed.
39
+ * Must have boolean type.
40
+ */
41
+ emailConfirmedField: string;
42
+
43
+ /**
44
+ * Required to confirm email
45
+ */
46
+ emailProvider: 'AWS_SES' ;
47
+
48
+ /**
49
+ * From which email to send password reset emails
50
+ * e.g. no-reply@example.com
51
+ * Example.com must be allowed in provider to send emails
52
+ */
53
+ sendFrom: string;
54
+
55
+ /**
56
+ * Options for email provider
57
+ */
58
+ providerOptions: {
59
+ AWS_SES: {
60
+ region: string;
61
+ accessKeyId: string;
62
+ secretAccessKey: string;
63
+ }
64
+ }
65
+ }
66
+
67
+
68
+ }