@adminforth/open-signup 1.0.0

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,346 @@
1
+ import AdminForth, { AdminForthPlugin, Filters, suggestIfTypo, AdminForthDataTypes } from "adminforth";
2
+ import type { IAdminForth, IHttpServer, AdminForthComponentDeclaration, AdminForthResourceColumn, AdminForthResource, BeforeLoginConfirmationFunction, HttpExtra } from "adminforth";
3
+ import type { PluginOptions } from './types.js';
4
+
5
+
6
+ export default class OpenSignupPlugin extends AdminForthPlugin {
7
+ options: PluginOptions;
8
+ emailField: AdminForthResourceColumn;
9
+ passwordField: AdminForthResourceColumn;
10
+ authResource: AdminForthResource;
11
+ emailConfirmedField?: AdminForthResourceColumn;
12
+
13
+ adminforth: IAdminForth;
14
+
15
+ constructor(options: PluginOptions) {
16
+ super(options, import.meta.url);
17
+ this.options = options;
18
+ }
19
+
20
+ async modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
21
+ super.modifyResourceConfig(adminforth, resourceConfig);
22
+
23
+ if (!this.options.emailField) {
24
+ throw new Error(`emailField is required and should be a name of field in auth resource`);
25
+ }
26
+
27
+ // find field with name resourceConfig.emailField in adminforth.auth.usersResourceId and show error if it doesn't exist
28
+ const authResource = adminforth.config.resources.find(r => r.resourceId === adminforth.config.auth.usersResourceId);
29
+ if (!authResource) {
30
+ throw new Error(`Resource with id config.auth.usersResourceId=${adminforth.config.auth.usersResourceId} not found`);
31
+ }
32
+ this.authResource = authResource;
33
+
34
+ if (this.options.confirmEmails) {
35
+ if (!this.options.confirmEmails.adapter) {
36
+ throw new Error(`confirmEmails.adapter is required and should be a name of field in auth resource`);
37
+ }
38
+ if (!this.options.confirmEmails.emailConfirmedField) {
39
+ throw new Error(`confirmEmails.emailConfirmedField is required and should be a name of field in auth resource`);
40
+ }
41
+ const emailConfirmedField = this.authResource.columns.find(f => f.name === this.options.confirmEmails.emailConfirmedField);
42
+ if (!emailConfirmedField) {
43
+ const similar = suggestIfTypo(this.authResource.columns.map(f => f.name), this.options.confirmEmails.emailConfirmedField);
44
+ throw new Error(`Field with name ${this.options.confirmEmails.emailConfirmedField} not found in resource ${this.authResource.resourceId}.
45
+ ${similar ? `Did you mean ${similar}?` : ''}
46
+ `);
47
+ }
48
+ this.emailConfirmedField = emailConfirmedField;
49
+ }
50
+
51
+ const emailField = authResource.columns.find(f => f.name === this.options.emailField);
52
+ if (!emailField) {
53
+ const similar = suggestIfTypo(authResource.columns.map(f => f.name), this.options.emailField);
54
+
55
+ throw new Error(`Field with name ${this.options.emailField} not found in resource ${authResource.resourceId}.
56
+ ${similar ? `Did you mean ${similar}?` : ''}
57
+ `);
58
+ }
59
+ this.emailField = emailField;
60
+
61
+ if (!this.options.passwordField) {
62
+ throw new Error(`passwordField is required to get password constraints and should be a name of virtual field in auth resource`);
63
+ }
64
+
65
+ const passwordField: AdminForthResourceColumn = authResource.columns.find(f => f.name === this.options.passwordField);
66
+ if (!passwordField) {
67
+ const similar = suggestIfTypo(authResource.columns.map(f => f.name), this.options.passwordField);
68
+ throw new Error(`Field with name ${this.options.passwordField} not found in resource ${authResource.resourceId}.
69
+ ${similar ? `Did you mean ${similar}?` : ''}
70
+ `);
71
+ }
72
+ this.passwordField = passwordField;
73
+
74
+ (adminforth.config.customization.loginPageInjections.underInputs as AdminForthComponentDeclaration[]).push({
75
+ file: this.componentPath('SignupUnderLogin.vue'),
76
+ });
77
+ adminforth.config.customization.customPages.push({
78
+ path: '/signup',
79
+ component: {
80
+ file: this.componentPath('SignupPage.vue'),
81
+ meta: {
82
+ customLayout: true,
83
+ pluginInstanceId: this.pluginInstanceId,
84
+ requestEmailConfirmation: !!this.options.confirmEmails
85
+ }
86
+ }
87
+ });
88
+
89
+ // for confirmation disable login if email is not confirmed
90
+ if (this.options.confirmEmails) {
91
+ if (!adminforth.config.auth.beforeLoginConfirmation) {
92
+ adminforth.config.auth.beforeLoginConfirmation = [];
93
+ }
94
+ }
95
+ }
96
+
97
+ validateConfigAfterDiscover(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
98
+ if (this.options.confirmEmails) {
99
+ this.options.confirmEmails.adapter.validate();
100
+ }
101
+
102
+
103
+ if(this.options.confirmEmails){
104
+ const emailConfirmedColumn = this.resourceConfig.columns.find(f => f.name === this.options.confirmEmails.emailConfirmedField);
105
+ if (emailConfirmedColumn.type !== AdminForthDataTypes.BOOLEAN) {
106
+ throw new Error(`Field ${this.emailConfirmedField.name} must be of type boolean`);
107
+ }
108
+ }
109
+ }
110
+
111
+ instanceUniqueRepresentation(pluginOptions: any) : string {
112
+ // optional method to return unique string representation of plugin instance.
113
+ // Needed if plugin can have multiple instances on one resource
114
+ return `single`;
115
+ }
116
+
117
+ async doLogin(email: string, response: any, extra: HttpExtra): Promise<{ error?: string; allowedLogin: boolean; redirectTo?: string; }> {
118
+
119
+ const username = email;
120
+ const userRecord = await this.adminforth.resource(this.authResource.resourceId).get(Filters.EQ(this.emailField.name, email));
121
+ const adminUser = {
122
+ dbUser: userRecord,
123
+ pk: userRecord[this.authResource.columns.find((col) => col.primaryKey).name],
124
+ username,
125
+ };
126
+ const toReturn = { allowedLogin: true, error: '' };
127
+
128
+ await this.adminforth.restApi.processLoginCallbacks(adminUser, toReturn, response, extra);
129
+ if (toReturn.allowedLogin) {
130
+ this.adminforth.auth.setAuthCookie({
131
+ response,
132
+ username,
133
+ pk: userRecord[
134
+ this.authResource.columns.find((col) => col.primaryKey).name
135
+ ]
136
+ });
137
+ }
138
+
139
+ return toReturn;
140
+ }
141
+
142
+
143
+ setupEndpoints(server: IHttpServer) {
144
+
145
+ server.endpoint({
146
+ method: 'GET',
147
+ path: `/plugin/${this.pluginInstanceId}/password-constraints`,
148
+ noAuth: true,
149
+ handler: async ({tr}) => {
150
+ return {
151
+ minLength: this.passwordField.minLength,
152
+ maxLength: this.passwordField.maxLength,
153
+ validation: await Promise.all(
154
+ this.passwordField.validation.map(async ({ regExp, message }) => ({ regExp, message: await tr(message, 'opensignup') }))
155
+ ),
156
+ };
157
+ }
158
+ });
159
+
160
+ server.endpoint({
161
+ method: 'POST',
162
+ path: `/plugin/${this.pluginInstanceId}/complete-verified-signup`,
163
+ noAuth: true,
164
+ handler: async ({ body, response, headers, query, cookies, tr, requestUrl }) => {
165
+ const { token, password } = body;
166
+ const { email } = await this.adminforth.auth.verify(token, 'tempVerifyEmailToken', false);
167
+ if (!email) {
168
+ return { error: 'Invalid token', ok: false };
169
+ }
170
+
171
+ if(!password) {
172
+ return { error: tr('Password is required', 'opensignup'), ok: false };
173
+ }
174
+ const userRecord = await this.adminforth.resource(this.authResource.resourceId).get(Filters.EQ(this.emailField.name, email));
175
+ if (!userRecord) {
176
+ return { error: tr('User not found'), ok: false };
177
+ }
178
+
179
+ if (userRecord[this.options.confirmEmails.emailConfirmedField]) {
180
+ return { error: tr('Email already confirmed'), ok: false };
181
+ }
182
+
183
+ await this.adminforth.resource(this.authResource.resourceId).update(userRecord[this.authResource.columns.find((col) => col.primaryKey).name], {
184
+ [this.options.confirmEmails.emailConfirmedField]: true,
185
+ [this.options.passwordHashField]: await AdminForth.Utils.generatePasswordHash(password),
186
+ });
187
+ return await this.doLogin(email, response, { body, headers, query, cookies, requestUrl });
188
+ }
189
+ });
190
+
191
+ server.endpoint({
192
+ method: 'POST',
193
+ path: `/plugin/${this.pluginInstanceId}/signup`,
194
+ noAuth: true,
195
+ handler: async ({ body, response, headers, query, cookies, tr, requestUrl }) => {
196
+ const { email, url, password } = body;
197
+ const extra = { body, headers, query, cookies, requestUrl: url };
198
+ // validate email
199
+ if (this.emailField.validation) {
200
+ for (const { regExp, message } of this.emailField.validation) {
201
+ if (!new RegExp(regExp).test(email)) {
202
+ return { error: await tr(message, 'opensignup'), ok: false };
203
+ }
204
+ }
205
+ }
206
+
207
+ // validate password
208
+ if (!this.options.confirmEmails) {
209
+ if (password.length < this.passwordField.minLength) {
210
+ return {
211
+ error: await tr(`Password must be at least ${this.passwordField.minLength} characters long`, 'opensignup'),
212
+ ok: false
213
+ };
214
+ }
215
+ if (password.length > this.passwordField.maxLength) {
216
+ return {
217
+ error: await tr(`Password must be at most ${this.passwordField.maxLength} characters long`, 'opensignup'),
218
+ ok: false
219
+ };
220
+ }
221
+ if (this.passwordField.validation) {
222
+ for (const { regExp, message } of this.passwordField.validation) {
223
+ if (!new RegExp(regExp).test(password)) {
224
+ return { error: await tr(message, 'opensignup'), ok: false };
225
+ }
226
+ }
227
+ }
228
+ }
229
+
230
+ // This is not needed when right email validator is set on email field because
231
+ // it will not allow to create such email, but if user forgot to set it it might save situation
232
+ const normalizedEmail = email.toLowerCase(); // normalize email
233
+
234
+ // first check again if email already exists
235
+ const existingUser = await this.adminforth.resource(this.authResource.resourceId).get(Filters.EQ(this.emailField.name, normalizedEmail));
236
+ if ((!this.options.confirmEmails && existingUser) || (this.options.confirmEmails && existingUser?.[this.emailConfirmedField.name])) {
237
+ return { error: await tr(`Email already exists`, 'opensignup'), ok: false };
238
+ }
239
+
240
+ // create user
241
+ if (!existingUser) {
242
+ if (this.options.hooks?.beforeUserSave) {
243
+ const hook = this.options.hooks.beforeUserSave;
244
+ const recordToCreate = {
245
+ ...(this.options.defaultFieldValues || {}),
246
+ ...(this.options.confirmEmails ? { [this.options.confirmEmails.emailConfirmedField]: false } : {}),
247
+ [this.emailField.name]: normalizedEmail,
248
+ [this.options.passwordHashField]: password ? await AdminForth.Utils.generatePasswordHash(password) : '',
249
+ };
250
+ const resp = await hook({
251
+ resource: this.authResource,
252
+ record: recordToCreate,
253
+ adminforth: this.adminforth,
254
+ extra,
255
+ });
256
+
257
+ if (!resp || (!resp.ok && !resp.error)) {
258
+ throw new Error(`Hook beforeUserSave must return object with {ok: true} or { error: 'Error' } `);
259
+ }
260
+ if (resp.error) {
261
+ return { error: resp.error };
262
+ }
263
+ }
264
+ const created = await this.adminforth.resource(this.authResource.resourceId).create({
265
+ ...(this.options.defaultFieldValues || {}),
266
+ ...(this.options.confirmEmails ? { [this.options.confirmEmails.emailConfirmedField]: false } : {}),
267
+ [this.emailField.name]: normalizedEmail,
268
+ [this.options.passwordHashField]: password ? await AdminForth.Utils.generatePasswordHash(password) : '',
269
+ });
270
+
271
+ if (this.options.hooks?.afterUserSave) {
272
+ const hook = this.options.hooks.afterUserSave;
273
+ const resp = await hook({
274
+ resource: this.authResource,
275
+ record: created,
276
+ adminforth: this.adminforth,
277
+ extra,
278
+ });
279
+
280
+ if (!resp || (!resp.ok && !resp.error)) {
281
+ throw new Error(`Hook afterUserSave must return object with {ok: true} or { error: 'Error' } `);
282
+ }
283
+ if (resp.error) {
284
+ return { error: resp.error };
285
+ }
286
+ }
287
+ }
288
+
289
+ if (!this.options.confirmEmails) {
290
+ const resp = await this.doLogin(email, response, { body, headers, query, cookies, requestUrl });
291
+ return resp;
292
+ }
293
+
294
+ // send confirmation email
295
+
296
+ const brandName = this.adminforth.config.customization.brandName;
297
+
298
+ const verifyToken = this.adminforth.auth.issueJWT({email, issuer: brandName }, 'tempVerifyEmailToken', '2h');
299
+ process.env.HEAVY_DEBUG && console.log('🐛Sending reset tok to', verifyToken);
300
+ const emailText = tr(`
301
+ Dear user,
302
+ Welcome to {brandName}!
303
+
304
+ To confirm your email, click the link below:\n\n
305
+
306
+ {url}?verifyToken={verifyToken}\n\n
307
+
308
+ If you didn't request this, please ignore this email.\n\n
309
+ Link is valid for 2 hours.\n\n
310
+
311
+ Thanks,
312
+ The {brandName} Team
313
+
314
+ `, 'opensignup', { brandName, url, verifyToken }
315
+ );
316
+
317
+ const emailHtml = tr(`
318
+ <html>
319
+ <head></head>
320
+ <body>
321
+ <p>Dear user,</p>
322
+ <p>Welcome to {brandName}!</p>
323
+ <p>To confirm your email, click the link below:</p>
324
+ <a href="{url}?token={verifyToken}">Confirm email</a>
325
+ <p>If you didn't request this, please ignore this email.</p>
326
+ <p>Link is valid for 2 hours.</p>
327
+ <p>Thanks,</p>
328
+ <p>The {brandName} Team</p>
329
+ </body>
330
+ </html>
331
+ `, 'opensignup', { brandName, url, verifyToken });
332
+
333
+ const emailSubject = tr(`Signup request at {brandName}`, 'opensignup', { brandName });
334
+
335
+ // send email with AWS SES this.options.providerOptions.AWS_SES
336
+ this.options.confirmEmails.adapter.sendEmail(this.options.confirmEmails.sendFrom, email, emailText, emailHtml, emailSubject);
337
+
338
+ return { ok: true };
339
+ }
340
+ });
341
+
342
+
343
+
344
+ }
345
+
346
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@adminforth/open-signup",
3
+ "version": "1.0.0",
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/",
9
+ "prepare": "npm link adminforth"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://github.com/devforth/adminforth-open-signup.git"
14
+ },
15
+ "keywords": [
16
+ "adminforth",
17
+ "signup"
18
+ ],
19
+ "author": "devforth",
20
+ "license": "ISC",
21
+ "description": "Open signup for adminforth",
22
+ "dependencies": {
23
+ "@aws-sdk/client-ses": "^3.654.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^22.10.7",
27
+ "semantic-release": "^24.2.1",
28
+ "semantic-release-slack-bot": "^4.0.2",
29
+ "typescript": "^5.7.3"
30
+ },
31
+ "release": {
32
+ "plugins": [
33
+ "@semantic-release/commit-analyzer",
34
+ "@semantic-release/release-notes-generator",
35
+ "@semantic-release/npm",
36
+ "@semantic-release/github",
37
+ [
38
+ "semantic-release-slack-bot",
39
+ {
40
+ "notifyOnSuccess": true,
41
+ "notifyOnFail": true,
42
+ "slackIcon": ":package:",
43
+ "markdownReleaseNotes": true
44
+ }
45
+ ]
46
+ ],
47
+ "branches": [
48
+ "main",
49
+ {
50
+ "name": "next",
51
+ "prerelease": true
52
+ }
53
+ ]
54
+ }
55
+ }
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
+ import { EmailAdapter } from 'adminforth';
2
+
3
+ export interface PluginOptions {
4
+
5
+ /**
6
+ * Field name in auth resource which contains email
7
+ */
8
+ emailField: string;
9
+
10
+ /**
11
+ * To work properly, this plugin requires a virtual password field in auth resource,
12
+ * which will define any constraints which determine the password strength (regex, minLength).
13
+ * When user will enter passowrd, it will be validated against this field constraints
14
+ * If you don't need it in backoffice itself, you can add it and set showIn = []
15
+ */
16
+ passwordField: string;
17
+
18
+ /**
19
+ * Non-virtual field name in auth resource which contains password hash
20
+ */
21
+ passwordHashField: string;
22
+
23
+ /**
24
+ * Default which will be assigned to user by default
25
+ * e.g. can be used to set default role
26
+ */
27
+ defaultFieldValues?: {
28
+ [key: string]: any;
29
+ }
30
+
31
+
32
+
33
+
34
+ /**
35
+ * If not set user will not be asked for email confirmation (might be dangerous)
36
+ */
37
+ confirmEmails?: {
38
+
39
+ /**
40
+ * A boolean field in auth resource which will be set to true when email is confirmed.
41
+ * Must have boolean type.
42
+ */
43
+ emailConfirmedField: string;
44
+
45
+ /**
46
+ * From which email to send password reset emails
47
+ * e.g. no-reply@example.com
48
+ * Example.com must be allowed in provider to send emails
49
+ */
50
+ sendFrom: string;
51
+
52
+ /**
53
+ * Adapter to send emails
54
+ */
55
+ adapter: EmailAdapter;
56
+ }
57
+
58
+
59
+ /**
60
+ * Hooks to be executed before and after user is created
61
+ */
62
+ hooks?: {
63
+ beforeUserSave?: any;
64
+ afterUserSave?: any;
65
+ }
66
+
67
+
68
+ }