@design-edito/cli 0.0.78 → 0.0.79

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.
Files changed (55) hide show
  1. package/cli/assets/list.txt +1 -2
  2. package/cli/assets/version.txt +1 -1
  3. package/make-template/assets/express/esbuild.config.js +19 -0
  4. package/make-template/assets/express/package.json +16 -12
  5. package/make-template/assets/express/src/index.ts +14 -50
  6. package/make-template/assets/express/src/public/index.css +7 -0
  7. package/make-template/assets/express/src/public/index.html +15 -0
  8. package/make-template/assets/express/src/public/index.js +31 -0
  9. package/make-template/assets/express/src/tsconfig.json +7 -3
  10. package/make-template/assets/express/src/www/index.ts +42 -0
  11. package/make-template/assets/express-api/Dockerfile +18 -0
  12. package/make-template/assets/express-api/Dockerfile.dev +8 -0
  13. package/make-template/assets/express-api/env +36 -0
  14. package/make-template/assets/express-api/esbuild.config.js +26 -0
  15. package/make-template/assets/express-api/gitignore +214 -0
  16. package/make-template/assets/express-api/package.json +60 -0
  17. package/make-template/assets/express-api/src/api/auth/_utils/index.ts +47 -0
  18. package/make-template/assets/express-api/src/api/auth/index.ts +25 -0
  19. package/make-template/assets/express-api/src/api/auth/login/index.ts +101 -0
  20. package/make-template/assets/express-api/src/api/auth/logout/index.ts +45 -0
  21. package/make-template/assets/express-api/src/api/auth/refresh-token/index.ts +54 -0
  22. package/make-template/assets/express-api/src/api/auth/request-email-verification-token/index.ts +45 -0
  23. package/make-template/assets/express-api/src/api/auth/request-new-password/index.ts +62 -0
  24. package/make-template/assets/express-api/src/api/auth/signup/index.ts +99 -0
  25. package/make-template/assets/express-api/src/api/auth/submit-new-password/index.ts +61 -0
  26. package/make-template/assets/express-api/src/api/auth/verify-email/index.ts +79 -0
  27. package/make-template/assets/express-api/src/api/auth/whoami/index.ts +68 -0
  28. package/make-template/assets/express-api/src/api/index.ts +18 -0
  29. package/make-template/assets/express-api/src/api/types.ts +76 -0
  30. package/make-template/assets/express-api/src/api/utils.ts +146 -0
  31. package/make-template/assets/express-api/src/database/index.ts +173 -0
  32. package/make-template/assets/express-api/src/email/index.ts +47 -0
  33. package/make-template/assets/express-api/src/env/index.ts +77 -0
  34. package/make-template/assets/express-api/src/errors/index.ts +128 -0
  35. package/make-template/assets/express-api/src/index.ts +42 -0
  36. package/make-template/assets/express-api/src/init/index.ts +156 -0
  37. package/make-template/assets/express-api/src/jwt/index.ts +105 -0
  38. package/make-template/assets/express-api/src/public/index.css +7 -0
  39. package/make-template/assets/express-api/src/public/index.html +15 -0
  40. package/make-template/assets/express-api/src/public/index.js +31 -0
  41. package/make-template/assets/express-api/src/schema/_history/index.ts +124 -0
  42. package/make-template/assets/express-api/src/schema/_meta/index.ts +113 -0
  43. package/make-template/assets/express-api/src/schema/index.ts +17 -0
  44. package/make-template/assets/express-api/src/schema/user/index.ts +117 -0
  45. package/make-template/assets/express-api/src/schema/user-email-validation-token/index.ts +20 -0
  46. package/make-template/assets/express-api/src/schema/user-password-renewal-token/index.ts +20 -0
  47. package/make-template/assets/express-api/src/schema/user-revoked-auth-token/index.ts +26 -0
  48. package/make-template/assets/express-api/src/tsconfig.json +16 -0
  49. package/make-template/assets/express-api/src/www/index.ts +43 -0
  50. package/make-template/index.js +2 -3
  51. package/make-template/index.js.map +3 -3
  52. package/package.json +7 -8
  53. package/make-template/assets/express/src/routes/index.ts +0 -7
  54. package/upgrade/index.js +0 -12
  55. package/upgrade/index.js.map +0 -7
@@ -0,0 +1,47 @@
1
+ import { randomUUID } from 'crypto'
2
+ import { Duration } from '@design-edito/tools/agnostic/time/duration'
3
+ import { Logs } from '@design-edito/tools/agnostic/misc/logs'
4
+ import * as Database from '../../../database'
5
+ import * as Email from '../../../email'
6
+ import {
7
+ USER_EMAIL_VALIDATION_TOKEN_LIFETIME_MINUTES,
8
+ EMAILER_EMAIL,
9
+ EMAILER_NAME,
10
+ ROOT_USER_ID
11
+ } from '../../../env'
12
+ import {
13
+ IUserEmailValidationToken,
14
+ UserEmailValidationTokenModel
15
+ } from '../../../schema/user-email-validation-token'
16
+
17
+ export async function createAndSendUserEmailValidationToken (email: string, username: string) {
18
+ const tokenLifetimeMinutes = parseInt(USER_EMAIL_VALIDATION_TOKEN_LIFETIME_MINUTES)
19
+ const tokenLifetimeMs = Duration.minutes(tokenLifetimeMinutes).toMilliseconds()
20
+ const tokenExpiresOnTimestamp = Date.now() + tokenLifetimeMs
21
+ const tokenExpiresOn = new Date(tokenExpiresOnTimestamp)
22
+ const tokenValue = randomUUID()
23
+ const tokenDocument: IUserEmailValidationToken = {
24
+ email,
25
+ value: tokenValue,
26
+ expiresOn: tokenExpiresOn,
27
+ }
28
+ const tokenInserted = await Database.insertOne(UserEmailValidationTokenModel, tokenDocument, { initiatorId: ROOT_USER_ID })
29
+ if (!tokenInserted.success) {
30
+ console.log(Logs.styles.error('Token not inserted'))
31
+ console.log(Logs.styles.regular(tokenInserted.error.message))
32
+ console.log(tokenInserted.error.details)
33
+ // [WIP] what do here ?
34
+ // send mail to admin ?
35
+ // Retry ?
36
+ // Send fallback mail to user something like 'request a new token' ?
37
+ // Do nothing and try create and send token again when trying to login ?
38
+ }
39
+ const emailBody = Email.makeUserEmailVerificationTokenBody(username, tokenValue)
40
+ return await Email.send(
41
+ EMAILER_EMAIL,
42
+ EMAILER_NAME,
43
+ email,
44
+ username,
45
+ 'Validate your email',
46
+ emailBody)
47
+ }
@@ -0,0 +1,25 @@
1
+ // import { google } from './google'
2
+ // import { googleCallback } from './google-callback'
3
+ import { login } from './login'
4
+ import { logout } from './logout'
5
+ import { refreshToken } from './refresh-token'
6
+ import { requestEmailVerificationToken } from './request-email-verification-token'
7
+ import { requestNewPassword } from './request-new-password'
8
+ import { signup } from './signup'
9
+ import { submitNewPassword } from './submit-new-password'
10
+ import { verifyEmail } from './verify-email'
11
+ import { whoami } from './whoami'
12
+
13
+ export {
14
+ // google,
15
+ // googleCallback,
16
+ login,
17
+ logout,
18
+ refreshToken,
19
+ requestEmailVerificationToken,
20
+ requestNewPassword,
21
+ signup,
22
+ submitNewPassword,
23
+ verifyEmail,
24
+ whoami
25
+ }
@@ -0,0 +1,101 @@
1
+ import bcrypt from 'bcrypt'
2
+ import validator from 'validator'
3
+ import zod from 'zod'
4
+ import { Outcome } from '@design-edito/tools/agnostic/misc/outcome'
5
+ import { unknownToString } from '@design-edito/tools/agnostic/errors/unknown-to-string'
6
+ import { isNonNullObject } from '@design-edito/tools/agnostic/objects/is-object'
7
+ import * as Database from '../../../database'
8
+ import { Codes, makeFailureOutcome } from '../../../errors'
9
+ import { ROOT_USER_ID } from '../../../env'
10
+ import * as Jwt from '../../../jwt'
11
+ import { Role, Status, LocalUserModel } from '../../../schema/user'
12
+ import { makeEndpoint, nullAuthenticator } from '../../utils'
13
+
14
+ export type ExpectedBody = {
15
+ email: string
16
+ password: string
17
+ } | {
18
+ username: string
19
+ password: string
20
+ }
21
+
22
+ export type SuccessResponse = {
23
+ _id: string
24
+ username: string
25
+ email: string
26
+ role: Role
27
+ status: Status
28
+ verified: boolean
29
+ }
30
+
31
+ export const login = makeEndpoint<ExpectedBody, SuccessResponse>(
32
+ /* Authentication */
33
+ nullAuthenticator,
34
+
35
+ /* Validation */
36
+ async req => {
37
+ const { body } = req
38
+ if (!isNonNullObject(body)) return makeFailureOutcome(Codes.INVALID_REQUEST_BODY, body, 'Must be an object')
39
+ if ('email' in body) {
40
+ const withEmailSchema = zod.object({
41
+ email: zod.string().email('Invalid email format'),
42
+ password: zod.string().min(1, 'Password must be at least one character long')
43
+ })
44
+ try {
45
+ const withEmailValidated = withEmailSchema.parse(body)
46
+ return Outcome.makeSuccess(withEmailValidated)
47
+ } catch (err) {
48
+ const errStr = unknownToString(err)
49
+ return makeFailureOutcome(Codes.INVALID_REQUEST_BODY, body, errStr)
50
+ }
51
+ } else if ('username' in body) {
52
+ const withUsernameSchema = zod.object({
53
+ username: zod.string()
54
+ .min(1)
55
+ .refine(input => validator.isSlug(input.toLowerCase()), {
56
+ message: 'Username must contain alphanumeric or non starting, trailing nor consecutive hyphens or underscores'
57
+ }),
58
+ password: zod.string().min(1, 'Password must be at least one character long')
59
+ })
60
+ try {
61
+ const withUsernameValidated = withUsernameSchema.parse(body)
62
+ return Outcome.makeSuccess(withUsernameValidated)
63
+ } catch (err) {
64
+ const errStr = unknownToString(err)
65
+ return makeFailureOutcome(Codes.INVALID_REQUEST_BODY, body, errStr)
66
+ }
67
+ } else {
68
+ return makeFailureOutcome(Codes.INVALID_REQUEST_BODY, body, 'Missing username or email property')
69
+ }
70
+ },
71
+
72
+ /* Process */
73
+ async (body, _req, res) => {
74
+ const { password } = body
75
+ const query = 'email' in body ? { email: body.email } : { username: body.username }
76
+ const foundUser = await Database.findOne(LocalUserModel, query, { initiatorId: ROOT_USER_ID })
77
+ if (!foundUser.success) {
78
+ const err = foundUser.error
79
+ if (err.code === Codes.DB_ERROR) return foundUser
80
+ return makeFailureOutcome(Codes.INVALID_CREDENTIALS)
81
+ }
82
+ const passwordsMatch = await bcrypt.compare(password, foundUser.payload.password)
83
+ if (!passwordsMatch) return makeFailureOutcome(Codes.INVALID_CREDENTIALS)
84
+ const userData = foundUser.payload
85
+ const userId = userData._id.toString()
86
+ const accessToken = await Jwt.generateAccessToken(userId, 0)
87
+ const refreshToken = await Jwt.generateRefreshToken(userId, 0)
88
+ if (!accessToken.success) return accessToken // [WIP] maybe wrap this into a less verbose error?
89
+ if (!refreshToken.success) return refreshToken // [WIP] maybe wrap this into a less verbose error?
90
+ Jwt.attachAccessTokenToRes(res, accessToken.payload)
91
+ Jwt.attachRefreshTokenToRes(res, refreshToken.payload)
92
+ return Outcome.makeSuccess({
93
+ _id: userId,
94
+ username: userData.username,
95
+ email: userData.email,
96
+ role: userData.role,
97
+ status: userData.status,
98
+ verified: userData.verified
99
+ })
100
+ }
101
+ )
@@ -0,0 +1,45 @@
1
+ import { Outcome } from '@design-edito/tools/agnostic/misc/outcome'
2
+ import * as Database from '../../../database'
3
+ import { ROOT_USER_ID } from '../../../env'
4
+ import { Codes, makeFailureOutcome } from '../../../errors'
5
+ import { BaseUserModel } from '../../../schema/user'
6
+ import { UserRevokedTokenModel } from '../../../schema/user-revoked-auth-token'
7
+ import { makeEndpoint, nullValidator } from '../../utils'
8
+
9
+ export const logout = makeEndpoint<{}, {}>(
10
+ /* Authentication */
11
+ async (_req, res) => {
12
+ // [WIP] this generic "user authed and exists" stuff will have to live elsewhere
13
+ const { accessTokenPayload, accessTokenSigned } = res.locals
14
+ if (accessTokenPayload === undefined) return makeFailureOutcome(Codes.USER_NOT_AUTHENTICATED, 'No access token provided')
15
+ const { userId, exp } = accessTokenPayload
16
+ const now = Date.now()
17
+ const tokenHasExpired = now >= exp * 1000
18
+ if (tokenHasExpired) return makeFailureOutcome(Codes.USER_NOT_AUTHENTICATED, 'Access token has expired')
19
+ const foundRevokedToken = await Database.findOne(UserRevokedTokenModel, { value: accessTokenSigned }, { initiatorId: ROOT_USER_ID })
20
+ if (foundRevokedToken.success) return makeFailureOutcome(Codes.USER_NOT_AUTHENTICATED, 'Access token has been revoked')
21
+ const foundUser = await Database.findOne(BaseUserModel, { _id: userId }, { initiatorId: ROOT_USER_ID })
22
+ if (!foundUser.success) return makeFailureOutcome(Codes.USER_DOES_NOT_EXIST, `Could not find a user with id ${userId}`)
23
+ return Outcome.makeSuccess(true)
24
+ },
25
+
26
+ /* Validation */
27
+ nullValidator,
28
+
29
+ /* Process */
30
+ async (_body, _req, res) => {
31
+ const { accessTokenSigned, refreshTokenSigned } = res.locals
32
+ const now = new Date()
33
+ const toRevoke = [accessTokenSigned, refreshTokenSigned]
34
+ .filter(token => token !== undefined)
35
+ .map(token => ({ value: token, revokedOn: now }))
36
+ if (toRevoke.length === 0) return Outcome.makeSuccess({})
37
+ const blacklisted = await Database.insertMany(
38
+ UserRevokedTokenModel,
39
+ toRevoke,
40
+ { initiatorId: ROOT_USER_ID }
41
+ )
42
+ if (!blacklisted.success) return blacklisted
43
+ return Outcome.makeSuccess({})
44
+ }
45
+ )
@@ -0,0 +1,54 @@
1
+ import jwt from 'jsonwebtoken'
2
+ import { Outcome } from '@design-edito/tools/agnostic/misc/outcome'
3
+ import { unknownToString } from '@design-edito/tools/agnostic/errors/unknown-to-string'
4
+ import * as Database from '../../../database'
5
+ import { JWT_SECRET, REFRESH_TOKEN_MAX_REFRESH_COUNT, ROOT_USER_ID } from '../../../env'
6
+ import { Codes, makeFailureOutcome } from '../../../errors'
7
+ import * as Jwt from '../../../jwt'
8
+ import { BaseUserModel } from '../../../schema/user'
9
+ import { makeEndpoint, nullValidator } from '../../utils'
10
+ import { UserRevokedTokenModel } from '../../../schema/user-revoked-auth-token'
11
+
12
+ export const refreshToken = makeEndpoint<{}, {}>(
13
+
14
+ /* Authentication */
15
+ async (req, res) => {
16
+ const refreshToken = req.cookies?.refreshToken
17
+ const tokenIsString = typeof refreshToken === 'string'
18
+ const foundRevokedToken = await Database.findOne(UserRevokedTokenModel, { value: refreshToken }, { initiatorId: ROOT_USER_ID })
19
+ if (foundRevokedToken.success) return makeFailureOutcome(Codes.USER_NOT_AUTHENTICATED, 'Refresh token has been revoked')
20
+ if (!tokenIsString) return makeFailureOutcome(Codes.USER_NOT_AUTHENTICATED, 'No refresh token provided')
21
+ try {
22
+ const decoded = jwt.verify(refreshToken, JWT_SECRET)
23
+ if (!Jwt.isValidPayload(decoded)) return makeFailureOutcome(Codes.USER_NOT_AUTHENTICATED, 'Malformed refresh token')
24
+ const { userId, exp, refreshCount } = decoded
25
+ const now = Math.floor(Date.now() / 1000)
26
+ const tokenHasExpired = now >= exp
27
+ if (tokenHasExpired) return makeFailureOutcome(Codes.USER_NOT_AUTHENTICATED, 'Refresh token has expired')
28
+ if (refreshCount >= REFRESH_TOKEN_MAX_REFRESH_COUNT) return makeFailureOutcome(Codes.USER_NOT_AUTHENTICATED, 'Refresh token has reached maximum refresh count')
29
+ const foundUser = await Database.findOne(BaseUserModel, { _id: userId }, { initiatorId: ROOT_USER_ID })
30
+ if (!foundUser.success) return makeFailureOutcome(Codes.USER_DOES_NOT_EXIST, `Could not find a user with id ${userId}`)
31
+ res.locals.refreshTokenSigned = refreshToken
32
+ res.locals.refreshTokenPayload = decoded
33
+ } catch (err) {
34
+ return makeFailureOutcome(Codes.USER_NOT_AUTHENTICATED, `Bad refresh token: ${unknownToString(err)}`)
35
+ }
36
+ return Outcome.makeSuccess(true)
37
+ },
38
+
39
+ /* Request body validation (none) */
40
+ nullValidator,
41
+
42
+ /* Process request */
43
+ async (_body, _req, res) => {
44
+ if (res.locals.refreshTokenPayload === undefined) return makeFailureOutcome(Codes.USER_NOT_AUTHENTICATED, 'Refresh token has expired')
45
+ const { userId, refreshCount } = res.locals.refreshTokenPayload
46
+ const newAccessToken = await Jwt.generateAccessToken(userId, 0)
47
+ const newRefreshToken = await Jwt.generateRefreshToken(userId, refreshCount + 1)
48
+ if (!newAccessToken.success) return newAccessToken // [WIP] maybe wrap this into a less verbose error?
49
+ if (!newRefreshToken.success) return newRefreshToken // [WIP] maybe wrap this into a less verbose error?
50
+ Jwt.attachAccessTokenToRes(res, newAccessToken.payload)
51
+ Jwt.attachRefreshTokenToRes(res, newRefreshToken.payload)
52
+ return Outcome.makeSuccess({})
53
+ }
54
+ )
@@ -0,0 +1,45 @@
1
+ import zod from 'zod'
2
+ import { Outcome } from '@design-edito/tools/agnostic/misc/outcome'
3
+ import { isNonNullObject } from '@design-edito/tools/agnostic/objects/is-object'
4
+ import { unknownToString } from '@design-edito/tools/agnostic/errors/unknown-to-string'
5
+ import * as Database from '../../../database'
6
+ import { ROOT_USER_ID } from '../../../env'
7
+ import { Codes, makeFailureOutcome } from '../../../errors'
8
+ import { LocalUserModel } from '../../../schema/user'
9
+ import { UserEmailValidationTokenModel } from '../../../schema/user-email-validation-token'
10
+ import { makeEndpoint, nullAuthenticator } from '../../utils'
11
+ import { createAndSendUserEmailValidationToken } from '../_utils'
12
+
13
+ export type ExpectedBody = { email: string }
14
+
15
+ export const requestEmailVerificationToken = makeEndpoint<ExpectedBody, {}>(
16
+ /* Auth */
17
+ nullAuthenticator,
18
+
19
+ /* Validation */
20
+ async req => {
21
+ const { body } = req
22
+ if (!isNonNullObject(body)) return makeFailureOutcome(Codes.INVALID_REQUEST_BODY, body, 'Must be an object')
23
+ const validationSchema = zod.object({ email: zod.string().email('Invalid email format') })
24
+ try {
25
+ const validated = validationSchema.parse(body)
26
+ return Outcome.makeSuccess(validated)
27
+ } catch (err) {
28
+ const errStr = unknownToString(err)
29
+ return makeFailureOutcome(Codes.INVALID_REQUEST_BODY, body, errStr)
30
+ }
31
+ },
32
+
33
+ /* Process */
34
+ async body => {
35
+ const { email } = body
36
+ const userLookup = await Database.findOne(LocalUserModel, { email }, { initiatorId: ROOT_USER_ID })
37
+ if (!userLookup.success) return makeFailureOutcome(Codes.USER_EMAIL_DOES_NOT_EXIST, email)
38
+ const tokensDeletion = await Database.deleteMany(UserEmailValidationTokenModel, { email }, { initiatorId: ROOT_USER_ID })
39
+ if (!tokensDeletion.success) { /* [WIP] what do ? */ }
40
+ const userData = userLookup.payload
41
+ if (userData.verified === true) return makeFailureOutcome(Codes.USER_EMAIL_ALREADY_VERIFIED, email) // [WIP] maybe this discloses user data?
42
+ await createAndSendUserEmailValidationToken(email, userData.username)
43
+ return Outcome.makeSuccess({})
44
+ }
45
+ )
@@ -0,0 +1,62 @@
1
+ import { randomUUID } from 'crypto'
2
+ import zod from 'zod'
3
+ import { Outcome } from '@design-edito/tools/agnostic/misc/outcome'
4
+ import { isNonNullObject } from '@design-edito/tools/agnostic/objects/is-object'
5
+ import { unknownToString } from '@design-edito/tools/agnostic/errors/unknown-to-string'
6
+ import { Duration } from '@design-edito/tools/agnostic/time/duration'
7
+ import * as Database from '../../../database'
8
+ import * as Email from '../../../email'
9
+ import { EMAILER_EMAIL, EMAILER_NAME, ROOT_USER_ID } from '../../../env'
10
+ import { makeFailureOutcome, Codes } from '../../../errors'
11
+ import { LocalUserModel } from '../../../schema/user'
12
+ import { UserPasswordRenewalTokenModel } from '../../../schema/user-password-renewal-token'
13
+ import { makeEndpoint, nullAuthenticator } from '../../utils'
14
+
15
+ export type ExpectedBody = {
16
+ email: string
17
+ }
18
+
19
+ export const requestNewPassword = makeEndpoint<ExpectedBody, {}>(
20
+ /* Authentication */
21
+ nullAuthenticator,
22
+
23
+ /* Validation */
24
+ async req => {
25
+ const { body } = req
26
+ if (!isNonNullObject(body)) return makeFailureOutcome(Codes.INVALID_REQUEST_BODY, body, 'Must be an object')
27
+ const validationSchema = zod.object({ email: zod.string().email('Invalid email format') })
28
+ try {
29
+ const validated = validationSchema.parse(body)
30
+ return Outcome.makeSuccess(validated)
31
+ } catch (err) {
32
+ const errStr = unknownToString(err)
33
+ return makeFailureOutcome(Codes.INVALID_REQUEST_BODY, body, errStr)
34
+ }
35
+ },
36
+
37
+ /* Process */
38
+ async body => {
39
+ const foundUser = await Database.findOne(LocalUserModel, { email: body.email }, { initiatorId: ROOT_USER_ID })
40
+ if (!foundUser.success) return makeFailureOutcome(Codes.USER_EMAIL_DOES_NOT_EXIST, body.email)
41
+ await Database.deleteMany(UserPasswordRenewalTokenModel, { email: body.email }, { initiatorId: ROOT_USER_ID })
42
+ const token = randomUUID()
43
+ const now = Date.now()
44
+ const tokenLifetimeMs = Duration.hours(1).toMilliseconds()
45
+ const expiresOn = new Date(now + tokenLifetimeMs)
46
+ const tokenSaved = await Database.insertOne(
47
+ UserPasswordRenewalTokenModel,
48
+ { value: token, email: body.email, expiresOn },
49
+ { initiatorId: ROOT_USER_ID }
50
+ )
51
+ if (!tokenSaved.success) return tokenSaved
52
+ const emailBody = Email.makeUserPasswordRenewalTokenBody(body.email, token)
53
+ await Email.send(
54
+ EMAILER_EMAIL,
55
+ EMAILER_NAME,
56
+ foundUser.payload.email,
57
+ foundUser.payload.username,
58
+ 'New password request',
59
+ emailBody)
60
+ return Outcome.makeSuccess({})
61
+ }
62
+ )
@@ -0,0 +1,99 @@
1
+ import bcrypt from 'bcrypt'
2
+ import validator from 'validator'
3
+ import zod from 'zod'
4
+ import { Outcome } from '@design-edito/tools/agnostic/misc/outcome'
5
+ import { isNonNullObject } from '@design-edito/tools/agnostic/objects/is-object'
6
+ import { unknownToString } from '@design-edito/tools/agnostic/errors/unknown-to-string'
7
+ import { Logs } from '@design-edito/tools/agnostic/misc/logs'
8
+ import * as Database from '../../../database'
9
+ import { ROOT_USER_ID } from '../../../env'
10
+ import { Codes, makeFailureOutcome } from '../../../errors'
11
+ import { BaseUserModel, LocalUserModel, GoogleUserModel, Role, Status } from '../../../schema/user'
12
+ import { makeEndpoint, nullAuthenticator } from '../../utils'
13
+ import { createAndSendUserEmailValidationToken } from '../_utils'
14
+
15
+ export type ExpectedBody = {
16
+ username: string
17
+ email: string
18
+ password: string
19
+ }
20
+
21
+ export type SuccessResponse = {
22
+ _id: string
23
+ username: string
24
+ email: string
25
+ role: Role
26
+ status: Status
27
+ verified: boolean
28
+ }
29
+
30
+ export const signup = makeEndpoint<ExpectedBody, SuccessResponse>(
31
+ /* Authentication (none) */
32
+ nullAuthenticator,
33
+
34
+ /* Body validation */
35
+ async req => {
36
+ const { body } = req
37
+ if (!isNonNullObject(body)) return makeFailureOutcome(Codes.INVALID_REQUEST_BODY, body, 'Must be an object')
38
+ const validationSchema = zod.object({
39
+ username: zod.string()
40
+ .min(1)
41
+ .refine(input => validator.isSlug(input.toLowerCase()), {
42
+ message: 'Username must contain alphanumeric or non starting, trailing nor consecutive hyphens or underscores'
43
+ }),
44
+ email: zod.string().email('Invalid email format'),
45
+ password: zod.string().min(1, 'Password must be at least one character long')
46
+ })
47
+ try {
48
+ const validated = validationSchema.parse(body)
49
+ return Outcome.makeSuccess(validated)
50
+ } catch (err) {
51
+ const errStr = unknownToString(err)
52
+ return makeFailureOutcome(Codes.INVALID_REQUEST_BODY, body, errStr)
53
+ }
54
+ },
55
+
56
+ /* Process request */
57
+ async body => {
58
+ const { username, email, password } = body
59
+ const rootQueryContext: Database.OperationContext = { initiatorId: ROOT_USER_ID }
60
+ const foundUsername = await Database.findOne(BaseUserModel, { username }, rootQueryContext)
61
+ if (foundUsername.success) return makeFailureOutcome(Codes.USERNAME_ALREADY_TAKEN, username)
62
+ const foundEmail = await Database.findOne(GoogleUserModel, { email }, rootQueryContext)
63
+ if (foundEmail.success) return makeFailureOutcome(Codes.EMAIL_ADDRESS_ALREADY_TAKEN, email)
64
+ const inserted = await Database.insertOne(
65
+ LocalUserModel,
66
+ {
67
+ username,
68
+ role: Role.USER,
69
+ status: Status.ACTIVE,
70
+ email,
71
+ password: await bcrypt.hash(password, 10),
72
+ verified: false
73
+ },
74
+ rootQueryContext
75
+ )
76
+ if (!inserted.success) return inserted
77
+ // Create validation token, store it, send email
78
+ const sent = await createAndSendUserEmailValidationToken(email, username)
79
+ const strippedUserData: SuccessResponse = {
80
+ _id: inserted.payload._id.toString(),
81
+ username: inserted.payload.username,
82
+ email: inserted.payload.email,
83
+ role: inserted.payload.role,
84
+ status: inserted.payload.status,
85
+ verified: inserted.payload.verified
86
+ }
87
+ if (sent.success) return Outcome.makeSuccess(strippedUserData)
88
+ else {
89
+ console.log(Logs.styles.error('Email not sent'))
90
+ console.log(Logs.styles.regular(unknownToString(sent.error)))
91
+ // [WIP] what do here ?
92
+ // send mail to admin ?
93
+ // Retry ?
94
+ // Send fallback mail to user something like "request a new token" ?
95
+ // Do nothing and try create and send token again when trying to login ?
96
+ return Outcome.makeSuccess(strippedUserData)
97
+ }
98
+ }
99
+ )
@@ -0,0 +1,61 @@
1
+ import bcrypt from 'bcrypt'
2
+ import zod from 'zod'
3
+ import { isNonNullObject } from '@design-edito/tools/agnostic/objects/is-object'
4
+ import { Outcome } from '@design-edito/tools/agnostic/misc/outcome'
5
+ import { unknownToString } from '@design-edito/tools/agnostic/errors/unknown-to-string'
6
+ import * as Database from '../../../database'
7
+ import { ROOT_USER_ID } from '../../../env'
8
+ import { makeFailureOutcome, Codes } from '../../../errors'
9
+ import { LocalUserModel } from '../../../schema/user'
10
+ import { UserPasswordRenewalTokenModel } from '../../../schema/user-password-renewal-token'
11
+ import { makeEndpoint, nullAuthenticator } from '../../utils'
12
+
13
+ export type ExpectedBody = {
14
+ email: string
15
+ token: string
16
+ password: string
17
+ }
18
+
19
+ export const submitNewPassword = makeEndpoint<ExpectedBody, {}>(
20
+ nullAuthenticator,
21
+
22
+ async req => {
23
+ const { body } = req
24
+ if (!isNonNullObject(body)) return makeFailureOutcome(Codes.INVALID_REQUEST_BODY, body, 'Must be an object')
25
+ const validationSchema = zod.object({
26
+ email: zod.string().email('Invalid email format'),
27
+ token: zod.string(),
28
+ password: zod.string().min(1, 'Password must be at least one character long')
29
+ })
30
+ try {
31
+ const validated = validationSchema.parse(body)
32
+ return Outcome.makeSuccess(validated)
33
+ } catch (err) {
34
+ const errStr = unknownToString(err)
35
+ return makeFailureOutcome(Codes.INVALID_REQUEST_BODY, body, errStr)
36
+ }
37
+ },
38
+
39
+ async body => {
40
+ const deletedToken = await Database.deleteOne(
41
+ UserPasswordRenewalTokenModel,
42
+ { value: body.token, email: body.email, expiresOn: { $gte: Date.now() } },
43
+ { initiatorId: ROOT_USER_ID }
44
+ )
45
+ if (!deletedToken.success) return makeFailureOutcome(Codes.USER_PASSWORD_RENEWAL_TOKEN_DOES_NOT_EXIST, body.email, body.token)
46
+ const foundUser = await Database.findOne(
47
+ LocalUserModel,
48
+ { email: body.email },
49
+ { initiatorId: ROOT_USER_ID }
50
+ )
51
+ if (!foundUser.success) return makeFailureOutcome(Codes.USER_EMAIL_DOES_NOT_EXIST, body.email)
52
+ const updatedUser = await Database.updateOne(
53
+ LocalUserModel,
54
+ { _id: foundUser.payload._id },
55
+ { $set: { password: await bcrypt.hash(body.password, 10) } },
56
+ { initiatorId: ROOT_USER_ID }
57
+ )
58
+ if (!updatedUser.success) return updatedUser
59
+ return Outcome.makeSuccess({})
60
+ }
61
+ )
@@ -0,0 +1,79 @@
1
+ import zod from 'zod'
2
+ import { Outcome } from '@design-edito/tools/agnostic/misc/outcome'
3
+ import { isNonNullObject } from '@design-edito/tools/agnostic/objects/is-object'
4
+ import { unknownToString } from '@design-edito/tools/agnostic/errors/unknown-to-string'
5
+ import * as Database from '../../../database'
6
+ import { ROOT_USER_ID } from '../../../env'
7
+ import { Codes, makeFailureOutcome } from '../../../errors'
8
+ import { UserEmailValidationTokenModel } from '../../../schema/user-email-validation-token'
9
+ import { LocalUserModel, Role, Status } from '../../../schema/user'
10
+ import { makeEndpoint, nullAuthenticator } from '../../utils'
11
+
12
+ export type ExpectedBody = {
13
+ email: string
14
+ token: string
15
+ }
16
+
17
+ export type SuccessResponse = {
18
+ _id: string
19
+ username: string
20
+ email: string
21
+ role: Role
22
+ status: Status
23
+ verified: boolean
24
+ }
25
+
26
+ export const verifyEmail = makeEndpoint<ExpectedBody, SuccessResponse>(
27
+ /* Auth */
28
+ nullAuthenticator,
29
+
30
+ /* Validation */
31
+ async req => {
32
+ const { body } = req
33
+ if (!isNonNullObject(body)) return makeFailureOutcome(Codes.INVALID_REQUEST_BODY, body, 'Must be an object')
34
+ const validationSchema = zod.object({
35
+ email: zod.string().email('Invalid email format'),
36
+ token: zod.string()
37
+ })
38
+ try {
39
+ const validated = validationSchema.parse(body)
40
+ return Outcome.makeSuccess(validated)
41
+ } catch (err) {
42
+ const errStr = unknownToString(err)
43
+ return makeFailureOutcome(Codes.INVALID_REQUEST_BODY, body, errStr)
44
+ }
45
+ },
46
+
47
+ /* Process */
48
+ async body => {
49
+ const { email, token } = body
50
+ if (token === undefined) return makeFailureOutcome(Codes.USER_EMAIL_VERIFICATION_TOKEN_NOT_PROVIDED)
51
+ const now = new Date()
52
+ const rootQueryContext = { initiatorId: ROOT_USER_ID }
53
+ const tokenDeletion = await Database.deleteOne(
54
+ UserEmailValidationTokenModel,
55
+ { email, value: token, expiresOn: { $gte: now } },
56
+ rootQueryContext
57
+ )
58
+ if (!tokenDeletion.success) return makeFailureOutcome(Codes.USER_EMAIL_VERIFICATION_TOKEN_DOES_NOT_EXIST)
59
+ const userUpdation = await Database.updateOne(
60
+ LocalUserModel,
61
+ { email },
62
+ { $set: { verified: true } },
63
+ rootQueryContext
64
+ )
65
+ if (!userUpdation.success) return makeFailureOutcome(
66
+ Codes.USER_EMAIL_VERIFICATION_PROCESS_FAILED,
67
+ userUpdation.error.message
68
+ )
69
+ const updatedUser = userUpdation.payload
70
+ return Outcome.makeSuccess({
71
+ _id: updatedUser._id.toString(),
72
+ username: updatedUser.username,
73
+ email: updatedUser.email,
74
+ role: updatedUser.role,
75
+ status: updatedUser.status,
76
+ verified: updatedUser.verified
77
+ })
78
+ }
79
+ )