├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .lintstagedrc.js ├── .prettierignore ├── .prettierrc.js ├── LICENSE.md ├── README.md ├── docs ├── banner.jpg ├── logo-black.png └── logo.png ├── global.d.ts ├── jest.config.js ├── lerna.json ├── logo.svg ├── package-lock.json ├── package.json ├── packages ├── accounts │ ├── README.md │ ├── docs │ │ ├── migrate-meteor-verification-tokens.js │ │ └── social.jpg │ ├── global.d.ts │ ├── jest.config.js │ ├── jest.setup.js │ ├── logo.svg │ ├── package-lock.json │ ├── package.json │ ├── scripts │ │ └── sendTestMails.tsx │ ├── src │ │ ├── accounts.test.ts │ │ ├── accounts.ts │ │ ├── email │ │ │ ├── email.test.ts │ │ │ ├── enrollmentEmail.test.tsx │ │ │ ├── enrollmentEmail.tsx │ │ │ ├── resetPasswordEmail.test.tsx │ │ │ ├── resetPasswordEmail.tsx │ │ │ ├── verificationEmail.test.tsx │ │ │ └── verificationEmail.tsx │ │ ├── index.ts │ │ ├── lib │ │ │ ├── compact.ts │ │ │ ├── constants.ts │ │ │ ├── email.test.ts │ │ │ ├── email.ts │ │ │ ├── indexes.ts │ │ │ ├── jwt.ts │ │ │ ├── options.ts │ │ │ ├── password.ts │ │ │ ├── random.ts │ │ │ ├── username.test.ts │ │ │ └── username.ts │ │ ├── methods │ │ │ ├── __tests__ │ │ │ │ └── helpers.ts │ │ │ ├── addEmail.test.ts │ │ │ ├── addEmail.ts │ │ │ ├── createEmailVerificationToken.test.ts │ │ │ ├── createEmailVerificationToken.ts │ │ │ ├── createPasswordResetToken.test.ts │ │ │ ├── createPasswordResetToken.ts │ │ │ ├── createUser.test.ts │ │ │ ├── createUser.ts │ │ │ ├── enrollUser.test.ts │ │ │ ├── enrollUser.ts │ │ │ ├── login.test.ts │ │ │ ├── login.ts │ │ │ ├── refreshToken.test.ts │ │ │ ├── refreshToken.ts │ │ │ ├── removeEmail.test.ts │ │ │ ├── removeEmail.ts │ │ │ ├── resetPassword.test.ts │ │ │ ├── resetPassword.ts │ │ │ ├── revokeToken.test.ts │ │ │ ├── revokeToken.ts │ │ │ ├── sendEnrollmentEmail.test.ts │ │ │ ├── sendEnrollmentEmail.ts │ │ │ ├── sendResetPasswordEmail.test.ts │ │ │ ├── sendResetPasswordEmail.ts │ │ │ ├── sendVerificationEmail.test.ts │ │ │ ├── sendVerificationEmail.ts │ │ │ ├── setUsername.test.ts │ │ │ ├── setUsername.ts │ │ │ ├── verifyEmail.test.ts │ │ │ └── verifyEmail.ts │ │ └── types.ts │ └── tsconfig.json ├── cron │ ├── README.md │ ├── docs │ │ ├── logo.png │ │ └── social.jpg │ ├── global.d.ts │ ├── jest.config.js │ ├── logo.svg │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── cron.test.ts │ │ ├── cron.ts │ │ ├── db.ts │ │ ├── index.ts │ │ └── log.ts │ └── tsconfig.json ├── email │ ├── README.md │ ├── docs │ │ ├── social.jpg │ │ └── standard-template.jpg │ ├── global.d.ts │ ├── jest.config.js │ ├── logo.svg │ ├── package-lock.json │ ├── package.json │ ├── scripts │ │ └── sendTestMail.tsx │ ├── src │ │ ├── index.ts │ │ ├── lib │ │ │ ├── nl2br.tsx │ │ │ ├── omit.ts │ │ │ └── parseTpl.ts │ │ ├── render.test.tsx │ │ ├── render.tsx │ │ ├── send.test.tsx │ │ ├── send.ts │ │ ├── templates │ │ │ ├── Blocks.tsx │ │ │ ├── Email.tsx │ │ │ ├── Layout.tsx │ │ │ ├── index.tsx │ │ │ └── themes │ │ │ │ └── default.ts │ │ └── test │ │ │ ├── TestMail.tsx │ │ │ └── smokeTest.ts │ └── tsconfig.json ├── errors │ ├── README.md │ ├── docs │ │ └── social.jpg │ ├── jest.config.js │ ├── logo.svg │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── errors.test.ts │ │ ├── errors.ts │ │ └── index.ts │ └── tsconfig.json ├── forms │ ├── README.md │ ├── docs │ │ └── social.jpg │ ├── jest.config.js │ ├── jest.warnings.js │ ├── logo.svg │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── getFormData.test.tsx │ │ ├── getFormData.ts │ │ ├── handleChange.ts │ │ ├── handleSubmit.ts │ │ └── index.ts │ └── tsconfig.json ├── hash │ ├── README.md │ ├── docs │ │ └── social.jpg │ ├── jest.config.js │ ├── logo.svg │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── index.test.ts │ │ ├── index.ts │ │ ├── sha256.test.ts │ │ └── sha256.ts │ └── tsconfig.json ├── mongo │ ├── README.md │ ├── docs │ │ └── social.jpg │ ├── global.d.ts │ ├── jest.config.js │ ├── logo.svg │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── collection.ts │ │ ├── connection │ │ │ ├── connection.test.ts │ │ │ ├── connection.ts │ │ │ ├── cursor.ts │ │ │ ├── validatePaginationArgs.test.ts │ │ │ └── validatePaginationArgs.ts │ │ ├── db.test.ts │ │ ├── db.ts │ │ ├── index.ts │ │ └── utils │ │ │ ├── index.ts │ │ │ └── isDuplicateKeyError.ts │ └── tsconfig.json ├── nextjs-auth-api │ ├── README.md │ ├── docs │ │ └── social.jpg │ ├── global.d.ts │ ├── jest.config.js │ ├── jest.setup.js │ ├── logo.svg │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── auth.test.ts │ │ ├── auth.ts │ │ ├── handlers │ │ │ ├── create-account.ts │ │ │ ├── enroll-account.ts │ │ │ ├── login.ts │ │ │ ├── logout.ts │ │ │ ├── refresh-token.ts │ │ │ ├── reset-password.ts │ │ │ └── verify-email.ts │ │ ├── index.ts │ │ ├── session │ │ │ └── cookies.ts │ │ └── utils.ts │ └── tsconfig.json ├── nextjs-auth-ui │ ├── README.md │ ├── docs │ │ └── social.jpg │ ├── global.d.ts │ ├── jest.config.js │ ├── logo.svg │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── auth-pages.tsx │ │ ├── forms │ │ │ ├── enroll-account-form.tsx │ │ │ ├── forgot-password-form.tsx │ │ │ ├── login-form.tsx │ │ │ ├── reset-password-form.tsx │ │ │ └── signup-form.tsx │ │ ├── index.ts │ │ ├── pages │ │ │ ├── enroll-account-page.tsx │ │ │ ├── forgot-password-page.tsx │ │ │ ├── login-page.tsx │ │ │ ├── logout-page.tsx │ │ │ ├── reset-password-page.tsx │ │ │ ├── signup-page.tsx │ │ │ └── verify-email-page.tsx │ │ ├── shared │ │ │ ├── button.tsx │ │ │ ├── check-icon.tsx │ │ │ ├── cross-icon.tsx │ │ │ ├── field.tsx │ │ │ ├── input.tsx │ │ │ ├── link.tsx │ │ │ ├── page.tsx │ │ │ └── spinner.tsx │ │ ├── store.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── style.css │ └── tsconfig.json └── swift │ ├── README.md │ ├── docs │ └── social.jpg │ ├── jest.config.js │ ├── logo.svg │ ├── package-lock.json │ ├── package.json │ ├── src │ └── index.ts │ └── tsconfig.json └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | .cache 2 | dist 3 | cypress 4 | 5 | /packages/*/.coverage 6 | /packages/*/lib 7 | /packages/*/dist -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | extends: [ 4 | 'plugin:@typescript-eslint/recommended', 5 | 'prettier/@typescript-eslint', 6 | 'plugin:prettier/recommended', 7 | ], 8 | parserOptions: { 9 | ecmaVersion: 2020, 10 | sourceType: 'module', 11 | }, 12 | rules: { 13 | '@typescript-eslint/no-explicit-any': 'off', 14 | '@typescript-eslint/ban-ts-comment': 'off', 15 | 'prefer-const': [ 16 | 'error', 17 | { 18 | destructuring: 'all', 19 | }, 20 | ], 21 | curly: ['error', 'all'], 22 | }, 23 | 24 | overrides: [ 25 | { 26 | files: ['**/*.test.{ts,tsx}', '**/scripts/*.{ts,tsx}', '*.config.js'], 27 | rules: { 28 | '@typescript-eslint/no-var-requires': 'off', 29 | }, 30 | }, 31 | ], 32 | }; 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .db 2 | !.db/.gitkeep 3 | *.tsbuildinfo 4 | /packages/*/.coverage 5 | /packages/*/lib 6 | /packages/*/dist 7 | 8 | .coverage 9 | node_modules 10 | -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '**/*.{js,jsx}': (files) => [`eslint --quiet --fix ${files.join(' ')}`], 3 | '**/*.{ts,tsx}': (files) => [ 4 | `tsc --noEmit`, 5 | `eslint --quiet --fix ${files.join(' ')}`, 6 | ], 7 | '**/*.{md,js,json,yml,html,css,pcss}': (files) => [ 8 | `prettier --write ${files.join(' ')}`, 9 | ], 10 | }; 11 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .cache 2 | dist 3 | cypress 4 | 5 | /packages/*/.coverage 6 | /packages/*/lib 7 | /packages/*/dist -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: 'all', 4 | singleQuote: true, 5 | printWidth: 80, 6 | tabWidth: 2, 7 | overrides: [ 8 | { 9 | files: '*.html', 10 | options: { 11 | printWidth: 120, 12 | }, 13 | }, 14 | ], 15 | }; 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![logo](./docs/banner.jpg) 2 | 3 | This repo contains packages from [rake.red](https://rake.red) that have been open sourced. 4 | -------------------------------------------------------------------------------- /docs/banner.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smeijer/rakered/93ec75cb477e741cfce098f7f714562a93c6c286/docs/banner.jpg -------------------------------------------------------------------------------- /docs/logo-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smeijer/rakered/93ec75cb477e741cfce098f7f714562a93c6c286/docs/logo-black.png -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smeijer/rakered/93ec75cb477e741cfce098f7f714562a93c6c286/docs/logo.png -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | import 'jest-partial'; 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | setupFilesAfterEnv: ['jest-partial'], 5 | testMatch: ['./**/src/**/*.test.ts', './**/src/**/*.test.tsx'], 6 | coverageDirectory: './.coverage', 7 | }; 8 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "lerna": "2.2.0", 3 | "packages": ["packages/*"], 4 | "npmClient": "npm", 5 | "version": "1.0.0" 6 | } -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rakered", 3 | "description": "The open source components from rake.red", 4 | "homepage": "https://rake.red", 5 | "private": true, 6 | "scripts": { 7 | "clean": "rimraf \"packages/**/dist\" \"packages/**/*.tsbuildinfo\"", 8 | "start:db": "mongod --dbpath ../.db --replSet rs0", 9 | "test": "lerna run test", 10 | "build": "lerna run build", 11 | "prepare": "npm run clean && npm run build", 12 | "lint": "tsc --noEmit && eslint . --quiet --fix", 13 | "ci:lint": "eslint -c .eslintrc.js", 14 | "ci:tsc": "tsc --noEmit --project ./tsconfig.json" 15 | }, 16 | "devDependencies": { 17 | "@types/jest": "^26.0.23", 18 | "@typescript-eslint/eslint-plugin": "^4.14.1", 19 | "@typescript-eslint/parser": "^4.14.1", 20 | "eslint": "^7.25.0", 21 | "eslint-config-prettier": "^7.2.0", 22 | "eslint-plugin-prettier": "^3.3.1", 23 | "husky": "^4.3.8", 24 | "lerna": "3.22.1", 25 | "lint-staged": "^10.5.3", 26 | "prettier": "2.2.1", 27 | "rimraf": "^3.0.2", 28 | "typescript": "^4.1.3" 29 | }, 30 | "workspaces": [ 31 | "./packages/*" 32 | ], 33 | "husky": { 34 | "hooks": { 35 | "pre-commit": "lint-staged" 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/accounts/docs/migrate-meteor-verification-tokens.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'packages/mongo'; 2 | 3 | const db = await connect('mongodb://localhost:3001'); 4 | const bulk = db.users.initializeOrderedBulkOp(); 5 | 6 | const users = db.users 7 | .find({ 'services.email.verificationTokens.0': { $exists: true } }) 8 | .toArray(); 9 | 10 | for (const user of users) { 11 | const tokens = {}; 12 | 13 | // Meteor can hold multiple tokens for a single email, we only need the last 14 | for (const token of user.services.email.verificationTokens) { 15 | tokens[token.address] = token.token; 16 | } 17 | 18 | // store the verification tokens under 'doc.emails.$.token' 19 | for (const email of Object.keys(tokens)) { 20 | bulk 21 | .find({ _id: user._id, 'emails.address': email }) 22 | .updateOne({ $set: { 'emails.$.token': tokens[email] } }); 23 | } 24 | 25 | // and remove the 'services.email.verificationTokens' property 26 | bulk 27 | .find({ _id: user._id }) 28 | .updateOne({ $unset: { 'services.email.verificationTokens': true } }); 29 | } 30 | 31 | // only execute the bulk when there are operations. 32 | if (bulk.nUpdateOps) { 33 | await bulk.execute(); 34 | } 35 | -------------------------------------------------------------------------------- /packages/accounts/docs/social.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smeijer/rakered/93ec75cb477e741cfce098f7f714562a93c6c286/packages/accounts/docs/social.jpg -------------------------------------------------------------------------------- /packages/accounts/global.d.ts: -------------------------------------------------------------------------------- 1 | import 'jest-partial'; 2 | -------------------------------------------------------------------------------- /packages/accounts/jest.config.js: -------------------------------------------------------------------------------- 1 | const common = require('../../jest.config'); 2 | module.exports = { 3 | ...common, 4 | setupFilesAfterEnv: [...common.setupFilesAfterEnv, './jest.setup.js'], 5 | }; 6 | -------------------------------------------------------------------------------- /packages/accounts/jest.setup.js: -------------------------------------------------------------------------------- 1 | process.env.JWT_SECRET = 'hunter2'; 2 | process.env.EMAIL_FROM = 'noreply@example.com'; 3 | process.env.BASE_URL = 'https://example.com'; 4 | -------------------------------------------------------------------------------- /packages/accounts/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /packages/accounts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rakered/accounts", 3 | "version": "1.6.0", 4 | "description": "An account package for managing user accounts in mongodb, including registration & recovery emails.", 5 | "keywords": [ 6 | "nodejs", 7 | "mongodb" 8 | ], 9 | "main": "./lib/index.js", 10 | "source": "./src/index.ts", 11 | "license": "AGPL-3.0 OR COMMERCIAL", 12 | "author": "Stephan Meijer ", 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/rakered/rakered.git" 16 | }, 17 | "scripts": { 18 | "build": "rimraf ./lib *.tsbuildinfo && tsc", 19 | "prepare": "npm run build", 20 | "test": "jest --coverage --runInBand", 21 | "test:debug": "node --inspect-brk ./node_modules/jest/bin/jest --runInBand", 22 | "bump:patch": "npm version patch -m 'release(accounts): cut the %s release'", 23 | "bump:minor": "npm version minor -m 'release(accounts): cut the %s release'", 24 | "bump:major": "npm version major -m 'release(accounts): cut the %s release'" 25 | }, 26 | "files": [ 27 | "lib" 28 | ], 29 | "dependencies": { 30 | "@rakered/email": "^1.3.1", 31 | "@rakered/errors": "^1.1.0", 32 | "@rakered/mongo": "^1.2.0", 33 | "argon2": "^0.27.1", 34 | "bcryptjs": "^2.4.3", 35 | "common-tags": "^1.8.0", 36 | "jsonwebtoken": "^8.5.1", 37 | "react": "^17.0.1", 38 | "the-big-username-blacklist": "^1.5.2" 39 | }, 40 | "devDependencies": { 41 | "@types/glob": "^7.1.1", 42 | "@types/jest": "^26.0.20", 43 | "@types/mongodb": "^3.6.3", 44 | "@types/node": "^13.13.40", 45 | "@types/react": "^17.0.2", 46 | "jest": "^26.6.3", 47 | "jest-partial": "^1.0.1", 48 | "rimraf": "^3.0.2", 49 | "ts-jest": "^26.4.4", 50 | "typescript": "^4.1.3" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /packages/accounts/scripts/sendTestMails.tsx: -------------------------------------------------------------------------------- 1 | import { send, render } from '@rakered/email'; 2 | import { createEnrollmentEmail } from '../src/email/enrollmentEmail'; 3 | import { createResetPasswordEmail } from '../src/email/resetPasswordEmail'; 4 | import { createVerificationEmail } from '../src/email/verificationEmail'; 5 | import { EmailOptions } from '../src/types'; 6 | 7 | const [mailUrl] = process.argv.splice(2); 8 | 9 | if (!mailUrl) { 10 | console.log(` 11 | This script should get the MAIL_URL as param 12 | 13 | > ts-node ./scripts/sendTestMails smtp://usr:pwd@localhost:25 14 | `); 15 | process.exit(1); 16 | } 17 | 18 | // copy arg to process.env, as that is what `send` uses. 19 | process.env.MAIL_URL = mailUrl; 20 | 21 | const options: EmailOptions = { 22 | from: 'noreply@example.com', // don't set this to rake.red! (spam reasons) 23 | siteName: 'rake.red', 24 | siteUrl: 'https://rake.red', 25 | logoUrl: 'https://github.com/rakered/rakered/raw/main/docs/logo-black.png', 26 | to: 'hunter@example.com', 27 | magicLink: 'http://example.com/magic-link', 28 | }; 29 | 30 | Promise.resolve() 31 | .then(() => send(createEnrollmentEmail(options))) 32 | .then(() => send(createResetPasswordEmail(options))) 33 | .then(() => send(createVerificationEmail(options))) 34 | 35 | .catch((e) => { 36 | console.error(e); 37 | process.exit(1); 38 | }) 39 | .finally(() => { 40 | process.exit(); 41 | }); 42 | -------------------------------------------------------------------------------- /packages/accounts/src/accounts.test.ts: -------------------------------------------------------------------------------- 1 | import { init } from './accounts'; 2 | 3 | const ENV = { ...process.env }; 4 | 5 | beforeEach(() => { 6 | process.env.NODE_ENV = ENV.NODE_ENV; 7 | process.env.MAIL_URL = ENV.MAIL_URL; 8 | process.env.BASE_URL = ENV.BASE_URL; 9 | process.env.LOGO_URL = ENV.LOGO_URL; 10 | process.env.EMAIL_FROM = ENV.EMAIL_FROM; 11 | }); 12 | 13 | test('Throws error when trying to init in production, without email & token options', async () => { 14 | process.env.NODE_ENV = 'production'; 15 | delete process.env.MAIL_URL; 16 | 17 | expect(init).toThrow( 18 | `'mail url' should be provided via either env.MAIL_URL or env.RAKERED_MAIL_URL`, 19 | ); 20 | }); 21 | 22 | test('Throws error when trying to init without email.from', async () => { 23 | delete process.env.EMAIL_FROM; 24 | 25 | expect(() => 26 | init({ 27 | email: { 28 | from: '', 29 | siteUrl: 'https://example.com', 30 | siteName: '', 31 | logoUrl: '', 32 | }, 33 | }), 34 | ).toThrow( 35 | `'email from' should be provided via either env.EMAIL_FROM or env.RAKERED_EMAIL_FROM`, 36 | ); 37 | }); 38 | 39 | test('Throws error when trying to init without email.siteUrl', async () => { 40 | delete process.env.BASE_URL; 41 | 42 | expect(() => 43 | init({ email: { from: 'hi@me', siteUrl: '', siteName: '', logoUrl: '' } }), 44 | ).toThrow( 45 | `'base url' should be provided via either env.BASE_URL or env.RAKERED_BASE_URL`, 46 | ); 47 | }); 48 | 49 | test('Default collection options include pkPrefx', async () => { 50 | const accounts = init(); 51 | 52 | expect(accounts.collection.pkPrefix).toEqual('usr_'); 53 | await accounts.disconnect(); 54 | }); 55 | 56 | test('Does not throw when calling disconnect while unconnected', async () => { 57 | const accounts = init({ 58 | collection: { 59 | createIndex: async () => { 60 | return null; 61 | }, 62 | } as any, 63 | }); 64 | 65 | await expect(accounts.disconnect).not.toThrow(''); 66 | }); 67 | -------------------------------------------------------------------------------- /packages/accounts/src/email/email.test.ts: -------------------------------------------------------------------------------- 1 | import smokeTest from '@rakered/email/lib/test/smokeTest'; 2 | import init, { Accounts } from '../accounts'; 3 | 4 | let accounts: Accounts; 5 | 6 | beforeAll(async () => { 7 | accounts = init({ 8 | collection: 'test_email', 9 | }); 10 | 11 | await accounts.collection.deleteMany({}); 12 | await accounts.createUser({ email: 'hunter@example.com' }); 13 | }); 14 | 15 | beforeEach(() => { 16 | process.env.MAIL_URL = ''; 17 | }); 18 | 19 | afterAll(async () => { 20 | await accounts.disconnect(); 21 | }); 22 | 23 | const user = { email: 'hunter@example.com' }; 24 | 25 | test('can send enrollment email', async () => { 26 | const [result] = await smokeTest(accounts.sendEnrollmentEmail(user)); 27 | expect(result).toContain('To: hunter@example.com'); 28 | }); 29 | 30 | test('can send verification email', async () => { 31 | const [result] = await smokeTest(accounts.sendVerificationEmail(user)); 32 | expect(result).toContain('To: hunter@example.com'); 33 | }); 34 | 35 | test('can send reset password email', async () => { 36 | const [result] = await smokeTest(accounts.sendResetPasswordEmail(user)); 37 | expect(result).toContain('To: hunter@example.com'); 38 | }); 39 | -------------------------------------------------------------------------------- /packages/accounts/src/email/enrollmentEmail.test.tsx: -------------------------------------------------------------------------------- 1 | import { createEnrollmentEmail } from './enrollmentEmail'; 2 | import { EmailOptions } from '../types'; 3 | 4 | test('enrollment mail contains enrollment link', async () => { 5 | process.env.MAIL_URL = ''; 6 | 7 | const options: EmailOptions = { 8 | to: 'hunter@example.com', 9 | from: 'noreply@example.com', 10 | siteUrl: 'https://example.com', 11 | siteName: 'Example', 12 | logoUrl: 'https://example.com/logo.png', 13 | magicLink: 'https://example.com/verify', 14 | }; 15 | 16 | const mail = createEnrollmentEmail(options); 17 | 18 | expect(mail).toMatchPartial({ 19 | to: options.to, 20 | from: options.from, 21 | }); 22 | 23 | expect(mail.html).toContain(options.siteUrl); 24 | expect(mail.html).toContain(options.siteName); 25 | expect(mail.html).toContain(options.logoUrl); 26 | expect(mail.html).toContain(options.magicLink); 27 | }); 28 | -------------------------------------------------------------------------------- /packages/accounts/src/email/enrollmentEmail.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Email, 3 | Content, 4 | Header, 5 | Title, 6 | Paragraph, 7 | CallToAction, 8 | Container, 9 | Footer, 10 | render, 11 | } from '@rakered/email'; 12 | 13 | import { stripIndent } from 'common-tags'; 14 | 15 | import { EmailOptions } from '../types'; 16 | 17 | export const getEnrollmentText = ({ 18 | magicLink, 19 | siteName, 20 | }: EmailOptions) => stripIndent` 21 | Setup Your Account 22 | 23 | Please use the link below to setup your account on ${siteName}. 24 | 25 | If you did not expect to be invited, please ignore this message. 26 | 27 | ${magicLink} 28 | 29 | --- 30 | You have received this notification because you have signed up for ${siteName}. 31 | `; 32 | 33 | export function EnrollmentEmail({ 34 | siteName, 35 | logoUrl, 36 | magicLink, 37 | }: EmailOptions) { 38 | return ( 39 | 40 | 41 |
42 | 43 | 44 | 45 | Setup Your Account 46 | 47 | 48 | Please click on the button below to setup your account. 49 | 50 | 51 | Setup account 52 | 53 | 54 | If you did not expect to be invited, please ignore this message. 55 | 56 | 57 | 58 |
59 | You have received this notification because 60 |
61 | you have signed up for {siteName}. 62 |
63 | 64 | ); 65 | } 66 | 67 | export function createEnrollmentEmail(options: EmailOptions) { 68 | const { to, from } = options; 69 | 70 | const text = getEnrollmentText(options); 71 | const html = render(); 72 | const subject = `${options.siteName} – setup your account`; 73 | 74 | return { to, from, subject, text, html }; 75 | } 76 | -------------------------------------------------------------------------------- /packages/accounts/src/email/resetPasswordEmail.test.tsx: -------------------------------------------------------------------------------- 1 | import { createResetPasswordEmail } from './resetPasswordEmail'; 2 | import { EmailOptions } from '../types'; 3 | 4 | test('reset password mail contains reset link', async () => { 5 | process.env.MAIL_URL = ''; 6 | 7 | const options: EmailOptions = { 8 | to: 'hunter@example.com', 9 | from: 'noreply@example.com', 10 | siteUrl: 'https://example.com', 11 | siteName: 'Example', 12 | logoUrl: 'https://example.com/logo.png', 13 | magicLink: 'https://example.com/verify', 14 | }; 15 | 16 | const mail = createResetPasswordEmail(options); 17 | 18 | expect(mail).toMatchPartial({ 19 | to: options.to, 20 | from: options.from, 21 | }); 22 | 23 | expect(mail.html).toContain(options.siteUrl); 24 | expect(mail.html).toContain(options.siteName); 25 | expect(mail.html).toContain(options.logoUrl); 26 | expect(mail.html).toContain(options.magicLink); 27 | }); 28 | -------------------------------------------------------------------------------- /packages/accounts/src/email/resetPasswordEmail.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Email, 3 | Content, 4 | Header, 5 | Title, 6 | Paragraph, 7 | CallToAction, 8 | Container, 9 | Footer, 10 | render, 11 | } from '@rakered/email'; 12 | 13 | import { stripIndent } from 'common-tags'; 14 | 15 | import { EmailOptions } from '../types'; 16 | 17 | export const getResetPasswordText = ({ 18 | magicLink, 19 | siteName, 20 | }: EmailOptions) => stripIndent` 21 | Reset Your Password 22 | 23 | Please use the link below to reset your password on ${siteName}. 24 | 25 | If you did not request a password change, please ignore this message. 26 | 27 | ${magicLink} 28 | 29 | --- 30 | You have received this notification because you have signed up for ${siteName}. 31 | `; 32 | 33 | export function ResetPasswordEmail({ 34 | siteName, 35 | logoUrl, 36 | magicLink, 37 | }: EmailOptions) { 38 | return ( 39 | 40 | 41 |
42 | 43 | 44 | 45 | Reset Your Password 46 | 47 | 48 | Please click on the button below to reset your password. 49 | 50 | 51 | Reset password 52 | 53 | 54 | If you did not create this request, please ignore this message. 55 | 56 | 57 | 58 |
59 | You have received this notification because 60 |
61 | you have signed up for {siteName}. 62 |
63 | 64 | ); 65 | } 66 | 67 | export function createResetPasswordEmail(options: EmailOptions) { 68 | const { to, from } = options; 69 | 70 | const text = getResetPasswordText(options); 71 | const html = render(); 72 | const subject = `${options.siteName} – reset your password`; 73 | 74 | return { to, from, subject, text, html }; 75 | } 76 | -------------------------------------------------------------------------------- /packages/accounts/src/email/verificationEmail.test.tsx: -------------------------------------------------------------------------------- 1 | import { createVerificationEmail } from './verificationEmail'; 2 | import { EmailOptions } from '../types'; 3 | 4 | test('verification mail contains verify link', async () => { 5 | process.env.MAIL_URL = ''; 6 | 7 | const options: EmailOptions = { 8 | to: 'hunter@example.com', 9 | from: 'noreply@example.com', 10 | siteUrl: 'https://example.com', 11 | siteName: 'Example', 12 | logoUrl: 'https://example.com/logo.png', 13 | magicLink: 'https://example.com/verify', 14 | }; 15 | 16 | const mail = createVerificationEmail(options); 17 | 18 | expect(mail).toMatchPartial({ 19 | to: options.to, 20 | from: options.from, 21 | }); 22 | 23 | expect(mail.html).toContain(options.siteUrl); 24 | expect(mail.html).toContain(options.siteName); 25 | expect(mail.html).toContain(options.logoUrl); 26 | expect(mail.html).toContain(options.magicLink); 27 | }); 28 | -------------------------------------------------------------------------------- /packages/accounts/src/email/verificationEmail.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Email, 3 | Content, 4 | Header, 5 | Title, 6 | Paragraph, 7 | CallToAction, 8 | Container, 9 | Footer, 10 | render, 11 | } from '@rakered/email'; 12 | 13 | import { stripIndent } from 'common-tags'; 14 | 15 | import { EmailOptions } from '../types'; 16 | 17 | export const getVerificationText = ({ 18 | magicLink, 19 | siteName, 20 | }: EmailOptions) => stripIndent` 21 | Confirm Your Email 22 | 23 | Please open the link below to confirm that you are the owner of this account. 24 | 25 | If you did not create an account, please ignore this message. 26 | 27 | ${magicLink} 28 | 29 | --- 30 | You have received this notification because you have signed up for ${siteName}. 31 | `; 32 | 33 | export function VerificationEmail({ 34 | siteName, 35 | siteUrl, 36 | logoUrl, 37 | magicLink, 38 | }: EmailOptions) { 39 | return ( 40 | 41 | 42 |
43 | 44 | 45 | 46 | Confirm Your Email 47 | 48 | 49 | Please click on the button below to confirm that you are the owner of 50 | this account. 51 | 52 | 53 | Confirm email 54 | 55 | 56 | If you did not create an account, please ignore this message. 57 | 58 | 59 | 60 |
61 | You have received this notification because 62 |
63 | you have signed up for {siteName}. 64 |
65 | 66 | ); 67 | } 68 | 69 | export function createVerificationEmail(options: EmailOptions) { 70 | const { to, from } = options; 71 | 72 | const text = getVerificationText(options); 73 | const html = render(); 74 | const subject = `${options.siteName} – confirm your email`; 75 | 76 | return { to, from, subject, text, html }; 77 | } 78 | -------------------------------------------------------------------------------- /packages/accounts/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './accounts'; 2 | export { default } from './accounts'; 3 | export type { AuthTokenResult } from './types'; 4 | export { getOption } from './lib/options'; 5 | export type { ENV_KEYS } from './lib/options'; 6 | -------------------------------------------------------------------------------- /packages/accounts/src/lib/compact.ts: -------------------------------------------------------------------------------- 1 | export function compact>(source: T): Partial { 2 | const result = {}; 3 | const keys = Object.keys(source); 4 | 5 | for (const key of keys) { 6 | if (typeof source[key] === 'undefined' || source[key] === null) { 7 | continue; 8 | } 9 | 10 | result[key] = source[key]; 11 | } 12 | 13 | return result; 14 | } 15 | -------------------------------------------------------------------------------- /packages/accounts/src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | export const API_TOKEN_LENGTH = 40; 2 | 3 | /** 4 | * The default password reset token expiration period is 2 days (172_800 seconds). 5 | */ 6 | export const RESET_TOKEN_EXPIRY_SECONDS = 172_800; // 2 days 7 | 8 | /** 9 | * The default refresh token expiration period is 30 days (2_592_000 seconds). 10 | * This can be configured up to 1 year (31_557_600 seconds). The lifetime does 11 | * not extend when tokens are rotated. 12 | */ 13 | export const REFRESH_TOKEN_EXPIRY_SECONDS = 2_592_000; // 30 days 14 | export const REFRESH_TOKEN_MAX_EXPIRY_SECONDS = 31_557_600; // 1 year 15 | 16 | /** 17 | * The default access token expiration period is 24 hours (86_400 seconds). This 18 | * can be configured up to 30 days (31_557_600 seconds). 19 | */ 20 | export const ACCESS_TOKEN_EXPIRY_SECONDS = 86_400; // 1 day 21 | export const ACCESS_TOKEN_MAX_EXPIRY_SECONDS = 2_592_000; // 30 days 22 | 23 | /** 24 | * The maximum number of active sessions a user can have. This is used to keep 25 | * the collection healthy, but also to keep the tokens safe. Realistically spoken 26 | * users have a notebook, phone, and maybe tablet. It's fine if logging in on 27 | * another device means that the oldest token is being revoked. 28 | */ 29 | export const MAX_ACTIVE_REFRESH_TOKENS = 5; 30 | -------------------------------------------------------------------------------- /packages/accounts/src/lib/email.test.ts: -------------------------------------------------------------------------------- 1 | import { normalizeEmail, isValidEmail } from './email'; 2 | 3 | test('email is valid when all parts are present', () => { 4 | expect(isValidEmail('a@b.c')).toEqual(true); 5 | }); 6 | 7 | test('empty string is not a valid email', () => { 8 | expect(isValidEmail('')).toEqual(false); 9 | }); 10 | 11 | test('isValidEmail cannot handles missing input', () => { 12 | // @ts-ignore 13 | expect(isValidEmail()).toEqual(false); 14 | }); 15 | 16 | test('email is normalized to trimmed lower case', () => { 17 | expect(normalizeEmail(' A@b.C ')).toEqual('a@b.c'); 18 | }); 19 | 20 | test('normalize handles empty strings', () => { 21 | expect(normalizeEmail('')).toEqual(''); 22 | }); 23 | 24 | test('normalize handles missing input', () => { 25 | // @ts-ignore 26 | expect(normalizeEmail()).toEqual(''); 27 | }); 28 | -------------------------------------------------------------------------------- /packages/accounts/src/lib/email.ts: -------------------------------------------------------------------------------- 1 | // {chars} @ {chars} . {chars} 2 | export const EMAIL_REGEXP = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; 3 | 4 | export function normalizeEmail(str) { 5 | return (str || '').toLowerCase().trim(); 6 | } 7 | 8 | export function isValidEmail(email) { 9 | return EMAIL_REGEXP.test(email || ''); 10 | } 11 | -------------------------------------------------------------------------------- /packages/accounts/src/lib/indexes.ts: -------------------------------------------------------------------------------- 1 | export const INDEXES = [ 2 | [{ handle: 1 }, { unique: true, sparse: true, name: 'unique-handle' }], 3 | // please note, the `email.address` index works cross document, not within docs 4 | [ 5 | { 'emails.address': 1 }, 6 | { unique: true, sparse: true, name: 'unique-email-address' }, 7 | ], 8 | [ 9 | { 'services.api.tokens.hashedToken': 1 }, 10 | { unique: true, sparse: true, name: 'unique-api-token' }, 11 | ], 12 | ]; 13 | -------------------------------------------------------------------------------- /packages/accounts/src/lib/jwt.ts: -------------------------------------------------------------------------------- 1 | import picoid from 'picoid'; 2 | import jwt from 'jsonwebtoken'; 3 | import { AuthTokenResult, User, UserDocument } from '../types'; 4 | import { compact } from './compact'; 5 | import { 6 | ACCESS_TOKEN_EXPIRY_SECONDS, 7 | ACCESS_TOKEN_MAX_EXPIRY_SECONDS, 8 | REFRESH_TOKEN_EXPIRY_SECONDS, 9 | REFRESH_TOKEN_MAX_EXPIRY_SECONDS, 10 | } from './constants'; 11 | import { getOption } from './options'; 12 | 13 | interface TokenOptions { 14 | refreshToken: { expiresIn: number }; 15 | accessToken: { expiresIn: number }; 16 | } 17 | 18 | function min(...nums: (number | undefined)[]) { 19 | return Math.min( 20 | ...nums.filter((x): x is number => typeof x !== 'undefined'), 21 | ); 22 | } 23 | 24 | export function cleanUser(document: UserDocument | User): User { 25 | return compact({ 26 | _id: document._id, 27 | username: document.username, 28 | email: 'email' in document ? document.email : document.emails?.[0]?.address, 29 | name: document.name, 30 | roles: (document.roles || []).filter(Boolean), 31 | }) as User; 32 | } 33 | 34 | export function createTokens( 35 | document: UserDocument | User, 36 | options?: Partial, 37 | ): AuthTokenResult { 38 | const user = cleanUser(document); 39 | const secret = getOption('JWT_SECRET'); 40 | 41 | const data = { 42 | ...user, 43 | sub: user._id, 44 | prm: picoid(), // prime is used to tie request and refresh token together 45 | }; 46 | 47 | const refreshToken = jwt.sign(data, secret, { 48 | expiresIn: min( 49 | options?.refreshToken?.expiresIn || REFRESH_TOKEN_EXPIRY_SECONDS, 50 | REFRESH_TOKEN_EXPIRY_SECONDS, 51 | ), 52 | }); 53 | 54 | const accessToken = jwt.sign(data, secret, { 55 | expiresIn: min( 56 | options?.accessToken?.expiresIn || ACCESS_TOKEN_EXPIRY_SECONDS, 57 | ACCESS_TOKEN_EXPIRY_SECONDS, 58 | ), 59 | }); 60 | 61 | return { 62 | user, 63 | refreshToken, 64 | accessToken, 65 | }; 66 | } 67 | 68 | export interface TokenPayload { 69 | _id: string; 70 | username?: string; 71 | email?: string; 72 | roles: string[]; 73 | prm: string; 74 | iat: number; 75 | exp: number; 76 | } 77 | 78 | export function verifyToken( 79 | token: string, 80 | options?: { ignoreExpiration?: boolean }, 81 | ): TokenPayload | null { 82 | try { 83 | return jwt.verify(token, getOption('JWT_SECRET'), options); 84 | } catch (ex) { 85 | return null; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /packages/accounts/src/lib/options.ts: -------------------------------------------------------------------------------- 1 | export type ENV_KEYS = 2 | | 'MAIL_URL' 3 | | 'EMAIL_FROM' 4 | | 'SITE_NAME' 5 | | 'BASE_URL' 6 | | 'LOGO_URL' 7 | | 'JWT_SECRET'; 8 | 9 | export function getOption(key: ENV_KEYS): string | undefined; 10 | export function getOption(key: ENV_KEYS, fallback: string): string; 11 | 12 | export function getOption( 13 | key: ENV_KEYS, 14 | fallback?: string, 15 | ): string | undefined { 16 | return process.env[`RAKERED_${key}`] ?? process.env[key] ?? fallback; 17 | } 18 | 19 | export function checkOption(key: ENV_KEYS): void { 20 | const option = getOption(key); 21 | const isEmpty = option == null || option === ''; 22 | 23 | if (isEmpty) { 24 | throw new Error( 25 | `'${key 26 | .replace(/_/g, ' ') 27 | .toLowerCase()}' should be provided via either env.${key} or env.RAKERED_${key}`, 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/accounts/src/lib/password.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | 3 | export function SHA256(string): string { 4 | return crypto.createHash('sha256').update(string).digest('hex'); 5 | } 6 | 7 | export function getPasswordString( 8 | password: string | { algorithm: 'sha-256'; digest: string } | undefined, 9 | ): string { 10 | if (!password) { 11 | return ''; 12 | } 13 | 14 | if (typeof password === 'string') { 15 | return SHA256(password); 16 | } 17 | 18 | if (password.algorithm === 'sha-256') { 19 | return password.digest; 20 | } 21 | 22 | /* istanbul ignore next */ 23 | throw new Error('Unsupported password hash algorithm.'); 24 | } 25 | -------------------------------------------------------------------------------- /packages/accounts/src/lib/random.ts: -------------------------------------------------------------------------------- 1 | export function random(min, max) { 2 | return Math.floor(Math.random() * (max - min + 1)) + min; 3 | } 4 | -------------------------------------------------------------------------------- /packages/accounts/src/lib/username.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | normalizeUsername, 3 | isReservedUsername, 4 | isValidUsername, 5 | } from './username'; 6 | 7 | test('accepts non-reserved username', () => { 8 | expect(isReservedUsername('foobar')).toEqual(false); 9 | }); 10 | 11 | test('admin is reserved username', () => { 12 | expect(isReservedUsername('admin')).toEqual(true); 13 | }); 14 | 15 | test('marketplace is reserved', () => { 16 | expect(isReservedUsername('marketplace')).toEqual(true); 17 | }); 18 | 19 | test('.well-known is a reserved username', () => { 20 | expect(isReservedUsername('.well-known')).toEqual(true); 21 | }); 22 | 23 | test('reserved username handles missing input', () => { 24 | // @ts-ignore 25 | expect(isReservedUsername()).toEqual(false); 26 | }); 27 | 28 | test('username can have letters an hyphens', () => { 29 | expect(isValidUsername('John-Doe')).toEqual(true); 30 | }); 31 | 32 | test('accepts non-reserved username', () => { 33 | expect(isValidUsername('foobar')).toEqual(true); 34 | }); 35 | 36 | test('empty username is not allowed', () => { 37 | expect(isValidUsername('')).toEqual(false); 38 | }); 39 | 40 | test('username is being checked for minimum length', () => { 41 | expect(isValidUsername('aa')).toEqual(false); 42 | }); 43 | 44 | test('username is being checked for maximum length', () => { 45 | expect(isValidUsername('a'.repeat(100))).toEqual(false); 46 | }); 47 | 48 | test('username is being checked for illegal characters', () => { 49 | expect(isValidUsername('foo@bar.com')).toEqual(false); 50 | }); 51 | 52 | test('username cannot start with a hyphen', () => { 53 | expect(isValidUsername('-john')).toEqual(false); 54 | }); 55 | 56 | test('username cannot end with a hyphen', () => { 57 | expect(isValidUsername('doe-')).toEqual(false); 58 | }); 59 | 60 | test('username cannot have consecutive hyphens', () => { 61 | expect(isValidUsername('john--doe')).toEqual(false); 62 | }); 63 | 64 | test('valid username handles missing input', () => { 65 | // @ts-ignore 66 | expect(isValidUsername()).toEqual(false); 67 | }); 68 | -------------------------------------------------------------------------------- /packages/accounts/src/lib/username.ts: -------------------------------------------------------------------------------- 1 | import blacklist from 'the-big-username-blacklist'; 2 | 3 | // - username may only contain alphanumeric characters and hyphens. 4 | // - username cannot have multiple consecutive hyphens. 5 | // - username cannot begin or end with a hyphen. 6 | // - minimum length is 3 characters 7 | // - maximum length is 20 characters. 8 | export const USERNAME_REGEXP = /^(?=.{3,20}$)([a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9]))*)$/; 9 | 10 | export const reserved = new Set( 11 | ['anonymous', 'own', 'viewer', 'webhook', 'webhooks', 'yourself'].concat( 12 | blacklist.list, 13 | ), 14 | ); 15 | 16 | export function isReservedUsername(username) { 17 | return reserved.has((username || '').toLowerCase().trim()); 18 | } 19 | 20 | export function isValidUsername(username) { 21 | return USERNAME_REGEXP.test(username || ''); 22 | } 23 | 24 | export function normalizeUsername(str) { 25 | return (str || '').replace(/[^a-zA-Z0-9]/g, '').toLowerCase(); 26 | } 27 | -------------------------------------------------------------------------------- /packages/accounts/src/methods/__tests__/helpers.ts: -------------------------------------------------------------------------------- 1 | import init, { Accounts, defaultOptions } from '../../accounts'; 2 | import { CreateUserDocument } from '../createUser'; 3 | 4 | export async function initForTest(collection: string): Promise { 5 | return init({ collection: `test_${collection}` }); 6 | } 7 | 8 | export function getTestContext(accounts: Accounts) { 9 | return { 10 | collection: accounts.collection, 11 | email: defaultOptions.email, 12 | urls: defaultOptions.urls, 13 | }; 14 | } 15 | 16 | export const TEST_USER: CreateUserDocument = { 17 | username: 'AzureDiamond', 18 | email: 'hunter@example.com', 19 | password: 'hunter2', 20 | }; 21 | -------------------------------------------------------------------------------- /packages/accounts/src/methods/addEmail.test.ts: -------------------------------------------------------------------------------- 1 | import { Accounts } from '../accounts'; 2 | import { Context, AuthTokenResult } from '../types'; 3 | import { getTestContext, initForTest, TEST_USER } from './__tests__/helpers'; 4 | 5 | let accounts: Accounts; 6 | let identity: AuthTokenResult; 7 | let context: Context; 8 | 9 | beforeAll(async () => { 10 | accounts = await initForTest('addEmail'); 11 | context = getTestContext(accounts); 12 | }); 13 | 14 | beforeEach(async () => { 15 | await accounts.collection.deleteMany({}); 16 | identity = await accounts.createUser(TEST_USER); 17 | }); 18 | 19 | afterAll(async () => { 20 | await accounts.disconnect(); 21 | }); 22 | 23 | test('can add email to existing account', async () => { 24 | await accounts.addEmail({ 25 | userId: identity.user._id, 26 | email: 'hunter-new@example.com', 27 | }); 28 | 29 | const user = await accounts.collection.findOne({ _id: identity.user._id }); 30 | 31 | expect(user!.emails).toMatchPartial([ 32 | { address: 'hunter@example.com' }, 33 | { address: 'hunter-new@example.com', verified: false }, 34 | ]); 35 | }); 36 | 37 | test('can add email to existing account in verified state', async () => { 38 | await accounts.addEmail({ 39 | userId: identity.user._id, 40 | email: 'hunter-new@example.com', 41 | verified: true, 42 | }); 43 | 44 | const user = await accounts.collection.findOne({ _id: identity.user._id }); 45 | 46 | expect(user!.emails).toMatchPartial([ 47 | { address: 'hunter@example.com' }, 48 | { address: 'hunter-new@example.com', verified: true }, 49 | ]); 50 | }); 51 | 52 | test('throws error when invalid email was provided', async () => { 53 | await expect( 54 | accounts.addEmail({ 55 | userId: identity.user._id, 56 | email: 'hunter-new', 57 | verified: true, 58 | }), 59 | ).rejects.toThrow('Email is invalid or already taken.'); 60 | }); 61 | 62 | test('throws when email is already registered', async () => { 63 | await expect( 64 | accounts.addEmail({ 65 | userId: identity.user._id, 66 | email: 'hunter@example.com', 67 | verified: true, 68 | }), 69 | ).rejects.toThrow('Incorrect userId provided or email already taken.'); 70 | }); 71 | 72 | test('throws when email does not belong to given user', async () => { 73 | await expect( 74 | accounts.addEmail({ 75 | userId: 'abc', 76 | email: 'hunter-new@example.com', 77 | verified: true, 78 | }), 79 | ).rejects.toThrow('Incorrect userId provided or email already taken.'); 80 | }); 81 | 82 | test('throws when adding email that belongs to different user', async () => { 83 | const { user } = await accounts.createUser({ 84 | username: 'other-user', 85 | password: { digest: 'hi', algorithm: 'sha-256' }, 86 | }); 87 | 88 | await expect( 89 | accounts.addEmail({ 90 | userId: user._id, 91 | email: 'hunter@example.com', 92 | verified: true, 93 | }), 94 | ).rejects.toThrow('Email is invalid or already taken.'); 95 | }); 96 | -------------------------------------------------------------------------------- /packages/accounts/src/methods/addEmail.ts: -------------------------------------------------------------------------------- 1 | import { isValidEmail, normalizeEmail } from '../lib/email'; 2 | import { Context } from '../types'; 3 | import { isDuplicateKeyError } from '@rakered/mongo/lib/utils'; 4 | import { UserInputError } from '@rakered/errors'; 5 | 6 | export interface addEmailDocument { 7 | userId: string; 8 | email: string; 9 | verified?: boolean; 10 | } 11 | 12 | async function addEmail( 13 | options: addEmailDocument, 14 | context: Context, 15 | ): Promise { 16 | const { collection } = context; 17 | 18 | const email = normalizeEmail(options.email); 19 | 20 | if (!isValidEmail(email)) { 21 | throw new UserInputError('Email is invalid or already taken.'); 22 | } 23 | 24 | const now = new Date(); 25 | 26 | const doc: any = { 27 | address: email, 28 | verified: Boolean(options.verified), 29 | }; 30 | 31 | try { 32 | const { modifiedCount } = await collection.updateOne( 33 | { _id: options.userId, 'emails.address': { $ne: email } }, 34 | { $push: { emails: doc }, $set: { updatedAt: now } }, 35 | ); 36 | 37 | if (modifiedCount !== 1) { 38 | throw new UserInputError( 39 | 'Incorrect userId provided or email already taken.', 40 | ); 41 | } 42 | } catch (e) { 43 | if (isDuplicateKeyError(e, 'emails.address')) { 44 | throw new UserInputError(`Email is invalid or already taken.`); 45 | } 46 | 47 | /* istanbul ignore next */ 48 | throw e; 49 | } 50 | } 51 | 52 | export default addEmail; 53 | -------------------------------------------------------------------------------- /packages/accounts/src/methods/createEmailVerificationToken.test.ts: -------------------------------------------------------------------------------- 1 | import { Accounts } from '../accounts'; 2 | import { Context, AuthTokenResult } from '../types'; 3 | import createEmailVerificationToken from './createEmailVerificationToken'; 4 | import { getTestContext, initForTest, TEST_USER } from './__tests__/helpers'; 5 | 6 | let accounts: Accounts; 7 | let identity: AuthTokenResult; 8 | let context: Context; 9 | 10 | beforeAll(async () => { 11 | accounts = await initForTest('createEmailVerificationToken'); 12 | context = getTestContext(accounts); 13 | }); 14 | 15 | beforeEach(async () => { 16 | await accounts.collection.deleteMany({}); 17 | identity = await accounts.createUser(TEST_USER); 18 | }); 19 | 20 | afterAll(async () => { 21 | await accounts.disconnect(); 22 | }); 23 | 24 | test('does basic input validation', async () => { 25 | // call without email 26 | // @ts-ignore 27 | await expect(createEmailVerificationToken({}, context)).rejects.toThrow( 28 | 'Email is invalid.', 29 | ); 30 | 31 | // call with invalid email 32 | await expect( 33 | createEmailVerificationToken({ email: 'hunter@domain' }, context), 34 | ).rejects.toThrow('Email is invalid.'); 35 | }); 36 | 37 | test('can request verification token', async () => { 38 | const { token } = await createEmailVerificationToken( 39 | { email: 'hunter@example.com' }, 40 | context, 41 | ); 42 | 43 | const user = await accounts.collection.findOne({ 44 | _id: identity.user._id, 45 | }); 46 | 47 | const hashedToken = user!.emails?.[0].token; 48 | expect(typeof token).toEqual('string'); 49 | expect(typeof hashedToken).toEqual('string'); 50 | expect(token).not.toEqual(hashedToken); 51 | }); 52 | 53 | test('can not request verification token for unregistered email', async () => { 54 | await expect( 55 | createEmailVerificationToken({ email: 'unknown@example.com' }, context), 56 | ).rejects.toThrow('Email is unknown.'); 57 | }); 58 | -------------------------------------------------------------------------------- /packages/accounts/src/methods/createEmailVerificationToken.ts: -------------------------------------------------------------------------------- 1 | import picoid from 'picoid'; 2 | import { Context, TokenResult } from '../types'; 3 | import { SHA256 } from '../lib/password'; 4 | import { isValidEmail, normalizeEmail } from '../lib/email'; 5 | import { random } from '../lib/random'; 6 | import { UserInputError } from '@rakered/errors'; 7 | 8 | export interface EmailVerificationTokenDocument { 9 | email: string; 10 | } 11 | 12 | async function createEmailVerificationToken( 13 | { email }: EmailVerificationTokenDocument, 14 | { collection }: Context, 15 | ): Promise { 16 | if (typeof email !== 'string' || !isValidEmail(email)) { 17 | throw new UserInputError('Email is invalid.'); 18 | } 19 | 20 | email = normalizeEmail(email); 21 | const digits = random(100_000, 999_999).toString(); 22 | const token = picoid(); 23 | const hashedToken = SHA256(token); 24 | const hashedDigits = SHA256(digits); 25 | 26 | const { modifiedCount } = await collection.updateOne( 27 | { 'emails.address': email }, 28 | { 29 | $set: { 'emails.$.token': hashedToken, 'emails.$.digits': hashedDigits }, 30 | }, 31 | ); 32 | 33 | if (modifiedCount !== 1) { 34 | throw new UserInputError('Email is unknown.'); 35 | } 36 | 37 | return { 38 | token, 39 | expires: 0, 40 | }; 41 | } 42 | 43 | export default createEmailVerificationToken; 44 | -------------------------------------------------------------------------------- /packages/accounts/src/methods/createPasswordResetToken.test.ts: -------------------------------------------------------------------------------- 1 | import { Accounts } from '../accounts'; 2 | import { Context, AuthTokenResult } from '../types'; 3 | import createPasswordResetToken from './createPasswordResetToken'; 4 | import { getTestContext, initForTest, TEST_USER } from './__tests__/helpers'; 5 | 6 | let accounts: Accounts; 7 | let identity: AuthTokenResult; 8 | let context: Context; 9 | 10 | beforeAll(async () => { 11 | accounts = await initForTest('createPasswordResetToken'); 12 | context = getTestContext(accounts); 13 | }); 14 | 15 | beforeEach(async () => { 16 | await accounts.collection.deleteMany({}); 17 | identity = await accounts.createUser(TEST_USER); 18 | }); 19 | 20 | afterAll(async () => { 21 | await accounts.disconnect(); 22 | }); 23 | 24 | test('does basic input validation', async () => { 25 | // call without email 26 | // @ts-ignore 27 | await expect(createPasswordResetToken({}, context)).rejects.toThrow( 28 | 'Email is invalid.', 29 | ); 30 | 31 | // call with invalid email 32 | await expect( 33 | createPasswordResetToken({ email: 'hunter@domain' }, context), 34 | ).rejects.toThrow('Email is invalid.'); 35 | }); 36 | 37 | test('can request reset token', async () => { 38 | const { token } = await createPasswordResetToken( 39 | { email: 'hunter@example.com' }, 40 | context, 41 | ); 42 | 43 | const user = await accounts.collection.findOne({ 44 | _id: identity.user._id, 45 | }); 46 | 47 | const hashedToken = user?.services.password?.reset?.token; 48 | expect(typeof hashedToken).toEqual('string'); 49 | expect(token).not.toEqual(hashedToken); 50 | }); 51 | 52 | test('can not request reset token for unregistered email', async () => { 53 | await expect( 54 | createPasswordResetToken({ email: 'unknown@example.com' }, context), 55 | ).rejects.toThrow('Email is unknown.'); 56 | }); 57 | -------------------------------------------------------------------------------- /packages/accounts/src/methods/createPasswordResetToken.ts: -------------------------------------------------------------------------------- 1 | import picoid from 'picoid'; 2 | import { Context, TokenResult } from '../types'; 3 | import { SHA256 } from '../lib/password'; 4 | import { RESET_TOKEN_EXPIRY_SECONDS } from '../lib/constants'; 5 | import { isValidEmail, normalizeEmail } from '../lib/email'; 6 | import { UserInputError } from '@rakered/errors'; 7 | 8 | export interface PasswordResetDocument { 9 | email: string; 10 | type?: 'reset' | 'enroll'; 11 | } 12 | 13 | async function createPasswordResetToken( 14 | { email, type }: PasswordResetDocument, 15 | { collection }: Context, 16 | ): Promise { 17 | if (typeof email !== 'string' || !isValidEmail(email)) { 18 | throw new UserInputError('Email is invalid.'); 19 | } 20 | 21 | const token = picoid(); 22 | 23 | const reset = { 24 | token: SHA256(token), 25 | email: normalizeEmail(email), 26 | reason: type || 'reset', 27 | when: new Date(), 28 | }; 29 | 30 | const { modifiedCount } = await collection.updateOne( 31 | { 'emails.address': normalizeEmail(email) }, 32 | { $set: { 'services.password.reset': reset } }, 33 | ); 34 | 35 | if (modifiedCount !== 1) { 36 | throw new UserInputError('Email is unknown.'); 37 | } 38 | 39 | const expires = Math.floor( 40 | reset.when.getTime() / 1000 + RESET_TOKEN_EXPIRY_SECONDS, 41 | ); 42 | 43 | return { 44 | token, 45 | expires, 46 | }; 47 | } 48 | 49 | export default createPasswordResetToken; 50 | -------------------------------------------------------------------------------- /packages/accounts/src/methods/enrollUser.test.ts: -------------------------------------------------------------------------------- 1 | import { Accounts } from '../accounts'; 2 | import { initForTest } from './__tests__/helpers'; 3 | import smokeTest from '@rakered/email/lib/test/smokeTest'; 4 | 5 | let accounts: Accounts; 6 | let token; 7 | 8 | beforeAll(async () => { 9 | accounts = await initForTest('enrollUser'); 10 | }); 11 | 12 | beforeEach(async () => { 13 | await accounts.collection.deleteMany({}); 14 | 15 | const { user } = await accounts.createUser({ 16 | email: 'hunter@example.com', 17 | }); 18 | 19 | const [email] = await smokeTest(accounts.sendEnrollmentEmail(user)); 20 | token = email.match(/\/enroll-account\/(.*)\s/)[1]; 21 | }); 22 | 23 | afterAll(async () => { 24 | await accounts.disconnect(); 25 | }); 26 | 27 | test('does basic input validation', async () => { 28 | // call without token 29 | // @ts-ignore 30 | await expect(accounts.enrollUser({ password: 'hunter2' })).rejects.toThrow( 31 | 'Token must be provided.', 32 | ); 33 | 34 | // call with invalid token 35 | await expect( 36 | accounts.enrollUser({ token: 'hunter', password: 'hunter2' }), 37 | ).rejects.toThrow('Invalid token provided.'); 38 | 39 | // call without password 40 | // @ts-ignore 41 | await expect(accounts.enrollUser({ token })).rejects.toThrow( 42 | 'Password must be provided.', 43 | ); 44 | }); 45 | 46 | test('can enroll user', async () => { 47 | const result = await accounts.enrollUser({ 48 | token, 49 | password: 'hunter2', 50 | }); 51 | 52 | expect(result.accessToken).toBeTruthy(); 53 | expect(result.refreshToken).toBeTruthy(); 54 | 55 | expect(result).toMatchPartial({ 56 | user: { email: 'hunter@example.com' }, 57 | }); 58 | }); 59 | 60 | test('cannot enroll user with same or similar looking username', async () => { 61 | const enroll = (username) => 62 | accounts.enrollUser({ username, password: 'hunter2', token }); 63 | 64 | await expect( 65 | accounts.createUser({ username: 'azure', password: 'hunter2' }), 66 | ).resolves.toMatchPartial({ 67 | user: { username: 'azure' }, 68 | }); 69 | 70 | await expect(enroll('azure')).rejects.toThrow( 71 | `Username azure is unavailable.`, 72 | ); 73 | 74 | await expect(enroll('Azure')).rejects.toThrow( 75 | `Username Azure is unavailable.`, 76 | ); 77 | 78 | await expect(enroll('azu-re')).rejects.toThrow( 79 | `Username azu-re is unavailable.`, 80 | ); 81 | }); 82 | 83 | test('cannot enroll user with reserved username', async () => { 84 | const enroll = async (username) => 85 | accounts.enrollUser({ username, password: 'hunter2', token }); 86 | 87 | await expect(enroll('anonymous')).rejects.toThrow( 88 | `Username anonymous is unavailable.`, 89 | ); 90 | await expect(enroll('owner')).rejects.toThrow( 91 | `Username owner is unavailable.`, 92 | ); 93 | }); 94 | 95 | test('cannot enroll user with invalid pattern', async () => { 96 | const enroll = async (username) => 97 | accounts.enrollUser({ username, password: 'hunter2', token }); 98 | 99 | await expect(enroll('azu-re')).resolves.toMatchPartial({ 100 | user: { username: 'azu-re' }, 101 | }); 102 | 103 | await expect(enroll('azu.re')).rejects.toThrow(`Username azu.re is invalid.`); 104 | 105 | await expect(enroll('azu--re')).rejects.toThrow( 106 | `Username azu--re is invalid.`, 107 | ); 108 | }); 109 | -------------------------------------------------------------------------------- /packages/accounts/src/methods/enrollUser.ts: -------------------------------------------------------------------------------- 1 | import argon from 'argon2'; 2 | import { 3 | isValidUsername, 4 | isReservedUsername, 5 | normalizeUsername, 6 | } from '../lib/username'; 7 | 8 | import { getPasswordString, SHA256 } from '../lib/password'; 9 | import { Context, AuthTokenResult, Password } from '../types'; 10 | import { isDuplicateKeyError } from '@rakered/mongo/lib/utils'; 11 | import { createTokens } from '../lib/jwt'; 12 | import { UserInputError } from '@rakered/errors'; 13 | 14 | export interface EnrollUserDocument { 15 | token: string; 16 | name?: string; 17 | username?: string; 18 | password: Password; 19 | } 20 | 21 | /** 22 | * Complete user registration with the help of an invite token that has been 23 | * send earlier on. 24 | */ 25 | async function enrollUser( 26 | user: EnrollUserDocument, 27 | context: Context, 28 | ): Promise { 29 | const { collection } = context; 30 | 31 | const name = user.name ? user.name.trim() : null; 32 | const username = user.username ? user.username.trim() : null; 33 | const passwordString = getPasswordString(user.password); 34 | 35 | if (!user.token) { 36 | throw new UserInputError(`Token must be provided.`); 37 | } 38 | 39 | if (typeof username === 'string') { 40 | if (isReservedUsername(username)) { 41 | throw new UserInputError(`Username ${username} is unavailable.`); 42 | } 43 | 44 | if (!isValidUsername(username)) { 45 | throw new UserInputError(`Username ${username} is invalid.`); 46 | } 47 | } 48 | 49 | if (!passwordString) { 50 | throw new UserInputError(`Password must be provided.`); 51 | } 52 | 53 | const now = new Date(); 54 | const hashedPassword = await argon.hash(passwordString); 55 | 56 | const doc: any = { 57 | name, 58 | 'emails.$.verified': true, 59 | 'services.password': { argon: hashedPassword }, 60 | updatedAt: now, 61 | }; 62 | 63 | if (username) { 64 | doc.username = username; 65 | doc.handle = normalizeUsername(username); 66 | } 67 | 68 | try { 69 | const { value } = await collection.findOneAndUpdate( 70 | { 71 | 'services.password.reset.token': SHA256(user.token), 72 | 'services.password.reset.reason': 'enroll', 73 | 'emails.verified': false, 74 | }, 75 | { $set: doc }, 76 | { returnOriginal: false }, 77 | ); 78 | 79 | if (!value) { 80 | throw new UserInputError(`Invalid token provided.`); 81 | } 82 | 83 | const tokens = createTokens(value); 84 | 85 | await collection.updateOne( 86 | { _id: value._id }, 87 | { 88 | $set: { 89 | 'services.resume': { 90 | refreshTokens: [ 91 | { 92 | when: now, 93 | token: SHA256(tokens.refreshToken), 94 | }, 95 | ], 96 | }, 97 | }, 98 | }, 99 | ); 100 | 101 | return tokens; 102 | } catch (e) { 103 | if (isDuplicateKeyError(e, 'handle')) { 104 | throw new UserInputError(`Username ${username} is unavailable.`); 105 | } 106 | 107 | /* istanbul ignore next */ 108 | throw e; 109 | } 110 | } 111 | 112 | export default enrollUser; 113 | -------------------------------------------------------------------------------- /packages/accounts/src/methods/login.ts: -------------------------------------------------------------------------------- 1 | import argon from 'argon2'; 2 | import bcrypt from 'bcryptjs'; 3 | import { getPasswordString, SHA256 } from '../lib/password'; 4 | import { Context, AuthTokenResult, Password, UserDocument } from '../types'; 5 | import { normalizeEmail } from '../lib/email'; 6 | import { cleanUser, createTokens } from '../lib/jwt'; 7 | import { normalizeUsername } from '../lib/username'; 8 | import { UserInputError } from '@rakered/errors'; 9 | import { MAX_ACTIVE_REFRESH_TOKENS } from '../lib/constants'; 10 | 11 | // Support a few variants for developer convenience 12 | export type LoginDocument = 13 | | { identity: string; password: Password } 14 | | { email: string; password: Password } 15 | | { username: string; password: Password }; 16 | 17 | async function verifyPassword( 18 | hash: UserDocument['services']['password'], 19 | plain: string, 20 | ): Promise { 21 | return hash?.argon 22 | ? await argon.verify(hash.argon, plain) 23 | : hash?.bcrypt 24 | ? await bcrypt.compare(plain, hash.bcrypt) 25 | : false; 26 | } 27 | 28 | async function login( 29 | credentials: LoginDocument, 30 | { collection, onLogin }: Context, 31 | ): Promise { 32 | // only one of them can be provided 33 | const identity = 34 | 'email' in credentials 35 | ? credentials.email 36 | : 'username' in credentials 37 | ? credentials.username 38 | : credentials.identity; 39 | 40 | if (typeof identity !== 'string') { 41 | throw new UserInputError('Incorrect credentials provided.'); 42 | } 43 | 44 | const passwordString = getPasswordString(credentials.password); 45 | const selector = identity.includes('@') 46 | ? { 'emails.address': normalizeEmail(identity) } 47 | : { handle: normalizeUsername(identity) }; 48 | 49 | const userDoc = await collection.findOne(selector); 50 | 51 | if (!userDoc) { 52 | throw new UserInputError('Incorrect credentials provided.'); 53 | } 54 | 55 | const hashedPassword = userDoc.services.password; 56 | let valid = await verifyPassword(hashedPassword, passwordString); 57 | 58 | // Legacy fallback for passwords that haven't been hashed on the client. No 59 | // worries, they are stored as hashes. They are just not hashed before the 60 | // client sends them to the server. Which unfortunately is quite common. 61 | if (!valid && typeof credentials.password === 'string') { 62 | valid = await verifyPassword(hashedPassword, credentials.password); 63 | } 64 | 65 | if (!valid) { 66 | throw new UserInputError('Incorrect credentials provided.'); 67 | } 68 | 69 | let user = cleanUser(userDoc); 70 | if (typeof onLogin === 'function') { 71 | user = (await onLogin(user)) || user; 72 | } 73 | 74 | const newTokens = createTokens(user); 75 | const update = { 76 | when: new Date(), 77 | token: SHA256(newTokens.refreshToken), 78 | }; 79 | 80 | // push the new token to the end, and remove all but last n refresh tokens 81 | await collection.updateOne( 82 | { _id: user._id }, 83 | { 84 | $push: { 85 | 'services.resume.refreshTokens': { 86 | $each: [update], 87 | $slice: -MAX_ACTIVE_REFRESH_TOKENS, 88 | }, 89 | }, 90 | $set: { updatedAt: update.when }, 91 | }, 92 | ); 93 | 94 | return newTokens; 95 | } 96 | 97 | export default login; 98 | -------------------------------------------------------------------------------- /packages/accounts/src/methods/removeEmail.test.ts: -------------------------------------------------------------------------------- 1 | import { Accounts } from '../accounts'; 2 | import { Context, AuthTokenResult } from '../types'; 3 | import { getTestContext, initForTest, TEST_USER } from './__tests__/helpers'; 4 | 5 | let accounts: Accounts; 6 | let identity: AuthTokenResult; 7 | let context: Context; 8 | 9 | beforeAll(async () => { 10 | accounts = await initForTest('removeEmail'); 11 | context = getTestContext(accounts); 12 | }); 13 | 14 | beforeEach(async () => { 15 | await accounts.collection.deleteMany({}); 16 | identity = await accounts.createUser(TEST_USER); 17 | }); 18 | 19 | afterAll(async () => { 20 | await accounts.disconnect(); 21 | }); 22 | 23 | test('can remove email from account', async () => { 24 | await accounts.removeEmail({ 25 | userId: identity.user._id, 26 | email: 'hunter@example.com', 27 | }); 28 | 29 | const user = await accounts.collection.findOne({ _id: identity.user._id }); 30 | 31 | expect(user!.emails).toHaveLength(0); 32 | }); 33 | 34 | test('throws error when invalid email was provided', async () => { 35 | await expect( 36 | accounts.removeEmail({ 37 | userId: identity.user._id, 38 | email: 'hunter-new', 39 | }), 40 | ).rejects.toThrow('Email is invalid.'); 41 | }); 42 | 43 | test('throws when email does not belong to given user', async () => { 44 | await expect( 45 | accounts.removeEmail({ 46 | userId: 'abc', 47 | email: 'hunter@example.com', 48 | verified: true, 49 | }), 50 | ).rejects.toThrow('Incorrect userId provided or email is unknown.'); 51 | }); 52 | -------------------------------------------------------------------------------- /packages/accounts/src/methods/removeEmail.ts: -------------------------------------------------------------------------------- 1 | import { isValidEmail, normalizeEmail } from '../lib/email'; 2 | import { Context } from '../types'; 3 | import { UserInputError } from '@rakered/errors'; 4 | 5 | export interface removeEmailDocument { 6 | userId: string; 7 | email: string; 8 | verified?: boolean; 9 | primary?: boolean; 10 | } 11 | 12 | async function removeEmail( 13 | options: removeEmailDocument, 14 | context: Context, 15 | ): Promise { 16 | const { collection } = context; 17 | 18 | const email = normalizeEmail(options.email); 19 | 20 | if (!isValidEmail(email)) { 21 | throw new UserInputError('Email is invalid.'); 22 | } 23 | 24 | const now = new Date(); 25 | 26 | const { modifiedCount } = await collection.updateOne( 27 | { 28 | _id: options.userId, 29 | 'emails.address': email, 30 | // make sure that either 1 email address remains, or that the username is set 31 | // we can't leave users hanging without an option to login. 32 | $or: [{ 'emails.1': { $exists: true } }, { username: { $exists: true } }], 33 | }, 34 | { $pull: { emails: { address: email } }, $set: { updatedAt: now } }, 35 | ); 36 | 37 | if (modifiedCount !== 1) { 38 | throw new UserInputError('Incorrect userId provided or email is unknown.'); 39 | } 40 | } 41 | 42 | export default removeEmail; 43 | -------------------------------------------------------------------------------- /packages/accounts/src/methods/resetPassword.test.ts: -------------------------------------------------------------------------------- 1 | import { Accounts } from '../accounts'; 2 | import { Context, AuthTokenResult } from '../types'; 3 | import createPasswordResetToken from './createPasswordResetToken'; 4 | import { getTestContext, initForTest, TEST_USER } from './__tests__/helpers'; 5 | 6 | let accounts: Accounts; 7 | let identity: AuthTokenResult; 8 | let context: Context; 9 | 10 | beforeAll(async () => { 11 | accounts = await initForTest('resetPassword'); 12 | context = getTestContext(accounts); 13 | }); 14 | 15 | beforeEach(async () => { 16 | await accounts.collection.deleteMany({}); 17 | identity = await accounts.createUser(TEST_USER); 18 | }); 19 | 20 | afterAll(async () => { 21 | await accounts.disconnect(); 22 | }); 23 | 24 | test('does basic input validation', async () => { 25 | // call without token 26 | await expect( 27 | // @ts-ignore 28 | accounts.resetPassword({ password: 'hunter3' }), 29 | ).rejects.toThrow('Invalid token or password provided.'); 30 | 31 | // call without password 32 | await expect( 33 | // @ts-ignore 34 | accounts.resetPassword({ token: 'token' }), 35 | ).rejects.toThrow('Invalid token or password provided.'); 36 | }); 37 | 38 | test('can request reset token, use it to reset password, and login again', async () => { 39 | const { token } = await createPasswordResetToken( 40 | { email: 'hunter@example.com' }, 41 | context, 42 | ); 43 | 44 | const resetPasswordResult = await accounts.resetPassword({ 45 | token, 46 | password: 'hunter3', 47 | }); 48 | 49 | expect(typeof resetPasswordResult.accessToken).toEqual('string'); 50 | expect(typeof resetPasswordResult.refreshToken).toEqual('string'); 51 | expect(resetPasswordResult.user).toMatchPartial({ 52 | email: 'hunter@example.com', 53 | }); 54 | 55 | // verify that we can login with new password 56 | await expect( 57 | accounts.login({ 58 | identity: 'hunter@example.com', 59 | password: 'hunter3', 60 | }), 61 | ).resolves.toHaveProperty('accessToken'); 62 | 63 | // verify that we can't login with old password 64 | await expect( 65 | accounts.login({ identity: 'hunter@example.com', password: 'hunter2' }), 66 | ).rejects.toThrow('Incorrect credentials provided.'); 67 | }); 68 | 69 | test('can not reset password with expired token', async () => { 70 | const _dateNow = Date.now.bind(global.Date); 71 | 72 | const { token, expires } = await createPasswordResetToken( 73 | { email: 'hunter@example.com' }, 74 | context, 75 | ); 76 | 77 | // 1 second after expiry date should do 78 | global.Date.now = jest.fn(() => expires * 1000 + 1000); 79 | 80 | await expect( 81 | accounts.resetPassword({ 82 | token, 83 | password: 'hunter3', 84 | }), 85 | ).rejects.toThrow('Invalid or expired token provided.'); 86 | 87 | global.Date.now = _dateNow; 88 | }); 89 | -------------------------------------------------------------------------------- /packages/accounts/src/methods/resetPassword.ts: -------------------------------------------------------------------------------- 1 | import argon from 'argon2'; 2 | 3 | import type { Context, AuthTokenResult, Password } from '../types'; 4 | import { getPasswordString, SHA256 } from '../lib/password'; 5 | import { RESET_TOKEN_EXPIRY_SECONDS } from '../lib/constants'; 6 | import { createTokens } from '../lib/jwt'; 7 | import { UserInputError } from '@rakered/errors'; 8 | 9 | export interface ResetPasswordDocument { 10 | token: string; 11 | password: Password; 12 | } 13 | 14 | async function resetPassword( 15 | { token, password }: ResetPasswordDocument, 16 | { collection }: Context, 17 | ): Promise { 18 | const passwordString = getPasswordString(password); 19 | if (typeof token !== 'string' || !passwordString) { 20 | throw new UserInputError('Invalid token or password provided.'); 21 | } 22 | 23 | const hashedPassword = await argon.hash(passwordString); 24 | const expiryDate = new Date(Date.now() - RESET_TOKEN_EXPIRY_SECONDS * 1000); 25 | 26 | const { value } = await collection.findOneAndUpdate( 27 | { 28 | 'services.password.reset.token': SHA256(token), 29 | 'services.password.reset.when': { $gte: expiryDate }, 30 | }, 31 | { 32 | $unset: { 33 | 'services.password.reset': true, 34 | 'services.password.bcrypt': true, 35 | }, 36 | $set: { 37 | 'services.password.argon': hashedPassword, 38 | updatedAt: new Date(), 39 | }, 40 | }, 41 | { returnOriginal: false }, 42 | ); 43 | 44 | if (!value) { 45 | throw new UserInputError('Invalid or expired token provided.'); 46 | } 47 | 48 | return createTokens(value); 49 | } 50 | 51 | export default resetPassword; 52 | -------------------------------------------------------------------------------- /packages/accounts/src/methods/revokeToken.test.ts: -------------------------------------------------------------------------------- 1 | import { Accounts } from '../accounts'; 2 | import { Context, AuthTokenResult, UserDocument } from '../types'; 3 | import { getTestContext, initForTest, TEST_USER } from './__tests__/helpers'; 4 | import { createTokens } from '../lib/jwt'; 5 | 6 | let accounts: Accounts; 7 | let identity: AuthTokenResult; 8 | let context: Context; 9 | 10 | beforeAll(async () => { 11 | accounts = await initForTest('revokeToken'); 12 | context = getTestContext(accounts); 13 | }); 14 | 15 | beforeEach(async () => { 16 | await accounts.collection.deleteMany({}); 17 | identity = await accounts.createUser(TEST_USER); 18 | }); 19 | 20 | afterAll(async () => { 21 | await accounts.disconnect(); 22 | }); 23 | 24 | test('revoking a token removes it from the database', async () => { 25 | await accounts.revokeToken({ 26 | refreshToken: identity.refreshToken, 27 | accessToken: identity.accessToken, 28 | }); 29 | 30 | const user = await accounts.collection.findOne({ _id: identity.user._id }); 31 | const refreshTokens = user!.services.resume!.refreshTokens; 32 | expect(refreshTokens).toHaveLength(0); 33 | }); 34 | 35 | test('cannot refresh a revoked token', async () => { 36 | await accounts.revokeToken({ 37 | refreshToken: identity.refreshToken, 38 | accessToken: identity.accessToken, 39 | }); 40 | 41 | await expect( 42 | accounts.refreshToken({ 43 | refreshToken: identity.refreshToken, 44 | accessToken: identity.accessToken, 45 | }), 46 | ).rejects.toThrow('Incorrect token provided.'); 47 | }); 48 | 49 | test('requires two tokens to revoke the token', async () => { 50 | await expect( 51 | // @ts-ignore 52 | accounts.revokeToken({ 53 | refreshToken: identity.refreshToken, 54 | }), 55 | ).rejects.toThrow('Incorrect token provided.'); 56 | 57 | await expect( 58 | // @ts-ignore 59 | accounts.revokeToken({ 60 | accessToken: identity.accessToken, 61 | }), 62 | ).rejects.toThrow('Incorrect token provided.'); 63 | }); 64 | 65 | test('can revoke refresh token with non matching request token', async () => { 66 | const user = await accounts.collection.findOne({ _id: identity.user._id }); 67 | const otherTokens = createTokens(user as UserDocument); 68 | 69 | await expect( 70 | accounts.revokeToken({ 71 | refreshToken: identity.refreshToken, 72 | accessToken: otherTokens.accessToken, 73 | }), 74 | ).resolves.not.toThrow('Incorrect token provided.'); 75 | }); 76 | 77 | test('cannot revoke token for deleted user', async () => { 78 | await accounts.collection.deleteMany({}); 79 | 80 | await expect( 81 | accounts.revokeToken({ 82 | refreshToken: identity.refreshToken, 83 | accessToken: identity.accessToken, 84 | }), 85 | ).rejects.toThrow('Incorrect token provided.'); 86 | }); 87 | 88 | test('cannot revoke token for other user', async () => { 89 | await accounts.collection.deleteMany({}); 90 | const otherTokens = createTokens({ 91 | _id: 'abc', 92 | createdAt: new Date(), 93 | services: {}, 94 | updatedAt: new Date(), 95 | }); 96 | 97 | await expect( 98 | accounts.revokeToken({ 99 | refreshToken: otherTokens.refreshToken, 100 | accessToken: identity.accessToken, 101 | }), 102 | ).rejects.toThrow('Incorrect token provided.'); 103 | }); 104 | -------------------------------------------------------------------------------- /packages/accounts/src/methods/revokeToken.ts: -------------------------------------------------------------------------------- 1 | import { SHA256 } from '../lib/password'; 2 | import { Context } from '../types'; 3 | import { verifyToken } from '../lib/jwt'; 4 | import { UserInputError } from '@rakered/errors'; 5 | 6 | export type TokenDocument = { refreshToken: string; accessToken: string }; 7 | 8 | async function revokeToken( 9 | tokens: TokenDocument, 10 | { collection }: Context, 11 | ): Promise { 12 | if (!tokens?.accessToken || !tokens?.refreshToken) { 13 | throw new UserInputError('Incorrect token provided.'); 14 | } 15 | 16 | const currentRefreshToken = verifyToken(tokens.refreshToken); 17 | const currentAccessToken = verifyToken(tokens.accessToken); 18 | 19 | // we don't check prime here, instead we check userId. It should be possible 20 | // for users to remove old tokens, when already using newer access tokens. 21 | if ( 22 | !currentAccessToken || 23 | !currentRefreshToken || 24 | currentAccessToken._id !== currentRefreshToken._id 25 | ) { 26 | throw new UserInputError('Incorrect token provided.'); 27 | } 28 | 29 | const hashedToken = SHA256(tokens.refreshToken); 30 | 31 | const { modifiedCount } = await collection.updateOne( 32 | { 33 | _id: currentRefreshToken._id, 34 | 'services.resume.refreshTokens.token': hashedToken, 35 | }, 36 | { $pull: { 'services.resume.refreshTokens': { token: hashedToken } } }, 37 | ); 38 | 39 | if (modifiedCount !== 1) { 40 | throw new UserInputError('Incorrect token provided.'); 41 | } 42 | } 43 | 44 | export default revokeToken; 45 | -------------------------------------------------------------------------------- /packages/accounts/src/methods/sendEnrollmentEmail.test.ts: -------------------------------------------------------------------------------- 1 | import smokeTest from '@rakered/email/lib/test/smokeTest'; 2 | import init, { Accounts } from '../accounts'; 3 | 4 | let accounts: Accounts; 5 | 6 | beforeAll(async () => { 7 | accounts = init({ 8 | collection: 'test_email', 9 | }); 10 | 11 | await accounts.collection.deleteMany({}); 12 | await accounts.createUser({ email: 'hunter@example.com' }); 13 | }); 14 | 15 | beforeEach(() => { 16 | process.env.MAIL_URL = ''; 17 | }); 18 | 19 | afterAll(async () => { 20 | await accounts.disconnect(); 21 | }); 22 | 23 | test('can send enrollment email', async () => { 24 | const [result] = await smokeTest( 25 | accounts.sendEnrollmentEmail({ email: 'hunter@example.com' }), 26 | ); 27 | expect(result).toContain('To: hunter@example.com'); 28 | }); 29 | -------------------------------------------------------------------------------- /packages/accounts/src/methods/sendEnrollmentEmail.ts: -------------------------------------------------------------------------------- 1 | import { send } from '@rakered/email'; 2 | 3 | import { Context, EmailDoc } from '../types'; 4 | import createPasswordResetToken from './createPasswordResetToken'; 5 | import { createEnrollmentEmail } from '../email/enrollmentEmail'; 6 | 7 | /** 8 | * Send an email with a link that the user can use to set their initial password. 9 | */ 10 | async function sendEnrollmentEmail( 11 | identity: EmailDoc, 12 | context: Context, 13 | ): Promise { 14 | const { token } = await createPasswordResetToken( 15 | { 16 | ...identity, 17 | type: 'enroll', 18 | }, 19 | context, 20 | ); 21 | 22 | const options = { 23 | ...context.email, 24 | magicLink: context.urls.enrollAccount(token), 25 | to: identity.email, 26 | }; 27 | 28 | const email = createEnrollmentEmail(options); 29 | return send(email); 30 | } 31 | 32 | export default sendEnrollmentEmail; 33 | -------------------------------------------------------------------------------- /packages/accounts/src/methods/sendResetPasswordEmail.test.ts: -------------------------------------------------------------------------------- 1 | import smokeTest from '@rakered/email/lib/test/smokeTest'; 2 | import init, { Accounts } from '../accounts'; 3 | 4 | let accounts: Accounts; 5 | 6 | beforeAll(async () => { 7 | accounts = init({ 8 | collection: 'test_email', 9 | }); 10 | 11 | await accounts.collection.deleteMany({}); 12 | await accounts.createUser({ email: 'hunter@example.com' }); 13 | }); 14 | 15 | beforeEach(() => { 16 | process.env.MAIL_URL = ''; 17 | }); 18 | 19 | afterAll(async () => { 20 | await accounts.disconnect(); 21 | }); 22 | 23 | test('can send reset password email', async () => { 24 | const [result] = await smokeTest( 25 | accounts.sendResetPasswordEmail({ email: 'hunter@example.com' }), 26 | ); 27 | expect(result).toContain('To: hunter@example.com'); 28 | }); 29 | -------------------------------------------------------------------------------- /packages/accounts/src/methods/sendResetPasswordEmail.ts: -------------------------------------------------------------------------------- 1 | import { send } from '@rakered/email'; 2 | import { Context, EmailDoc } from '../types'; 3 | import createPasswordResetToken from './createPasswordResetToken'; 4 | import { createResetPasswordEmail } from '../email/resetPasswordEmail'; 5 | 6 | /** 7 | * Send an email with a link that the user can use to reset their password 8 | */ 9 | async function sendResetPasswordEmail(identity: EmailDoc, context: Context) { 10 | const { token } = await createPasswordResetToken(identity, context); 11 | 12 | const options = { 13 | ...context.email, 14 | magicLink: context.urls.resetPassword(token), 15 | to: identity.email, 16 | }; 17 | 18 | const email = createResetPasswordEmail(options); 19 | return send(email); 20 | } 21 | 22 | export default sendResetPasswordEmail; 23 | -------------------------------------------------------------------------------- /packages/accounts/src/methods/sendVerificationEmail.test.ts: -------------------------------------------------------------------------------- 1 | import smokeTest from '@rakered/email/lib/test/smokeTest'; 2 | import init, { Accounts } from '../accounts'; 3 | 4 | let accounts: Accounts; 5 | 6 | beforeAll(async () => { 7 | accounts = init({ 8 | collection: 'test_email', 9 | }); 10 | 11 | await accounts.collection.deleteMany({}); 12 | await accounts.createUser({ email: 'hunter@example.com' }); 13 | }); 14 | 15 | beforeEach(() => { 16 | process.env.MAIL_URL = ''; 17 | }); 18 | 19 | afterAll(async () => { 20 | await accounts.disconnect(); 21 | }); 22 | 23 | test('can send verification email', async () => { 24 | const [result] = await smokeTest( 25 | accounts.sendVerificationEmail({ email: 'hunter@example.com' }), 26 | ); 27 | expect(result).toContain('To: hunter@example.com'); 28 | }); 29 | -------------------------------------------------------------------------------- /packages/accounts/src/methods/sendVerificationEmail.ts: -------------------------------------------------------------------------------- 1 | import { send } from '@rakered/email'; 2 | 3 | import { Context, EmailDoc } from '../types'; 4 | import createEmailVerificationToken from './createEmailVerificationToken'; 5 | import { createVerificationEmail } from '../email/verificationEmail'; 6 | 7 | /** 8 | * Send an email with a link that the user can use verify their email address. 9 | */ 10 | async function sendVerificationEmail( 11 | identity: EmailDoc, 12 | context: Context, 13 | ): Promise { 14 | const { token } = await createEmailVerificationToken(identity, context); 15 | 16 | const options = { 17 | ...context.email, 18 | magicLink: context.urls.verifyEmail(token), 19 | to: identity.email, 20 | }; 21 | 22 | const email = createVerificationEmail(options); 23 | return send(email); 24 | } 25 | 26 | export default sendVerificationEmail; 27 | -------------------------------------------------------------------------------- /packages/accounts/src/methods/setUsername.test.ts: -------------------------------------------------------------------------------- 1 | import { Accounts } from '../accounts'; 2 | import { Context, AuthTokenResult } from '../types'; 3 | import { getTestContext, initForTest, TEST_USER } from './__tests__/helpers'; 4 | 5 | let accounts: Accounts; 6 | let identity: AuthTokenResult; 7 | let context: Context; 8 | 9 | beforeAll(async () => { 10 | accounts = await initForTest('setUsername'); 11 | context = getTestContext(accounts); 12 | }); 13 | 14 | beforeEach(async () => { 15 | await accounts.collection.deleteMany({}); 16 | identity = await accounts.createUser(TEST_USER); 17 | }); 18 | 19 | afterAll(async () => { 20 | await accounts.disconnect(); 21 | }); 22 | 23 | test('can set username', async () => { 24 | await accounts.setUsername({ 25 | userId: identity.user._id, 26 | username: 'other-hunter', 27 | }); 28 | 29 | const user = await accounts.collection.findOne({ _id: identity.user._id }); 30 | 31 | expect(user).toMatchPartial({ 32 | username: 'other-hunter', 33 | handle: 'otherhunter', 34 | }); 35 | }); 36 | 37 | test('can not set invalid usernames', async () => { 38 | const setUsername = async (username) => 39 | accounts.setUsername({ username, userId: identity.user._id }); 40 | 41 | await expect(setUsername('admin')).rejects.toThrow( 42 | `Username admin is unavailable.`, 43 | ); 44 | 45 | await expect(setUsername('hun.ter')).rejects.toThrow( 46 | `Username hun.ter is invalid.`, 47 | ); 48 | 49 | await expect(setUsername('hun--ter')).rejects.toThrow( 50 | `Username hun--ter is invalid.`, 51 | ); 52 | }); 53 | 54 | test('throws if user is not found', async () => { 55 | await expect( 56 | accounts.setUsername({ 57 | userId: 'abc', 58 | username: 'hunter-new', 59 | }), 60 | ).rejects.toThrow(`Incorrect userId provided.`); 61 | }); 62 | 63 | test('can not set username similar looking to already existing username', async () => { 64 | await accounts.createUser({ 65 | email: 'someone@example.com', 66 | username: 'other-hunter', 67 | password: 'hi', 68 | }); 69 | 70 | await expect( 71 | accounts.setUsername({ 72 | userId: identity.user._id, 73 | username: 'Other-Hunter', 74 | }), 75 | ).rejects.toThrow(`Username Other-Hunter is unavailable.`); 76 | }); 77 | 78 | test('can set own name to similar looking variant', async () => { 79 | await accounts.setUsername({ 80 | userId: identity.user._id, 81 | username: 'Hun-Ter', 82 | }); 83 | 84 | const user = await accounts.collection.findOne({ _id: identity.user._id }); 85 | 86 | expect(user).toMatchPartial({ 87 | username: 'Hun-Ter', 88 | handle: 'hunter', 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /packages/accounts/src/methods/setUsername.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isValidUsername, 3 | isReservedUsername, 4 | normalizeUsername, 5 | } from '../lib/username'; 6 | import { Context } from '../types'; 7 | import { isDuplicateKeyError } from '@rakered/mongo/lib/utils'; 8 | import { UserInputError } from '@rakered/errors'; 9 | 10 | export interface setUsernameDocument { 11 | userId: string; 12 | username: string; 13 | } 14 | 15 | async function setUsername( 16 | options: setUsernameDocument, 17 | context: Context, 18 | ): Promise { 19 | const { collection } = context; 20 | 21 | const username = (options.username || '').trim(); 22 | 23 | if (isReservedUsername(username)) { 24 | throw new UserInputError(`Username ${username} is unavailable.`); 25 | } 26 | 27 | if (!isValidUsername(username)) { 28 | throw new UserInputError(`Username ${username} is invalid.`); 29 | } 30 | 31 | const now = new Date(); 32 | 33 | const doc: any = { 34 | username, 35 | handle: normalizeUsername(username), 36 | updatedAt: now, 37 | }; 38 | 39 | try { 40 | const { modifiedCount } = await collection.updateOne( 41 | { _id: options.userId }, 42 | { $set: doc }, 43 | ); 44 | 45 | if (modifiedCount !== 1) { 46 | throw new UserInputError('Incorrect userId provided.'); 47 | } 48 | } catch (e) { 49 | if (isDuplicateKeyError(e, 'handle')) { 50 | throw new UserInputError(`Username ${username} is unavailable.`); 51 | } 52 | 53 | /* istanbul ignore next */ 54 | throw e; 55 | } 56 | } 57 | 58 | export default setUsername; 59 | -------------------------------------------------------------------------------- /packages/accounts/src/methods/verifyEmail.test.ts: -------------------------------------------------------------------------------- 1 | import { Accounts } from '../accounts'; 2 | import { Context, AuthTokenResult } from '../types'; 3 | 4 | import createEmailVerificationToken from './createEmailVerificationToken'; 5 | import { getTestContext, initForTest, TEST_USER } from './__tests__/helpers'; 6 | 7 | let accounts: Accounts; 8 | let identity: AuthTokenResult; 9 | let context: Context; 10 | 11 | beforeAll(async () => { 12 | accounts = await initForTest('verifyEmail'); 13 | context = getTestContext(accounts); 14 | }); 15 | 16 | beforeEach(async () => { 17 | await accounts.collection.deleteMany({}); 18 | identity = await accounts.createUser(TEST_USER); 19 | }); 20 | 21 | afterAll(async () => { 22 | await accounts.disconnect(); 23 | }); 24 | 25 | test('can request verification token and use it to verify email', async () => { 26 | const { token } = await createEmailVerificationToken( 27 | { email: 'hunter@example.com' }, 28 | context, 29 | ); 30 | 31 | const result = await accounts.verifyEmail({ token }); 32 | 33 | expect(typeof result.refreshToken).toEqual('string'); 34 | expect(typeof result.accessToken).toEqual('string'); 35 | expect(result.user).toEqual(identity.user); 36 | 37 | const doc = await accounts.collection.findOne({ _id: identity.user._id }); 38 | 39 | // verify that the email is verified 40 | expect(doc!.emails?.[0]).toMatchPartial({ 41 | address: 'hunter@example.com', 42 | verified: true, 43 | }); 44 | }); 45 | 46 | test('can not verify email with invalid token', async () => { 47 | const { token } = await createEmailVerificationToken( 48 | { email: 'hunter@example.com' }, 49 | context, 50 | ); 51 | 52 | await createEmailVerificationToken({ email: 'hunter@example.com' }, context); 53 | 54 | // test with a token that has been replaced 55 | await expect(accounts.verifyEmail({ token })).rejects.toThrow( 56 | 'Invalid or expired token provided.', 57 | ); 58 | 59 | // @ts-ignore ensure that token is required 60 | await expect(accounts.verifyEmail({ token: null })).rejects.toThrow( 61 | 'Invalid token provided.', 62 | ); 63 | }); 64 | -------------------------------------------------------------------------------- /packages/accounts/src/methods/verifyEmail.ts: -------------------------------------------------------------------------------- 1 | import type { Context, AuthTokenResult } from '../types'; 2 | import { SHA256 } from '../lib/password'; 3 | import { createTokens } from '../lib/jwt'; 4 | import { UserInputError } from '@rakered/errors'; 5 | 6 | export interface VerifyEmailDocument { 7 | token: string; 8 | } 9 | 10 | async function verifyEmail( 11 | { token }: VerifyEmailDocument, 12 | { collection }: Context, 13 | ): Promise { 14 | if (typeof token !== 'string') { 15 | throw new UserInputError('Invalid token provided.'); 16 | } 17 | 18 | const hashedToken = SHA256(token); 19 | 20 | const { value } = await collection.findOneAndUpdate( 21 | { 'emails.token': hashedToken }, 22 | { 23 | $unset: { 'emails.$.token': true }, 24 | $set: { 'emails.$.verified': true }, 25 | }, 26 | { returnOriginal: false }, 27 | ); 28 | 29 | // tokens don't have a expiry date, but new token requests, replace the old ones 30 | if (!value) { 31 | throw new UserInputError('Invalid or expired token provided.'); 32 | } 33 | 34 | return createTokens(value); 35 | } 36 | 37 | export default verifyEmail; 38 | -------------------------------------------------------------------------------- /packages/accounts/src/types.ts: -------------------------------------------------------------------------------- 1 | import { Collection } from '@rakered/mongo'; 2 | import { EmailSettings } from './index'; 3 | 4 | export type Password = string | { algorithm: 'sha-256'; digest: string }; 5 | 6 | export type EmailDoc = { email: string }; 7 | 8 | export type TokenUrls = { 9 | verifyEmail: (token: string) => string; 10 | resetPassword: (token: string) => string; 11 | enrollAccount: (token: string) => string; 12 | }; 13 | 14 | export interface Context { 15 | collection: Collection; 16 | email: EmailSettings; 17 | urls: TokenUrls; 18 | onLogin?: (user: User) => Promise; 19 | } 20 | 21 | export interface EmailOptions { 22 | to: string; 23 | from: string; 24 | siteName: string; 25 | siteUrl: string; 26 | logoUrl?: string; 27 | magicLink: string; 28 | } 29 | 30 | export interface User { 31 | _id: string; 32 | username: string; 33 | email: string; 34 | name: string; 35 | roles: string[]; 36 | } 37 | 38 | export interface InviteUserResult { 39 | user: { 40 | _id: string; 41 | email: string; 42 | }; 43 | } 44 | 45 | export interface AuthTokenResult { 46 | user: User; 47 | refreshToken: string; 48 | accessToken: string; 49 | } 50 | 51 | export interface TokenResult { 52 | token: string; 53 | expires: number; 54 | } 55 | 56 | export interface UserDocument { 57 | _id: string; 58 | name?: string; 59 | username?: string; 60 | // handle is a private field, it's a normalized version of the username 61 | handle?: string; 62 | emails?: { 63 | address: string; 64 | verified: boolean; 65 | token?: string; 66 | digits?: string; 67 | }[]; 68 | roles?: string[]; 69 | services: { 70 | password?: { 71 | argon?: string; 72 | bcrypt?: string; 73 | reset?: { 74 | token: string; 75 | email: string; 76 | reason?: string; 77 | when: Date; 78 | }; 79 | }; 80 | resume?: { 81 | refreshTokens?: { 82 | token: string; 83 | when: Date; 84 | }[]; 85 | }; 86 | }; 87 | createdAt: Date; 88 | updatedAt: Date; 89 | } 90 | -------------------------------------------------------------------------------- /packages/accounts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./lib" 6 | }, 7 | "include": ["./src/", "./global.d.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/cron/docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smeijer/rakered/93ec75cb477e741cfce098f7f714562a93c6c286/packages/cron/docs/logo.png -------------------------------------------------------------------------------- /packages/cron/docs/social.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smeijer/rakered/93ec75cb477e741cfce098f7f714562a93c6c286/packages/cron/docs/social.jpg -------------------------------------------------------------------------------- /packages/cron/global.d.ts: -------------------------------------------------------------------------------- 1 | import 'jest-partial'; 2 | -------------------------------------------------------------------------------- /packages/cron/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('../../jest.config'); 2 | -------------------------------------------------------------------------------- /packages/cron/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /packages/cron/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rakered/cron", 3 | "version": "1.3.1", 4 | "description": "Lightweight job scheduling for Node.js. Cron won't get easier.", 5 | "keywords": [ 6 | "nodejs", 7 | "mongodb", 8 | "cron" 9 | ], 10 | "main": "./lib/index.js", 11 | "types": "./lib/index.d.ts", 12 | "license": "AGPL-3.0 OR COMMERCIAL", 13 | "author": "Stephan Meijer ", 14 | "private": false, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/rakered/rakered.git" 18 | }, 19 | "scripts": { 20 | "build": "npm run clean && tsc", 21 | "clean": "rimraf \"./lib\" \"*.tsbuildinfo\"", 22 | "prepare": "npm run build", 23 | "test": "jest --coverage --runInBand ./src", 24 | "test:debug": "node --inspect-brk ./node_modules/jest/bin/jest --runInBand", 25 | "bump:patch": "npm version patch -m 'release(cron): cut the %s release'", 26 | "bump:minor": "npm version minor -m 'release(cron): cut the %s release'", 27 | "bump:major": "npm version major -m 'release(cron): cut the %s release'" 28 | }, 29 | "files": [ 30 | "lib" 31 | ], 32 | "dependencies": { 33 | "@breejs/later": "^4.0.2", 34 | "@rakered/mongo": "^1.5.1", 35 | "debug": "^4.3.1", 36 | "exit-hook": "github:smeijer-forks/exit-hook", 37 | "mongodb": "^3.6.8" 38 | }, 39 | "devDependencies": { 40 | "@types/jest": "^26.0.20", 41 | "@types/mongodb": "^3.6.16", 42 | "@types/node": "^14.14.21", 43 | "@types/sinon": "^10.0.0", 44 | "jest": "^26.6.3", 45 | "jest-partial": "^1.0.1", 46 | "mockdate": "^3.0.5", 47 | "rimraf": "^3.0.2", 48 | "sinon": "^10.0.0", 49 | "ts-jest": "^26.4.4", 50 | "typescript": "^4.1.3" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /packages/cron/src/db.ts: -------------------------------------------------------------------------------- 1 | import { create, Collection, Db } from '@rakered/mongo'; 2 | 3 | export interface Job { 4 | _id: string; 5 | name: string; 6 | created: Date; 7 | intended: Date; 8 | started?: Date; 9 | schedule?: string; 10 | data?: Record; 11 | } 12 | 13 | export type JobsDb = Db & { 14 | jobs: Collection; 15 | }; 16 | 17 | export function getDb(): JobsDb { 18 | const db = create(); 19 | db.jobs.pkPrefix = 'job_'; 20 | 21 | db.jobs.createIndex( 22 | { started: 1 }, 23 | { expireAfterSeconds: 300, name: 'job-ttl' }, 24 | ); 25 | 26 | return db; 27 | } 28 | -------------------------------------------------------------------------------- /packages/cron/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './cron'; 2 | export { default } from './cron'; 3 | export type { Job } from './db'; 4 | -------------------------------------------------------------------------------- /packages/cron/src/log.ts: -------------------------------------------------------------------------------- 1 | import debug from 'debug'; 2 | 3 | export default debug('cron'); 4 | -------------------------------------------------------------------------------- /packages/cron/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./lib" 6 | }, 7 | "include": ["./src/", "./global.d.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/email/README.md: -------------------------------------------------------------------------------- 1 | # @rakered/email 2 | 3 | Compose emails using react and send them with [nodemailer](https://github.com/nodemailer/nodemailer) 4 | 5 | ![social image](https://github.com/rakered/rakered/raw/main/packages/email/docs/social.jpg) 6 | 7 | ## Usage 8 | 9 | First, you'll need to create one or more email templates that you wish to send to your uses. For this, you'll be using react and our base components. There are various base elements, and a theme that can be modified to your needs. But let's start with an example of what you need to send a Mail. 10 | 11 | ### Sending emails 12 | 13 | To send emails, you'll need to set `process.env.MAIL_URL` to a valid `smtp` or `smpts` url. When this environment variable is not set, mails will be printed to `stdout` (your terminal/console). 14 | 15 | ```js 16 | import { send, render } from '@rakered/email'; 17 | 18 | await send({ 19 | to: 'hunter@example.com', 20 | from: 'stephan@example.com', 21 | subject: 'Confirm your account', 22 | text: 'hi there! Please use the link below to verify…', 23 | html: render(), 24 | }); 25 | ``` 26 | 27 | Valid SMTP urls have the following format: 28 | 29 | ``` 30 | # insecure, non ssl: 31 | smtp://username:password@example.com:25 32 | 33 | # secure, using ssl 34 | smtps://username:password@example.com:456 35 | ``` 36 | 37 | ### Building Templates 38 | 39 | It's important to wrap every mail template in the `Email` component, as that components provide the context (theme data) to the various building blocks. 40 | 41 | Next, we offer a number of building blocks, such as `Title`, `Paragraph`, and `CallToAction`. These are the components that you want to use to build your template with. Check `/src/template/blocks` if you wish to know all of them. 42 | 43 | All basic building blocks make smart use of our grid system, which can be found in `./src/template/layout`. It is possible to use this grid system in your templates as well, but generally speaking, you should not need it. 44 | 45 | ```js 46 | import { 47 | CallToAction, 48 | Content, 49 | Email, 50 | Header, 51 | Paragraph, 52 | Title, 53 | } from '@rakered/email'; 54 | 55 | function ConfirmAccountMail({ siteName, siteUrl }) { 56 | return ( 57 | 58 | 59 |
63 | 64 | Complete Registration 65 | 66 | 67 | Please click the button below, to complete your registration 68 | 69 | 70 | 71 | Confirm email 72 | 73 | 74 | 75 | ); 76 | } 77 | ``` 78 | 79 | ### Themes 80 | 81 | Using the standard theme, your result will look something like this: 82 | 83 | ![social image](https://github.com/rakered/rakered/raw/main/packages/email/docs/standard-template.jpg) 84 | -------------------------------------------------------------------------------- /packages/email/docs/social.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smeijer/rakered/93ec75cb477e741cfce098f7f714562a93c6c286/packages/email/docs/social.jpg -------------------------------------------------------------------------------- /packages/email/docs/standard-template.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smeijer/rakered/93ec75cb477e741cfce098f7f714562a93c6c286/packages/email/docs/standard-template.jpg -------------------------------------------------------------------------------- /packages/email/global.d.ts: -------------------------------------------------------------------------------- 1 | import 'jest-partial'; 2 | -------------------------------------------------------------------------------- /packages/email/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('../../jest.config'); 2 | -------------------------------------------------------------------------------- /packages/email/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /packages/email/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rakered/email", 3 | "version": "1.5.0", 4 | "description": "Compose emails using React and send them with nodemailer.", 5 | "keywords": [ 6 | "nodejs", 7 | "email", 8 | "react" 9 | ], 10 | "main": "./lib/index.js", 11 | "source": "./src/index.ts", 12 | "license": "AGPL-3.0 OR COMMERCIAL", 13 | "author": "Stephan Meijer ", 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/rakered/rakered.git" 17 | }, 18 | "scripts": { 19 | "build": "rimraf ./lib *.tsbuildinfo && tsc", 20 | "prepare": "npm run build", 21 | "test": "jest --coverage --runInBand ./src", 22 | "test:debug": "node --inspect-brk ./node_modules/jest/bin/jest --runInBand", 23 | "test:send": "ts-node scripts/sendTestMail.tsx smtp://project.3:secret.3@localhost:1025", 24 | "bump:patch": "npm version patch -m 'release(email): cut the %s release'", 25 | "bump:minor": "npm version minor -m 'release(email): cut the %s release'", 26 | "bump:major": "npm version major -m 'release(email): cut the %s release'" 27 | }, 28 | "files": [ 29 | "lib" 30 | ], 31 | "dependencies": { 32 | "exit-hook": "github:smeijer-forks/exit-hook", 33 | "nodemailer": "^6.4.17", 34 | "react": "^17.0.1", 35 | "react-dom": "^17.0.1" 36 | }, 37 | "devDependencies": { 38 | "@types/jest": "^26.0.20", 39 | "@types/nodemailer": "^6.4.0", 40 | "@types/react": "^17.0.2", 41 | "jest": "^26.6.3", 42 | "jest-partial": "^1.0.1", 43 | "rimraf": "^3.0.2", 44 | "ts-jest": "^26.4.4", 45 | "typescript": "^4.1.3" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/email/scripts/sendTestMail.tsx: -------------------------------------------------------------------------------- 1 | import { send, render } from '../src'; 2 | import TestMail from '../src/test/TestMail'; 3 | 4 | const [mailUrl] = process.argv.splice(2); 5 | 6 | if (!mailUrl) { 7 | console.log(` 8 | This script should get the MAIL_URL as param 9 | 10 | > ts-node ./scripts/sendTestMail smtp://usr:pwd@localhost:25 11 | `); 12 | process.exit(1); 13 | } 14 | 15 | // copy arg to process.env, as that is what `send` uses. 16 | 17 | process.env.MAIL_URL = mailUrl; 18 | 19 | // todo, create iterator that generates plain text, or use context and attribute on Email {mode=html | text} 20 | const mail = ( 21 | 22 | ); 23 | 24 | send({ 25 | to: 'hunter@example.com', 26 | from: 'stephan@example.com', 27 | subject: 'hello', 28 | text: 'hi there!', 29 | html: render(mail), 30 | }) 31 | .catch((e) => { 32 | console.error(e); 33 | process.exit(1); 34 | }) 35 | .finally(() => { 36 | process.exit(); 37 | }); 38 | -------------------------------------------------------------------------------- /packages/email/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './render'; 2 | export * from './send'; 3 | export * from './templates'; 4 | -------------------------------------------------------------------------------- /packages/email/src/lib/nl2br.tsx: -------------------------------------------------------------------------------- 1 | const regEx = /(\r\n|\n\r|\r|\n)/g; 2 | 3 | export function nl2br(str) { 4 | return `${str}`.split(regEx).map((line, index) => { 5 | if (line.match(regEx)) { 6 | return
; 7 | } 8 | 9 | return line; 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /packages/email/src/lib/omit.ts: -------------------------------------------------------------------------------- 1 | export function omit(obj: T, keys: K[]): Omit { 2 | const ret = {} as Omit; 3 | const omitKeys = new Set(keys); 4 | 5 | for (const k in obj) { 6 | const key = (k as unknown) as K; 7 | if (omitKeys.has(key)) { 8 | continue; 9 | } 10 | const presentKey = (key as unknown) as Exclude; 11 | ret[presentKey] = obj[presentKey]; 12 | } 13 | 14 | return ret; 15 | } 16 | -------------------------------------------------------------------------------- /packages/email/src/lib/parseTpl.ts: -------------------------------------------------------------------------------- 1 | function get(path, obj, fb = `$\{${path}}`) { 2 | return path.split('.').reduce((res, key) => res[key] ?? fb, obj); 3 | } 4 | 5 | function parseTpl(template, map, fallback) { 6 | return template.replace(/\${([^{]+[^}])}/g, (match) => { 7 | const path = match.substr(2, match.length - 3).trim(); 8 | return get(path, map, fallback); 9 | }); 10 | } 11 | 12 | export default parseTpl; 13 | -------------------------------------------------------------------------------- /packages/email/src/render.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from './render'; 2 | import TestMail from './test/TestMail'; 3 | 4 | test('can render email', () => { 5 | const html = render( 6 | , 12 | ); 13 | 14 | expect(html).toContain('placeholder.com-logo4.png'); 15 | expect(html).toContain('Complete Registration'); 16 | expect(html).toContain('Please copy this code'); 17 | expect(html).toContain(123456); 18 | expect(html).toContain('click this button instead'); 19 | expect(html).toContain('Confirm your email'); 20 | expect(html).toContain( 21 | 'If you did not create the account, please ignore this msg', 22 | ); 23 | }); 24 | -------------------------------------------------------------------------------- /packages/email/src/render.tsx: -------------------------------------------------------------------------------- 1 | import { renderToStaticMarkup } from 'react-dom/server'; 2 | import { ReactNode } from 'react'; 3 | 4 | export function render(vdom: ReactNode): string { 5 | return ` 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | ${renderToStaticMarkup(vdom)} 14 | 15 | `; 16 | } 17 | -------------------------------------------------------------------------------- /packages/email/src/send.test.tsx: -------------------------------------------------------------------------------- 1 | import { send } from './send'; 2 | import smokeTest from './test/smokeTest'; 3 | import { render } from './render'; 4 | import TestMail from './test/TestMail'; 5 | 6 | const sendMailMock = jest.fn(); 7 | jest.mock('nodemailer'); 8 | 9 | const nodemailer = require('nodemailer'); 10 | nodemailer.createTransport.mockReturnValue({ sendMail: sendMailMock }); 11 | 12 | const mail = { 13 | to: 'hunter@example.com', 14 | from: 'stephan@example.com', 15 | subject: 'hello', 16 | text: 'hi there!', 17 | html: render( 18 | , 19 | ), 20 | }; 21 | 22 | beforeEach(() => { 23 | process.env.MAIL_URL = ''; 24 | sendMailMock.mockClear(); 25 | nodemailer.createTransport.mockClear(); 26 | }); 27 | 28 | test('attempts to send the mail when MAIL_URL is set', async () => { 29 | process.env.MAIL_URL = 'smtp://localhost:25'; 30 | 31 | try { 32 | await send(mail); 33 | expect(sendMailMock).toHaveBeenCalledWith(mail); 34 | } catch (e) { 35 | expect(e).toEqual(1); 36 | } 37 | }); 38 | 39 | test('prints the mail message when MAIL_URL not set', async () => { 40 | const [msg] = await smokeTest(send(mail)); 41 | expect(msg).toMatch('hi there!'); 42 | }); 43 | 44 | test('throws error for invalid mail protocol', async () => { 45 | process.env.MAIL_URL = 'http://example.com'; 46 | 47 | await expect(send(mail)).rejects.toThrow( 48 | 'Email protocol must be smtp or smtps', 49 | ); 50 | }); 51 | -------------------------------------------------------------------------------- /packages/email/src/send.ts: -------------------------------------------------------------------------------- 1 | import nodemailer, { 2 | Transporter, 3 | SendMailOptions, 4 | SentMessageInfo, 5 | } from 'nodemailer'; 6 | import Composer from 'nodemailer/lib/mail-composer'; 7 | import { URL } from 'url'; 8 | import onExit from 'exit-hook'; 9 | 10 | function createTransport(mailUrl: string): Transporter { 11 | const url = new URL(mailUrl); 12 | 13 | if (url.protocol !== 'smtp:' && url.protocol !== 'smtps:') { 14 | throw new Error(`Email protocol must be smtp or smtps`); 15 | } 16 | 17 | if (url.protocol === 'smtp:' && url.port === '465') { 18 | console.warn( 19 | `Connecting over a secure port, while using smtp protocol! You probably mean to use smtps:`, 20 | ); 21 | } 22 | 23 | // Allow overriding pool setting, but default to true. 24 | if (!url.searchParams.has('pool')) { 25 | url.searchParams.set('pool', 'true'); 26 | } 27 | 28 | return nodemailer.createTransport(url.toString()); 29 | } 30 | 31 | const _transportCache = new Map(); 32 | function getTransport() { 33 | const url = process.env.MAIL_URL; 34 | 35 | if (!url) { 36 | return null; 37 | } 38 | 39 | if (_transportCache.has(url)) { 40 | return _transportCache.get(url); 41 | } 42 | 43 | const transporter = createTransport(url); 44 | _transportCache.set(url, transporter); 45 | return _transportCache.get(url); 46 | } 47 | 48 | let devModeMailId = 0; 49 | 50 | const listeners = new Set<(email: string) => void>(); 51 | 52 | export const addSmokeListener = (fn) => { 53 | listeners.add(fn); 54 | }; 55 | 56 | async function devModeSend(mail: SendMailOptions) { 57 | const messageId = (++devModeMailId).toString().padStart(3, '0'); 58 | 59 | const chunks: any[] = []; 60 | const stream = new Composer(mail).compile().createReadStream(); 61 | 62 | for await (const chunk of stream) { 63 | chunks.push(chunk); 64 | } 65 | 66 | const content = Buffer.concat(chunks).toString('utf-8'); 67 | const header = '====== BEGIN MAIL #' + messageId + ' ======'; 68 | const footer = '====== END MAIL #' + messageId + ' ======'; 69 | 70 | const output = [header, content, footer].join('\n'); 71 | 72 | for (const listener of listeners) { 73 | listener(output); 74 | listeners.delete(listener); 75 | } 76 | 77 | if (process.env.NODE_ENV !== 'test') { 78 | console.log(output); 79 | } 80 | 81 | return; 82 | } 83 | 84 | export type SendOptions = SendMailOptions; 85 | export async function send(options: SendOptions): Promise { 86 | const transport = getTransport(); 87 | 88 | if (transport) { 89 | return transport.sendMail(options); 90 | } 91 | 92 | return devModeSend(options); 93 | } 94 | 95 | export async function disconnect() { 96 | for (const url of _transportCache.keys()) { 97 | const transport = _transportCache.get(url); 98 | _transportCache.delete(url); 99 | 100 | if (transport && typeof transport.close === 'function') { 101 | transport.close(); 102 | } 103 | } 104 | } 105 | 106 | // graceful shutdown 107 | onExit(() => disconnect()); 108 | -------------------------------------------------------------------------------- /packages/email/src/templates/Blocks.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from './Email'; 2 | import { Grid } from './Layout'; 3 | 4 | export function Paragraph({ children }) { 5 | const style = useTheme('paragraph'); 6 | 7 | return
{children}
; 8 | } 9 | 10 | export function CallToAction({ href, children }) { 11 | const style = useTheme('callToAction'); 12 | 13 | return ( 14 | 19 | ); 20 | } 21 | 22 | export function Container({ children }) { 23 | const style = useTheme('container'); 24 | 25 | return ( 26 |
27 |
28 | {children} 29 |
30 |
31 | ); 32 | } 33 | 34 | export function Content({ children }) { 35 | const style = useTheme('content'); 36 | 37 | return ( 38 |
39 |
40 | {children} 41 |
42 |
43 | ); 44 | } 45 | 46 | export function Title({ children }) { 47 | const style = useTheme('title'); 48 | return
{children}
; 49 | } 50 | 51 | export function Code({ children }) { 52 | const style = useTheme('code'); 53 | 54 | return ( 55 |
56 |
{children}
57 |
58 | ); 59 | } 60 | 61 | export function Header({ 62 | logo, 63 | action, 64 | }: { 65 | logo?: string; 66 | action?: { label: string; url: string }; 67 | }) { 68 | const style = useTheme('header'); 69 | 70 | return ( 71 |
72 |
73 | {logo ? : null} 74 | 75 | {action ? ( 76 | 77 | {action.label} 78 | 79 | ) : null} 80 |
81 |
82 | ); 83 | } 84 | 85 | export function Footer({ children }) { 86 | const style = useTheme('footer'); 87 | 88 | return ( 89 |
90 |
{children}
91 |
92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /packages/email/src/templates/Email.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, ReactNode, useContext } from 'react'; 2 | import defaultTheme from './themes/default'; 3 | 4 | export const context = createContext({}); 5 | 6 | export function useTheme(prop) { 7 | const theme = useContext(context); 8 | return prop ? theme[prop] : theme; 9 | } 10 | 11 | const Provider = context.Provider; 12 | 13 | export function Email({ 14 | theme = defaultTheme, 15 | children, 16 | }: { 17 | theme?: Record; 18 | children?: ReactNode; 19 | }) { 20 | return ( 21 | 22 |
{children}
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /packages/email/src/templates/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, CSSProperties } from 'react'; 2 | 3 | const styles: Record = { 4 | table: { 5 | width: '100%', 6 | borderCollapse: 'collapse', 7 | }, 8 | }; 9 | 10 | function ensureArray(arr) { 11 | return Array.isArray(arr) ? arr : [arr].filter(Boolean); 12 | } 13 | 14 | interface CellProps { 15 | children?: ReactNode; 16 | style?: Record; 17 | className?: string; 18 | colSpan?: number; 19 | } 20 | 21 | function Cell({ children, style = {}, className, colSpan }: CellProps) { 22 | return ( 23 | 24 | {children} 25 | 26 | ); 27 | } 28 | 29 | function Row({ children, style = {} }) { 30 | const content = ensureArray(children) 31 | .filter(Boolean) 32 | .map((el, idx, { length }) => { 33 | if (el.type === Cell) { 34 | return el; 35 | } 36 | 37 | return ( 38 | 39 | {el} 40 | 41 | ); 42 | }); 43 | 44 | return {content}; 45 | } 46 | 47 | function Grid({ children, style = {} }) { 48 | const content = ensureArray(children) 49 | .filter(Boolean) 50 | .map((el, idx) => { 51 | if (!el) { 52 | return null; 53 | } 54 | 55 | // We want this content the be on it's own row. 56 | if (el.type === Row) { 57 | return el; 58 | } 59 | 60 | // The content is all inside a single cell (so a row) 61 | if (el.type === Cell) { 62 | return {el}; 63 | } 64 | 65 | // The content is one cell inside it's own row 66 | return ( 67 | 68 | {el} 69 | 70 | ); 71 | }) 72 | .filter(Boolean); 73 | 74 | return ( 75 | 80 | {content} 81 |
82 | ); 83 | } 84 | 85 | Grid.Row = Row; 86 | Grid.Cell = Cell; 87 | 88 | export { Grid, Row, Cell }; 89 | -------------------------------------------------------------------------------- /packages/email/src/templates/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './Email'; 2 | export * from './Blocks'; 3 | export * from './Layout'; 4 | export { default as theme } from './themes/default'; 5 | -------------------------------------------------------------------------------- /packages/email/src/templates/themes/default.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | root: { 3 | width: '100%', 4 | backgroundColor: '#f3f4f8', 5 | paddingTop: 40, 6 | paddingBottom: 96, 7 | }, 8 | 9 | header: { 10 | outer: { 11 | paddingBottom: 16, 12 | }, 13 | 14 | inner: { 15 | height: 60, 16 | }, 17 | 18 | logo: { 19 | paddingTop: 5, 20 | height: 48, 21 | }, 22 | 23 | button: { 24 | textAlign: 'right', 25 | border: '1px solid #050038', 26 | display: 'inline', 27 | padding: '12px 16px', 28 | borderRadius: 8, 29 | float: 'right', 30 | textDecoration: 'none', 31 | color: '#050038', 32 | lineHeight: '14px', 33 | }, 34 | }, 35 | 36 | footer: { 37 | outer: { 38 | opacity: 0.7, 39 | paddingTop: 40, 40 | width: 600, 41 | maxWidth: 600, 42 | minWidth: 600, 43 | margin: '0 auto', 44 | textAlign: 'center', 45 | color: '#050038', 46 | fontSize: 12, 47 | fontFamily: 'Helvetica Neue, Helvetica, Arial, sans-serif', 48 | }, 49 | }, 50 | 51 | container: { 52 | outer: { 53 | width: 560, 54 | maxWidth: 560, 55 | minWidth: 560, 56 | margin: '0 auto', 57 | }, 58 | }, 59 | 60 | content: { 61 | outer: { 62 | borderRadius: 8, 63 | padding: '40px 32px', 64 | width: 560 - 2 * 32, 65 | maxWidth: 560 - 2 * 32, 66 | minWidth: 560 - 2 * 32, 67 | margin: '0 auto', 68 | background: 'white', 69 | borderBottom: '1px solid #e1e0e7', 70 | }, 71 | }, 72 | 73 | title: { 74 | color: '#050038', 75 | fontFamily: 'Helvetica Neue, Helvetica, Arial, sans-serif', 76 | fontSize: 36, 77 | fontWeight: 500, 78 | lineHeight: 1.24, 79 | }, 80 | 81 | paragraph: { 82 | paddingTop: 16, 83 | color: '#050038', 84 | fontFamily: 'Helvetica Neue, Helvetica, Arial, sans-serif', 85 | fontSize: 18, 86 | fontWeight: 400, 87 | lineHeight: 1.4, 88 | opacity: 0.6, 89 | }, 90 | 91 | code: { 92 | outer: { 93 | paddingTop: 32, 94 | paddingBottom: 16, 95 | }, 96 | 97 | inner: { 98 | backgroundColor: '#f3f4f8', 99 | borderRadius: 8, 100 | color: '#050038', 101 | fontFamily: 'Helvetica Neue, Helvetica, Arial, sans-serif', 102 | fontSize: 36, 103 | fontWeight: 500, 104 | height: 128, 105 | lineHeight: '128px', 106 | textAlign: 'center', 107 | }, 108 | }, 109 | 110 | callToAction: { 111 | outer: { 112 | paddingTop: 32, 113 | paddingBottom: 16, 114 | margin: '0 auto', 115 | textAlign: 'center', 116 | }, 117 | 118 | inner: { 119 | background: '#3082ce', 120 | border: 'none', 121 | borderRadius: 8, 122 | color: '#fff', 123 | display: 'inline-block', 124 | fontFamily: 'Helvetica Neue, Helvetica, Arial, sans-serif', 125 | fontSize: 18, 126 | fontWeight: 500, 127 | lineHeight: 1.43, 128 | outline: 0, 129 | padding: '18px 24px', 130 | textAlign: 'center', 131 | textDecoration: 'none', 132 | }, 133 | }, 134 | }; 135 | -------------------------------------------------------------------------------- /packages/email/src/test/TestMail.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | CallToAction, 3 | Code, 4 | Container, 5 | Content, 6 | Email, 7 | Footer, 8 | Header, 9 | Paragraph, 10 | Title, 11 | } from '../templates'; 12 | 13 | function TestMail({ 14 | code, 15 | siteName, 16 | siteUrl = 'https://rake.red', 17 | logoUrl = 'https://rake.red/rakered-black.png', 18 | }) { 19 | return ( 20 | 21 | 22 |
23 | 24 | 25 | 26 | Complete Registration 27 | 28 | 29 | Please copy this code to the verification page to verify your account. 30 | 31 | 32 | {code} 33 | 34 | 35 | Or if you're unable to use that code, click this button instead. 36 | 37 | 38 | Confirm your email 39 | 40 | 41 | If you did not create the account, please ignore this msg 42 | 43 | 44 | 45 |
46 | You have received this notification because 47 |
48 | you have signed up for {siteName} 49 |
50 | 51 | ); 52 | } 53 | 54 | export default TestMail; 55 | -------------------------------------------------------------------------------- /packages/email/src/test/smokeTest.ts: -------------------------------------------------------------------------------- 1 | import { addSmokeListener } from '../send'; 2 | 3 | async function smokeTest(fn) { 4 | let msg; 5 | 6 | addSmokeListener((email) => { 7 | msg = email; 8 | }); 9 | 10 | const result = await Promise.resolve(fn); 11 | return [msg, result]; 12 | } 13 | 14 | export default smokeTest; 15 | -------------------------------------------------------------------------------- /packages/email/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./lib" 6 | }, 7 | "include": ["./src", "./global.d.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/errors/README.md: -------------------------------------------------------------------------------- 1 | # @rakered/errors 2 | 3 | Convenient custom errors matching http status code 4 | 5 | ![social image](https://github.com/rakered/rakered/raw/main/packages/errors/docs/social.jpg) 6 | 7 | ## Usage 8 | 9 | ```js 10 | import { AuthenticationError } from '@rakered/errors'; 11 | 12 | throw new AuthenticationError('you need to be logged in'); 13 | // » { code: 401, message: 'you need to be logged in' } 14 | ``` 15 | -------------------------------------------------------------------------------- /packages/errors/docs/social.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smeijer/rakered/93ec75cb477e741cfce098f7f714562a93c6c286/packages/errors/docs/social.jpg -------------------------------------------------------------------------------- /packages/errors/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('../../jest.config'); 2 | -------------------------------------------------------------------------------- /packages/errors/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /packages/errors/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rakered/errors", 3 | "version": "1.1.0", 4 | "description": "Convenient custom errors matching http status code.", 5 | "keywords": [ 6 | "nodejs", 7 | "errors" 8 | ], 9 | "main": "./lib/index.js", 10 | "source": "./src/index.ts", 11 | "license": "AGPL-3.0 OR COMMERCIAL", 12 | "author": "Stephan Meijer ", 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/rakered/rakered.git" 16 | }, 17 | "scripts": { 18 | "build": "rimraf ./lib *.tsbuildinfo && tsc", 19 | "prepare": "npm run build", 20 | "test": "jest --coverage --runInBand ./src", 21 | "test:debug": "node --inspect-brk ./node_modules/jest/bin/jest --runInBand", 22 | "bump:patch": "npm version patch -m 'release(errors): cut the %s release'", 23 | "bump:minor": "npm version minor -m 'release(errors): cut the %s release'", 24 | "bump:major": "npm version major -m 'release(errors): cut the %s release'" 25 | }, 26 | "files": [ 27 | "lib" 28 | ], 29 | "dependencies": {}, 30 | "devDependencies": { 31 | "@types/jest": "^26.0.20", 32 | "jest": "^26.6.3", 33 | "rimraf": "^3.0.2", 34 | "ts-jest": "^26.4.4", 35 | "typescript": "^4.1.3" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/errors/src/errors.test.ts: -------------------------------------------------------------------------------- 1 | import * as errors from './errors'; 2 | import { UserInputError } from './errors'; 3 | 4 | test('errors return name and code', () => { 5 | for (const CustomError of Object.values(errors)) { 6 | const error = new CustomError('some error'); 7 | 8 | expect(error.message).toEqual('some error'); 9 | expect(typeof error.code).toEqual('number'); 10 | } 11 | }); 12 | 13 | test('UserInputError can hold data', () => { 14 | const error = new UserInputError('some error', { 15 | input: 'name', 16 | message: 'required', 17 | }); 18 | 19 | expect(error.data).toEqual({ 20 | input: 'name', 21 | message: 'required', 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /packages/errors/src/errors.ts: -------------------------------------------------------------------------------- 1 | export class AuthenticationError extends Error { 2 | name = 'AuthenticationError'; 3 | code = 401; 4 | } 5 | 6 | export class ForbiddenError extends Error { 7 | name = 'ForbiddenError'; 8 | code = 403; 9 | } 10 | 11 | export class UserInputError extends Error { 12 | name = 'UserInputError'; 13 | code = 422; 14 | data; 15 | 16 | constructor( 17 | message: string, 18 | data?: Record | Record[] | string[], 19 | ) { 20 | super(message); 21 | this.data = data; 22 | } 23 | } 24 | 25 | export class NotFoundError extends Error { 26 | name = 'NotFoundError'; 27 | code = 404; 28 | } 29 | -------------------------------------------------------------------------------- /packages/errors/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './errors'; 2 | -------------------------------------------------------------------------------- /packages/errors/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./lib" 6 | }, 7 | "include": ["./src/", "./global.d.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/forms/README.md: -------------------------------------------------------------------------------- 1 | # @rakered/forms 2 | 3 | Tiny helper to help with form submissions 4 | 5 | ![social image](https://github.com/rakered/rakered/raw/main/packages/forms/docs/social.jpg) 6 | 7 | ## Usage 8 | 9 | The most basic helper is `getFormData`, it takes either the `event` directly, or an HTML `form` element. What it returns, is a dictionary holding the values of the form. 10 | 11 | ```js 12 | import { getFormData } from '@rakered/forms'; 13 | 14 | const onSubmit = (event) => { 15 | const data = getFormData(event); 16 | // » { name: 'smeijer', password: { digest: 'd03…83e', algorithm: 'sha-256' } } 17 | }; 18 | 19 |
20 | 21 | 22 |
; 23 | ``` 24 | 25 | Because we often want to wrap submit handlers between `event.preventDefault()` and `return false`, there is a `handleSubmit` helper that does exactly that. 26 | 27 | ```js 28 | import { handleSubmit } from '@rakered/forms'; 29 | 30 | const onSubmit = handleSubmit((values) => { 31 | // » { name: 'smeijer' } 32 | }); 33 | 34 |
35 | 36 |
; 37 | ``` 38 | 39 | ### Path expansions 40 | 41 | Where applicable, input names will be expanded to object structures 42 | 43 | ```js 44 |
45 | 46 | 47 | 48 | 49 |
50 | ``` 51 | 52 | serializes to: 53 | 54 | ```json5 55 | { 56 | user: { 57 | name: 'Stephan Meijer', 58 | age: 34, 59 | }, 60 | hobbies: ['chess', 'art'], 61 | } 62 | ``` 63 | 64 | ### Type Coercion 65 | 66 | A number of specific input types, are coerced to the proper data type. 67 | 68 | - **password** _{ digest: String, algorithm: 'sha-256' }_ 69 | This one is important, so let's start with that. Passwords are hashed using [@rakered/hash][rakered/hash], so you won't be reading the password that the user entered. Please don't try to work arround this. Instead, embrace it. 70 | 71 | - **datetime-local** _Date_ 72 | The `datetime-local` input stores a full date, so the is converted to a proper Date. Other date-like fields, such as `date`, `time`, or `week` only support partial dates, and are left alone. 73 | 74 | - **checkbox** _Boolean_ 75 | 76 | - **number** _Number_ 77 | 78 | - **range** _Number_ 79 | 80 | ### Typescript 81 | 82 | Both methods are typed and accept a generic to make the `values` a typed object. Together with the typecoercion, this can simplify form handling a lot. 83 | 84 | ```tsx 85 | interface User { 86 | name: string; 87 | age: number; 88 | } 89 | 90 | const signup = handleSubmit((values) => { 91 | // » { name: 'smeijer', age: 34 } 92 | }); 93 | 94 |
95 | 96 | 97 |
; 98 | ``` 99 | 100 | [rakered/hash]: https://github.com/rakered/rakered/tree/main/packages/hash 101 | -------------------------------------------------------------------------------- /packages/forms/docs/social.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smeijer/rakered/93ec75cb477e741cfce098f7f714562a93c6c286/packages/forms/docs/social.jpg -------------------------------------------------------------------------------- /packages/forms/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require('../../jest.config'), 3 | testEnvironment: 'jsdom', 4 | setupFilesAfterEnv: ['./jest.warnings.js'], 5 | }; 6 | -------------------------------------------------------------------------------- /packages/forms/jest.warnings.js: -------------------------------------------------------------------------------- 1 | // jest.warnings.js 2 | global.originalLogError = global.console.error; 3 | 4 | global.console.error = (...args) => { 5 | /** 6 | * Avoid jsdom error message after submitting a form 7 | * https://github.com/jsdom/jsdom/issues/1937 8 | */ 9 | const errorMessage = 'Not implemented: HTMLFormElement.prototype.submit'; 10 | if (args && args[0].includes(errorMessage)) { 11 | return false; 12 | } 13 | 14 | global.originalLogError(...args); 15 | 16 | return true; 17 | }; 18 | -------------------------------------------------------------------------------- /packages/forms/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /packages/forms/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rakered/forms", 3 | "version": "1.5.1", 4 | "description": "Tiny lib to deal with form submissions and structured FormData.", 5 | "keywords": [ 6 | "forms", "browser" 7 | ], 8 | "main": "./lib/index.js", 9 | "source": "./src/index.ts", 10 | "license": "AGPL-3.0 OR COMMERCIAL", 11 | "author": "Stephan Meijer ", 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/rakered/rakered.git" 15 | }, 16 | "scripts": { 17 | "build": "rimraf ./lib *.tsbuildinfo && tsc", 18 | "prepare": "npm run build", 19 | "test": "jest --coverage --runInBand ./src", 20 | "test:debug": "node --inspect-brk ./node_modules/jest/bin/jest --runInBand", 21 | "bump:patch": "npm version patch -m 'release(forms): cut the %s release'", 22 | "bump:minor": "npm version minor -m 'release(forms): cut the %s release'", 23 | "bump:major": "npm version major -m 'release(forms): cut the %s release'" 24 | }, 25 | "files": [ 26 | "lib" 27 | ], 28 | "dependencies": { 29 | "@rakered/hash": "^1.0.2" 30 | }, 31 | "devDependencies": { 32 | "@types/jest": "^26.0.20", 33 | "jest": "^26.6.3", 34 | "rimraf": "^3.0.2", 35 | "ts-jest": "^26.4.4", 36 | "typescript": "^4.1.3" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/forms/src/getFormData.test.tsx: -------------------------------------------------------------------------------- 1 | import { getFormData, Event } from './getFormData'; 2 | 3 | async function submitForm(form): Promise { 4 | const div = document.createElement('div'); 5 | div.innerHTML = ` 6 |
7 | ${form} 8 | 9 |
`; 10 | 11 | return new Promise((resolve) => { 12 | const form = div.querySelector('form'); 13 | form?.addEventListener('submit', (event) => { 14 | resolve({ 15 | preventDefault: event.preventDefault, 16 | currentTarget: event.target, 17 | }); 18 | }); 19 | 20 | form?.submit(); 21 | }); 22 | } 23 | 24 | test('correctly expands flattened names to object structures', async () => { 25 | const event = await submitForm(` 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | `); 38 | 39 | const data = getFormData(event); 40 | expect(data).toEqual({ 41 | first: 'first value', 42 | second: 'second value', 43 | obj: { 44 | first: 'first nested value', 45 | second: 'second nested value', 46 | }, 47 | arr: ['first', 'second'], 48 | idx: ['zero', 'one'], 49 | }); 50 | }); 51 | 52 | test('checkboxes use value when provided', async () => { 53 | const event = await submitForm(` 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | `); 62 | 63 | const data = getFormData(event); 64 | expect(data).toEqual({ 65 | empty: false, 66 | checked: true, 67 | valued: 'with-value', 68 | arr: ['red', 'blue'], 69 | }); 70 | }); 71 | 72 | test('coerce types', async () => { 73 | const event = await submitForm(` 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | `); 84 | 85 | const data = getFormData(event); 86 | 87 | expect(data.date).toBeInstanceOf(Date); 88 | expect(data.checkbox).toEqual(true); 89 | expect(data.number).toEqual(3); 90 | expect(data.emptyNumber).toEqual(undefined); 91 | expect(data.password).toEqual({ 92 | digest: 'f52fbd32b2b3b86ff88ef6c490628285f482af15ddcb29541f94bcf526a3f6c7', 93 | algorithm: 'sha-256', 94 | }); 95 | expect(data.radio).toEqual('on'); 96 | expect(data.text).toEqual('string'); 97 | }); 98 | -------------------------------------------------------------------------------- /packages/forms/src/getFormData.ts: -------------------------------------------------------------------------------- 1 | import hash, { HashResult } from '@rakered/hash'; 2 | 3 | const parsers = { 4 | 'datetime-local': (el) => (el.value === '' ? undefined : new Date(el.value)), 5 | checkbox: (el) => 6 | Boolean(el.checked) && el.value !== 'on' ? el.value : Boolean(el.checked), 7 | number: (el) => (el.value === '' ? undefined : Number(el.value)), 8 | password: (el) => (el.value === '' ? undefined : hash(el.value)), 9 | radio: (el) => (el.checked ? el.value : undefined), 10 | range: (el) => (el.value === '' ? undefined : Number(el.value)), 11 | text: (el) => el.value.trim(), 12 | }; 13 | 14 | // rename the type, as that makes more sense in this context 15 | export type HashedPassword = HashResult; 16 | 17 | export interface Event { 18 | readonly currentTarget: EventTarget | null; 19 | preventDefault(): void; 20 | } 21 | 22 | export type DataType = 23 | | string 24 | | number 25 | | boolean 26 | | Date 27 | | HashedPassword 28 | | FormData; 29 | 30 | export interface FormData { 31 | [key: string]: DataType | DataType[]; 32 | } 33 | 34 | export function getFormData( 35 | target: HTMLFormElement | Event, 36 | ): T { 37 | const form: HTMLFormElement = 38 | 'currentTarget' in target ? target.currentTarget : target; 39 | 40 | const elements: any = form.elements; 41 | const data: any = {}; 42 | 43 | for (const element of elements) { 44 | if (!element.name) { 45 | continue; 46 | } 47 | 48 | const parse = parsers[element.type] || parsers.text; 49 | const value = parse(element) ?? undefined; 50 | 51 | if (typeof value === 'undefined') { 52 | continue; 53 | } 54 | 55 | // a[b][c] becomes [ a, b, c ] 56 | const path = element.name.replace(/\[([^\]]+)?\]/g, '.$1').split('.'); 57 | let pointer = data; 58 | 59 | // walk the path, and create objects and arrays where required 60 | for (let i = 0; i < path.length - 1; i++) { 61 | // empty strings and numeric values, indicate arrays 62 | pointer[path[i]] = 63 | pointer[path[i]] || (/^$|^[0-9]*$/.test(path[i + 1]) ? [] : {}); 64 | 65 | pointer = pointer[path[i]]; 66 | } 67 | 68 | if (Array.isArray(pointer)) { 69 | // don't push checkboxes without values into array 70 | if (typeof value === 'boolean') { 71 | continue; 72 | } 73 | 74 | pointer.push(value); 75 | } else { 76 | pointer[path[path.length - 1]] = value; 77 | } 78 | } 79 | 80 | return data as T; 81 | } 82 | -------------------------------------------------------------------------------- /packages/forms/src/handleChange.ts: -------------------------------------------------------------------------------- 1 | import { getFormData, Event, FormData } from './getFormData'; 2 | 3 | export function handleChange(fn: (values: T) => any) { 4 | return function change(event: Event) { 5 | const values = getFormData(event); 6 | return fn(values); 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /packages/forms/src/handleSubmit.ts: -------------------------------------------------------------------------------- 1 | import { getFormData, Event, FormData } from './getFormData'; 2 | 3 | export function handleSubmit(fn: (values: T) => any) { 4 | return function submit(event: Event) { 5 | event.preventDefault(); 6 | 7 | const values = getFormData(event); 8 | return fn(values) ?? false; 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /packages/forms/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './getFormData'; 2 | export * from './handleSubmit'; 3 | export * from './handleChange'; 4 | -------------------------------------------------------------------------------- /packages/forms/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./lib", 6 | "lib": ["es2019", "DOM"] 7 | }, 8 | "include": ["./src/", "./global.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/hash/README.md: -------------------------------------------------------------------------------- 1 | # @rakered/hash 2 | 3 | A tiny, zero dependency, SHA256 hashing algorithm that can run in both the browser and in node. 4 | 5 | ![social image](https://github.com/rakered/rakered/raw/main/packages/hash/docs/social.jpg) 6 | 7 | ## Usage: 8 | 9 | ```js 10 | import hash from '@rakered/hash'; 11 | 12 | const hashedPassword = hash('hunter2'); 13 | // » { algorithm: 'sha-256', digest: 'f52f…f6c7' } 14 | ``` 15 | 16 | ## Alternatives 17 | 18 | If you're looking for something that only needs to run on nodejs, you might want to use the native `crypto` module instead. 19 | 20 | ```js 21 | import crypto from 'crypto'; 22 | 23 | function nodeHash(data) { 24 | return crypto.createHash('sha256').update(data).digest('hex'); 25 | } 26 | 27 | nodeHash('hunter2'); 28 | // » f52f…f6c7 29 | ``` 30 | 31 | If you're looking for something that only needs to run on modern browsers, you might want to use the native `crypto` module instead. 32 | 33 | ```js 34 | async function browserHash(data) { 35 | const encoded = new TextEncoder().encode(data); 36 | const buffer = await crypto.subtle.digest('SHA-256', encoded); 37 | const array = Array.from(new Uint8Array(buffer)); 38 | return array.map((b) => ('00' + b.toString(16)).slice(-2)).join(''); 39 | } 40 | 41 | await browserHash('hunter2'); 42 | // » f52f…f6c7 43 | ``` 44 | -------------------------------------------------------------------------------- /packages/hash/docs/social.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smeijer/rakered/93ec75cb477e741cfce098f7f714562a93c6c286/packages/hash/docs/social.jpg -------------------------------------------------------------------------------- /packages/hash/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('../../jest.config'); 2 | -------------------------------------------------------------------------------- /packages/hash/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /packages/hash/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rakered/hash", 3 | "version": "1.0.2", 4 | "description": "A tiny, zero dependency, isomorphic SHA256 hashing algorithm.", 5 | "keywords": [ 6 | "hash", 7 | "browser", 8 | "nodejs" 9 | ], 10 | "main": "./lib/index.js", 11 | "source": "./src/index.ts", 12 | "license": "AGPL-3.0 OR COMMERCIAL", 13 | "author": "Stephan Meijer ", 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/rakered/rakered.git" 17 | }, 18 | "scripts": { 19 | "build": "rimraf ./lib *.tsbuildinfo && tsc", 20 | "prepare": "npm run build", 21 | "test": "jest --coverage --runInBand ./src", 22 | "test:debug": "node --inspect-brk ./node_modules/jest/bin/jest --runInBand", 23 | "bump:patch": "npm version patch -m 'release(hash): cut the %s release'", 24 | "bump:minor": "npm version minor -m 'release(hash): cut the %s release'", 25 | "bump:major": "npm version major -m 'release(hash): cut the %s release'" 26 | }, 27 | "files": [ 28 | "lib" 29 | ], 30 | "dependencies": {}, 31 | "devDependencies": { 32 | "@types/jest": "^26.0.20", 33 | "jest": "^26.6.3", 34 | "rimraf": "^3.0.2", 35 | "ts-jest": "^26.4.4", 36 | "typescript": "^4.1.3" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/hash/src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { hash } from './index'; 2 | 3 | test('hash returns a type of HashResult', () => { 4 | expect(hash('hunter2')).toEqual({ 5 | algorithm: 'sha-256', 6 | digest: 'f52fbd32b2b3b86ff88ef6c490628285f482af15ddcb29541f94bcf526a3f6c7', 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /packages/hash/src/index.ts: -------------------------------------------------------------------------------- 1 | import SHA256 from './sha256'; 2 | 3 | export interface HashResult { 4 | digest: string; 5 | algorithm: 'sha-256'; 6 | } 7 | 8 | export function hash(string: string): HashResult { 9 | return { 10 | digest: SHA256(string), 11 | algorithm: 'sha-256', 12 | }; 13 | } 14 | 15 | export default hash; 16 | -------------------------------------------------------------------------------- /packages/hash/src/sha256.test.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | import hash from './sha256'; 3 | 4 | function cryptoHash(data) { 5 | return crypto.createHash('sha256').update(data).digest('hex'); 6 | } 7 | 8 | test('can hash string', () => { 9 | expect(hash('hunter2')).toEqual( 10 | 'f52fbd32b2b3b86ff88ef6c490628285f482af15ddcb29541f94bcf526a3f6c7', 11 | ); 12 | }); 13 | 14 | test('hash function has same output as node crypto', () => { 15 | const data = 'hunter2'; 16 | expect(hash(data)).toEqual(cryptoHash(data)); 17 | }); 18 | 19 | test('can hash extended range unicode characters', () => { 20 | const data = 'ØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿ'; 21 | expect(hash(data)).toEqual(cryptoHash(data)); 22 | }); 23 | 24 | test('can hash higher unicode characters', () => { 25 | const data = 'ઓઔકખગઘઙચછજઝઞટઠડઢણતથદધન઩પફબભમય'; 26 | expect(hash(data)).toEqual(cryptoHash(data)); 27 | }); 28 | -------------------------------------------------------------------------------- /packages/hash/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./lib" 6 | }, 7 | "include": ["./src/", "./global.d.ts"] 8 | } -------------------------------------------------------------------------------- /packages/mongo/docs/social.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smeijer/rakered/93ec75cb477e741cfce098f7f714562a93c6c286/packages/mongo/docs/social.jpg -------------------------------------------------------------------------------- /packages/mongo/global.d.ts: -------------------------------------------------------------------------------- 1 | import 'jest-partial'; 2 | -------------------------------------------------------------------------------- /packages/mongo/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('../../jest.config'); 2 | -------------------------------------------------------------------------------- /packages/mongo/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /packages/mongo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rakered/mongo", 3 | "version": "1.6.0", 4 | "description": "A tiny but elegant wrapper around the native mongodb driver for Node.js, removing boilerplate and fixing id generation.", 5 | "keywords": [ 6 | "nodejs", 7 | "mongodb" 8 | ], 9 | "main": "./lib/index.js", 10 | "types": "./lib/index.d.ts", 11 | "license": "AGPL-3.0 OR COMMERCIAL", 12 | "author": "Stephan Meijer ", 13 | "private": false, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/rakered/rakered.git" 17 | }, 18 | "scripts": { 19 | "build": "npm run clean && tsc", 20 | "clean": "rimraf \"./lib\" \"*.tsbuildinfo\"", 21 | "prepare": "npm run build", 22 | "test": "jest --coverage --runInBand ./src", 23 | "test:debug": "node --inspect-brk ./node_modules/jest/bin/jest --runInBand", 24 | "bump:patch": "npm version patch -m 'release(mongo): cut the %s release'", 25 | "bump:minor": "npm version minor -m 'release(mongo): cut the %s release'", 26 | "bump:major": "npm version major -m 'release(mongo): cut the %s release'" 27 | }, 28 | "files": [ 29 | "lib" 30 | ], 31 | "dependencies": { 32 | "@rakered/errors": "^1.0.0", 33 | "mongodb": "^3.6.8", 34 | "picoid": "^1.1.2", 35 | "saslprep": "^1.0.3" 36 | }, 37 | "devDependencies": { 38 | "@types/jest": "^26.0.20", 39 | "@types/mongodb": "^3.6.16", 40 | "@types/node": "^14.14.21", 41 | "jest": "^26.6.3", 42 | "jest-partial": "^1.0.1", 43 | "rimraf": "^3.0.2", 44 | "ts-jest": "^26.5.6", 45 | "typescript": "^4.1.3" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/mongo/src/connection/cursor.ts: -------------------------------------------------------------------------------- 1 | export function getCursor(record: Record, sortField): string { 2 | return Buffer.from([record[sortField], record._id].join(':')).toString( 3 | 'base64', 4 | ); 5 | } 6 | 7 | export function parseCursor(cursor): any[] { 8 | return Buffer.from(cursor, 'base64').toString('ascii').split(':'); 9 | } 10 | -------------------------------------------------------------------------------- /packages/mongo/src/connection/validatePaginationArgs.test.ts: -------------------------------------------------------------------------------- 1 | import { validatePaginationArgs } from './validatePaginationArgs'; 2 | 3 | test('throws when passed without object arg', () => { 4 | expect(() => validatePaginationArgs(null)).toThrowError( 5 | 'You must provide arguments to properly paginate the connection.', 6 | ); 7 | }); 8 | 9 | test('requires either first or last option', () => { 10 | expect(() => validatePaginationArgs({})).toThrowError( 11 | 'You must provide a `first` or `last` value to properly paginate the connection.', 12 | ); 13 | expect(() => validatePaginationArgs({ first: 1, last: 1 })).toThrowError( 14 | 'Passing both `first` and `last` to paginate the connection is not supported.', 15 | ); 16 | }); 17 | 18 | test('first and last option cannot be negative', () => { 19 | expect(() => validatePaginationArgs({ first: -1 })).toThrowError( 20 | 'First should be non negative.', 21 | ); 22 | expect(() => validatePaginationArgs({ last: -1 })).toThrowError( 23 | 'Last should be non negative.', 24 | ); 25 | }); 26 | 27 | test('when order is provided, it should contain field and valid direction', () => { 28 | expect(() => validatePaginationArgs({ first: 1, order: [] })).toThrowError( 29 | 'You must provide an `order` to properly paginate the connection.', 30 | ); 31 | 32 | expect(() => 33 | validatePaginationArgs({ first: 1, order: ['field'] }), 34 | ).toThrowError( 35 | 'You must provide an `order` to properly paginate the connection.', 36 | ); 37 | 38 | expect(() => 39 | validatePaginationArgs({ first: 1, order: ['field', 'up'] }), 40 | ).toThrowError( 41 | 'You must provide an `order` to properly paginate the connection.', 42 | ); 43 | 44 | expect(() => 45 | validatePaginationArgs({ first: 1, order: ['field', 'asc'] }), 46 | ).not.toThrow(); 47 | 48 | expect(() => 49 | validatePaginationArgs({ first: 1, order: ['field', 'desc'] }), 50 | ).not.toThrow(); 51 | }); 52 | -------------------------------------------------------------------------------- /packages/mongo/src/connection/validatePaginationArgs.ts: -------------------------------------------------------------------------------- 1 | import { UserInputError } from '@rakered/errors'; 2 | 3 | export function validatePaginationArgs(params) { 4 | if (!params || typeof params !== 'object') { 5 | throw new UserInputError( 6 | 'You must provide arguments to properly paginate the connection.', 7 | ); 8 | } 9 | 10 | if (!params.first && !params.last) { 11 | throw new UserInputError( 12 | 'You must provide a `first` or `last` value to properly paginate the connection.', 13 | ); 14 | } 15 | 16 | if (params.first && params.last) { 17 | throw new UserInputError( 18 | 'Passing both `first` and `last` to paginate the connection is not supported.', 19 | ); 20 | } 21 | 22 | if (params.last && params.last < 0) { 23 | throw new UserInputError('Last should be non negative.'); 24 | } 25 | 26 | if (params.first && params.first < 0) { 27 | throw new UserInputError('First should be non negative.'); 28 | } 29 | 30 | const [field, direction] = params.order || []; 31 | if ( 32 | params.order && 33 | (!field || (direction !== 'asc' && direction !== 'desc')) 34 | ) { 35 | throw new UserInputError( 36 | 'You must provide an `order` to properly paginate the connection.', 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/mongo/src/index.ts: -------------------------------------------------------------------------------- 1 | import { create } from './db'; 2 | 3 | export * from './db'; 4 | export * from './collection'; 5 | export type { 6 | ConnectionOptions, 7 | PaginationArgs, 8 | Connection, 9 | } from './connection/connection'; 10 | 11 | const db = create(); 12 | export default db; 13 | -------------------------------------------------------------------------------- /packages/mongo/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './isDuplicateKeyError'; 2 | -------------------------------------------------------------------------------- /packages/mongo/src/utils/isDuplicateKeyError.ts: -------------------------------------------------------------------------------- 1 | export function isDuplicateKeyError(error: any, key: string): boolean { 2 | const isDuplicate = error?.name === 'MongoError' && error?.code === 11000; 3 | return isDuplicate && !!error.keyPattern[key]; 4 | } 5 | -------------------------------------------------------------------------------- /packages/mongo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./lib" 6 | }, 7 | "include": ["./src/", "./global.d.ts"] 8 | } -------------------------------------------------------------------------------- /packages/nextjs-auth-api/README.md: -------------------------------------------------------------------------------- 1 | # @rakered/nextjs-auth-api 2 | 3 | Next.js SDK for using user authentication persisted in MongoDB 4 | 5 | ![social image](https://github.com/rakered/rakered/raw/main/packages/nextjs-auth-api/docs/social.jpg) 6 | 7 | ## Usage 8 | 9 | Create a Dynamic API Route handler at `/pages/api/auth/[...slug].js` 10 | 11 | ```js 12 | import { handleAuth } from '@rakered/nextjs-auth-api'; 13 | 14 | export default handleAuth(); 15 | ``` 16 | 17 | ## Getting Started 18 | 19 | ### Environment Variables 20 | 21 | The library needs the following required configuration keys. These can be configured in a .env.local file in the root of your application (See more info about [loading environmental variables in Next.js](https://nextjs.org/docs/basic-features/environment-variables)): 22 | 23 | - **MAIL_URL** _String_ 24 | 25 | The smtp url for the mail server to use. 26 | 27 | Optional when running in development mode 28 | 29 | - **JWT_SECRET** _String_ 30 | 31 | The secret to sign the jwt tokens with. 32 | 33 | - **EMAIL_FROM** _String_ 34 | 35 | The email address that's being used as sender. 36 | 37 | Optional if `options.email.from` is provided 38 | 39 | - **BASE_URL** _String_ 40 | 41 | The url that will be prefixed to magic urls and provided to the email template. 42 | 43 | Optional if `options.email.siteUrl` is provided 44 | 45 | - **SITE_NAME** _String_ 46 | 47 | The site name that will be provided to the email template. 48 | 49 | Optional if `options.email.siteName` is provided 50 | 51 | - **LOGO_URL** _String_ 52 | 53 | The url for the logo that will be shown in the email. 54 | 55 | Optional if `options.email.logoUrl` is provided 56 | 57 | ### API Route 58 | 59 | Create a Dynamic API Route handler at `/pages/api/auth/[...slug].js` 60 | 61 | ```js 62 | import { handleAuth } from '@rakered/nextjs-auth-api'; 63 | 64 | export default handleAuth(); 65 | ``` 66 | 67 | This will create the following urls: 68 | 69 | ```shell 70 | /api/auth/create-account 71 | /api/auth/enroll-account 72 | /api/auth/login 73 | /api/auth/logout 74 | /api/auth/refresh-token 75 | /api/auth/reset-password 76 | /api/auth/verify-email 77 | ``` 78 | -------------------------------------------------------------------------------- /packages/nextjs-auth-api/docs/social.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smeijer/rakered/93ec75cb477e741cfce098f7f714562a93c6c286/packages/nextjs-auth-api/docs/social.jpg -------------------------------------------------------------------------------- /packages/nextjs-auth-api/global.d.ts: -------------------------------------------------------------------------------- 1 | import 'jest-partial'; 2 | -------------------------------------------------------------------------------- /packages/nextjs-auth-api/jest.config.js: -------------------------------------------------------------------------------- 1 | const common = require('../../jest.config'); 2 | module.exports = { 3 | ...common, 4 | setupFilesAfterEnv: [...common.setupFilesAfterEnv, './jest.setup.js'], 5 | }; 6 | -------------------------------------------------------------------------------- /packages/nextjs-auth-api/jest.setup.js: -------------------------------------------------------------------------------- 1 | process.env.JWT_SECRET = 'hunter2'; 2 | process.env.EMAIL_FROM = 'noreply@example.com'; 3 | process.env.BASE_URL = 'https://example.com'; 4 | -------------------------------------------------------------------------------- /packages/nextjs-auth-api/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /packages/nextjs-auth-api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rakered/nextjs-auth-api", 3 | "version": "1.0.2", 4 | "description": "API sdk for using @rakered/accounts with Next.js. Self hosted accounts with a breeze.", 5 | "keywords": [ 6 | "nodejs", 7 | "auth", 8 | "nextjs" 9 | ], 10 | "main": "./lib/index.js", 11 | "source": "./src/index.ts", 12 | "license": "AGPL-3.0 OR COMMERCIAL", 13 | "author": "Stephan Meijer ", 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/rakered/rakered.git" 17 | }, 18 | "scripts": { 19 | "build": "rimraf ./lib *.tsbuildinfo && tsc", 20 | "prepare": "npm run build", 21 | "test": "jest --coverage --runInBand ./src", 22 | "test:debug": "node --inspect-brk ./node_modules/jest/bin/jest --runInBand", 23 | "bump:patch": "npm version patch -m 'release(errors): cut the %s release'", 24 | "bump:minor": "npm version minor -m 'release(errors): cut the %s release'", 25 | "bump:major": "npm version major -m 'release(errors): cut the %s release'" 26 | }, 27 | "files": [ 28 | "lib" 29 | ], 30 | "dependencies": { 31 | "@rakered/accounts": "^1.6.0", 32 | "cookie": "^0.4.1" 33 | }, 34 | "devDependencies": { 35 | "@types/jest": "^26.0.20", 36 | "@types/react": "^17.0.1", 37 | "@types/react-dom": "^17.0.1", 38 | "fetch-cookie": "^0.11.0", 39 | "jest": "^26.6.3", 40 | "jest-partial": "^1.0.1", 41 | "next": "^10.0.6", 42 | "rimraf": "^3.0.2", 43 | "test-listen": "^1.1.0", 44 | "ts-jest": "^26.4.4", 45 | "typescript": "^4.1.3" 46 | }, 47 | "peerDependencies": { 48 | "next": "^10.0.6" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/nextjs-auth-api/src/handlers/create-account.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | import { Context } from '../auth'; 3 | import { createUserResult, UserResult } from '../utils'; 4 | import { setTokenCookies } from '../session/cookies'; 5 | 6 | /** 7 | * Signup 8 | * 9 | * @param {CreateUserDocument|InviteUserDocument} req.body 10 | */ 11 | export async function handleCreateAccount( 12 | req: NextApiRequest, 13 | res: NextApiResponse, 14 | ctx: Context, 15 | ): Promise { 16 | const tokens = await ctx.accounts.createUser(req.body); 17 | if ('accessToken' in tokens) { 18 | setTokenCookies(res, tokens); 19 | } 20 | 21 | if ('password' in req.body) { 22 | await ctx.accounts.sendVerificationEmail({ email: tokens.user.email }); 23 | } else { 24 | await ctx.accounts.sendEnrollmentEmail({ email: tokens.user.email }); 25 | } 26 | 27 | res.status(200).json(tokens); 28 | 29 | return createUserResult(tokens); 30 | } 31 | -------------------------------------------------------------------------------- /packages/nextjs-auth-api/src/handlers/enroll-account.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | import { setTokenCookies } from '../session/cookies'; 3 | import { createUserResult, UserResult } from '../utils'; 4 | import { Context } from '../auth'; 5 | 6 | /** 7 | * Enroll 8 | * 9 | * @param {EnrollUserDocument} req.body The account data to enroll 10 | */ 11 | export async function handleEnrollAccount( 12 | req: NextApiRequest, 13 | res: NextApiResponse, 14 | ctx: Context, 15 | ): Promise { 16 | const tokens = await ctx.accounts.enrollUser(req.body); 17 | await ctx.accounts.sendEnrollmentEmail(tokens.user); 18 | 19 | setTokenCookies(res, tokens); 20 | res.status(200).json(tokens); 21 | 22 | return createUserResult(tokens); 23 | } 24 | -------------------------------------------------------------------------------- /packages/nextjs-auth-api/src/handlers/login.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | import { Context } from '../auth'; 3 | import { createUserResult, UserResult } from '../utils'; 4 | import { setTokenCookies } from '../session/cookies'; 5 | 6 | /** 7 | * Login 8 | * 9 | * @param {LoginDocument} req.body The user credentials 10 | */ 11 | export async function handleLogin( 12 | req: NextApiRequest, 13 | res: NextApiResponse, 14 | ctx: Context, 15 | ): Promise { 16 | const tokens = await ctx.accounts.login(req.body); 17 | setTokenCookies(res, tokens); 18 | res.status(200).json(tokens); 19 | 20 | return createUserResult(tokens); 21 | } 22 | -------------------------------------------------------------------------------- /packages/nextjs-auth-api/src/handlers/logout.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | import { setTokenCookies } from '../session/cookies'; 3 | import { Context } from '../auth'; 4 | 5 | /** 6 | * Logout 7 | * 8 | * @param {string} req.cookies.accessToken The access token of the user 9 | * @param {string} req.cookies.refreshToken The refresh token that should be invalidated 10 | */ 11 | export async function handleLogout( 12 | req: NextApiRequest, 13 | res: NextApiResponse, 14 | ctx: Context, 15 | ): Promise { 16 | const { accessToken, refreshToken } = req.cookies; 17 | 18 | if (accessToken && refreshToken) { 19 | await ctx.accounts.revokeToken({ accessToken, refreshToken }); 20 | } 21 | 22 | setTokenCookies(res, { 23 | accessToken: null, 24 | refreshToken: null, 25 | }); 26 | 27 | res.status(200).json({ ok: true }); 28 | } 29 | -------------------------------------------------------------------------------- /packages/nextjs-auth-api/src/handlers/refresh-token.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | import { setTokenCookies } from '../session/cookies'; 3 | import { Context } from '../auth'; 4 | 5 | /** 6 | * Refresh JWT tokens 7 | * 8 | * @param {string} req.cookies.accessToken The access token of the user 9 | * @param {string} req.cookies.refreshToken The refresh token of the user 10 | */ 11 | export async function handleTokenRefresh( 12 | req: NextApiRequest, 13 | res: NextApiResponse, 14 | ctx: Context, 15 | ): Promise { 16 | const { accessToken, refreshToken } = req.cookies; 17 | 18 | const tokens = await ctx.accounts.refreshToken({ accessToken, refreshToken }); 19 | setTokenCookies(res, tokens); 20 | 21 | res.status(200).json(tokens); 22 | } 23 | -------------------------------------------------------------------------------- /packages/nextjs-auth-api/src/handlers/reset-password.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | import { setTokenCookies } from '../session/cookies'; 3 | import { Context } from '../auth'; 4 | 5 | /** 6 | * Request an email with token to reset password 7 | * 8 | * @param {EmailDoc} req.body The email address to send a password reset mail to 9 | */ 10 | export async function handlePasswordResetRequest( 11 | req: NextApiRequest, 12 | res: NextApiResponse, 13 | ctx: Context, 14 | ): Promise { 15 | await ctx.accounts.sendResetPasswordEmail(req.body); 16 | res.status(200).json({ ok: true }); 17 | } 18 | 19 | /** 20 | * Reset password 21 | * 22 | * @param {ResetPasswordDocument} req.body The token and new password to reset 23 | */ 24 | export async function handlePasswordReset( 25 | req: NextApiRequest, 26 | res: NextApiResponse, 27 | ctx: Context, 28 | ): Promise { 29 | const tokens = await ctx.accounts.resetPassword(req.body); 30 | setTokenCookies(res, tokens); 31 | res.status(200).json(tokens); 32 | } 33 | -------------------------------------------------------------------------------- /packages/nextjs-auth-api/src/handlers/verify-email.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | import { setTokenCookies } from '../session/cookies'; 3 | import { Context } from '../auth'; 4 | 5 | /** 6 | * Request verification email 7 | * 8 | * @param {EmailDoc} req.body The email to send an verification mail to 9 | */ 10 | export async function handleEmailVerificationRequest( 11 | req: NextApiRequest, 12 | res: NextApiResponse, 13 | ctx: Context, 14 | ): Promise { 15 | await ctx.accounts.sendVerificationEmail(req.body); 16 | res.status(200).json({ ok: true }); 17 | } 18 | 19 | /** 20 | * Verify the email 21 | * 22 | * @param {VerifyEmailDocument} req.body The token for the email to verify 23 | */ 24 | export async function handleEmailVerification( 25 | req: NextApiRequest, 26 | res: NextApiResponse, 27 | ctx: Context, 28 | ): Promise { 29 | const tokens = await ctx.accounts.verifyEmail(req.body); 30 | setTokenCookies(res, tokens); 31 | res.status(200).json(tokens); 32 | } 33 | -------------------------------------------------------------------------------- /packages/nextjs-auth-api/src/index.ts: -------------------------------------------------------------------------------- 1 | export type { Handlers } from './auth'; 2 | export { handleAuth } from './auth'; 3 | export { handleEnrollAccount } from './handlers/enroll-account'; 4 | export { handleLogin } from './handlers/login'; 5 | export { handleLogout } from './handlers/logout'; 6 | export { handleTokenRefresh } from './handlers/refresh-token'; 7 | export { handleCreateAccount } from './handlers/create-account'; 8 | export { 9 | handleEmailVerificationRequest, 10 | handleEmailVerification, 11 | } from './handlers/verify-email'; 12 | -------------------------------------------------------------------------------- /packages/nextjs-auth-api/src/session/cookies.ts: -------------------------------------------------------------------------------- 1 | import { CookieSerializeOptions, parse, serialize } from 'cookie'; 2 | import { IncomingMessage, ServerResponse } from 'http'; 3 | import { 4 | ACCESS_TOKEN_EXPIRY_SECONDS, 5 | REFRESH_TOKEN_EXPIRY_SECONDS, 6 | } from '@rakered/accounts/lib/lib/constants'; 7 | 8 | const isSecureUrl = ( 9 | process.env.RAKERED_BASE_URL || process.env.BASE_URL 10 | )?.startsWith('https'); 11 | 12 | export const getAllCookies = ( 13 | req: IncomingMessage, 14 | ): { [key: string]: string } => { 15 | return parse(req.headers.cookie || ''); 16 | }; 17 | 18 | export const getCookie = (req: IncomingMessage, name: string): string => { 19 | const cookies = getAllCookies(req); 20 | return cookies[name]; 21 | }; 22 | 23 | export const setCookie = ( 24 | res: ServerResponse, 25 | name: string, 26 | value: string, 27 | options: CookieSerializeOptions = {}, 28 | ): void => { 29 | const strCookie = serialize(name, value, options); 30 | 31 | let previousCookies = res.getHeader('Set-Cookie') || []; 32 | if (!Array.isArray(previousCookies)) { 33 | previousCookies = [previousCookies as string]; 34 | } 35 | 36 | res.setHeader('Set-Cookie', [...previousCookies, strCookie]); 37 | }; 38 | 39 | export const clearCookie = ( 40 | res: ServerResponse, 41 | name: string, 42 | options: CookieSerializeOptions = {}, 43 | ): void => { 44 | setCookie(res, name, '', { ...options, maxAge: 0 }); 45 | }; 46 | 47 | export function setTokenCookies( 48 | res: ServerResponse, 49 | { 50 | accessToken, 51 | refreshToken, 52 | }: { accessToken: string | null; refreshToken: string | null }, 53 | ): void { 54 | const options: Partial = { 55 | httpOnly: true, 56 | secure: isSecureUrl, 57 | sameSite: 'lax', 58 | }; 59 | 60 | // add a cookie handler to the response object 61 | setCookie(res, 'accessToken', accessToken || '', { 62 | ...options, 63 | path: '/', 64 | maxAge: accessToken ? ACCESS_TOKEN_EXPIRY_SECONDS : 0, 65 | }); 66 | 67 | ['/api/auth/logout', '/api/auth/refresh-token'].map((path) => { 68 | setCookie(res, 'refreshToken', refreshToken || '', { 69 | ...options, 70 | path, 71 | maxAge: refreshToken ? REFRESH_TOKEN_EXPIRY_SECONDS : 0, 72 | }); 73 | }); 74 | } 75 | -------------------------------------------------------------------------------- /packages/nextjs-auth-api/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { AuthTokenResult } from '@rakered/accounts'; 2 | import { InviteUserResult } from '@rakered/accounts/lib/types'; 3 | 4 | export function createUserResult( 5 | tokens: AuthTokenResult | InviteUserResult, 6 | ): UserResult { 7 | return { user: { _id: tokens.user._id, email: tokens.user.email } }; 8 | } 9 | 10 | export type UserResult = { 11 | user: Pick; 12 | }; 13 | -------------------------------------------------------------------------------- /packages/nextjs-auth-api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./lib" 6 | }, 7 | "include": ["./src/", "./global.d.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/nextjs-auth-ui/README.md: -------------------------------------------------------------------------------- 1 | # @rakered/nextjs-auth-ui 2 | 3 | Next.js SDK for using user authentication persisted in MongoDB 4 | 5 | ![social image](https://github.com/rakered/rakered/raw/main/packages/nextjs-auth-ui/docs/social.jpg) 6 | 7 | ## Usage 8 | 9 | Create a Dynamic Page Route handler at `/pages/auth/[...slug].js` 10 | 11 | ```js 12 | import '@rakered/nextjs-auth-ui/style.css'; 13 | import { handleAuth } from '@rakered/nextjs-auth-ui'; 14 | 15 | export default handleAuth(); 16 | ``` 17 | 18 | This will create the following urls: 19 | 20 | ```shell 21 | /auth/signup 22 | /auth/login 23 | /auth/logout 24 | /auth/forgot-password 25 | /auth/enroll-account/{token} 26 | /auth/reset-password/{token} 27 | /auth/verify-email/{token} 28 | ``` 29 | -------------------------------------------------------------------------------- /packages/nextjs-auth-ui/docs/social.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smeijer/rakered/93ec75cb477e741cfce098f7f714562a93c6c286/packages/nextjs-auth-ui/docs/social.jpg -------------------------------------------------------------------------------- /packages/nextjs-auth-ui/global.d.ts: -------------------------------------------------------------------------------- 1 | import 'jest-partial'; 2 | -------------------------------------------------------------------------------- /packages/nextjs-auth-ui/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('../../jest.config'); 2 | -------------------------------------------------------------------------------- /packages/nextjs-auth-ui/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /packages/nextjs-auth-ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rakered/nextjs-auth-ui", 3 | "version": "1.1.1", 4 | "description": "UI for using @rakered/accounts with Next.js. Your own login & registration forms in a sec.", 5 | "keywords": [ 6 | "browser", 7 | "auth", 8 | "nextjs" 9 | ], 10 | "main": "./lib/index.js", 11 | "source": "./src/index.ts", 12 | "license": "AGPL-3.0 OR COMMERCIAL", 13 | "author": "Stephan Meijer ", 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/rakered/rakered.git" 17 | }, 18 | "scripts": { 19 | "build": "rimraf lib *.tsbuildinfo && tsc", 20 | "prepare": "npm run build", 21 | "test": "jest --coverage --runInBand --passWithNoTests ./src", 22 | "test:debug": "node --inspect-brk ./node_modules/jest/bin/jest --runInBand", 23 | "bump:patch": "npm version patch -m 'release(errors): cut the %s release'", 24 | "bump:minor": "npm version minor -m 'release(errors): cut the %s release'", 25 | "bump:major": "npm version major -m 'release(errors): cut the %s release'" 26 | }, 27 | "files": [ 28 | "lib", 29 | "style.css" 30 | ], 31 | "dependencies": { 32 | "@rakered/accounts": "^1.6.0", 33 | "@rakered/forms": "^1.5.1", 34 | "clsx": "^1.1.1", 35 | "zustand": "^3.3.1" 36 | }, 37 | "devDependencies": { 38 | "@types/jest": "^26.0.20", 39 | "@types/react": "^17.0.1", 40 | "@types/react-dom": "^17.0.1", 41 | "jest": "^26.6.3", 42 | "jest-partial": "^1.0.1", 43 | "next": "^10.0.6", 44 | "rimraf": "^3.0.2", 45 | "ts-jest": "^26.4.4", 46 | "typescript": "^4.1.3" 47 | }, 48 | "peerDependencies": { 49 | "next": "^10.0.6" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/nextjs-auth-ui/src/auth-pages.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import { LoginFormProps } from './forms/login-form'; 3 | import { ComponentType, ReactElement, ReactNode } from 'react'; 4 | import { SignupFormProps } from './forms/signup-form'; 5 | 6 | import { EnrollAccountPage } from './pages/enroll-account-page'; 7 | import { ForgotPasswordPage } from './pages/forgot-password-page'; 8 | import { LoginPage } from './pages/login-page'; 9 | import { LogoutPage } from './pages/logout-page'; 10 | import { ResetPasswordPage } from './pages/reset-password-page'; 11 | import { SignupPage } from './pages/signup-page'; 12 | import { VerifyEmailPage } from './pages/verify-email-page'; 13 | 14 | const pages: Record> = { 15 | login: LoginPage, 16 | logout: LogoutPage, 17 | signup: SignupPage, 18 | 'forgot-password': ForgotPasswordPage, 19 | 'reset-password': ResetPasswordPage, 20 | 'enroll-account': EnrollAccountPage, 21 | 'verify-email': VerifyEmailPage, 22 | }; 23 | 24 | export type AuthPagesProps = { 25 | logoUrl?: string; 26 | onLogin?: LoginFormProps['onLogin']; 27 | onSignup?: SignupFormProps['onSignup']; 28 | children?: ReactNode; 29 | }; 30 | 31 | export function AuthPages({ 32 | logoUrl, 33 | children, 34 | ...handlers 35 | }: AuthPagesProps): ReactElement { 36 | const router = useRouter(); 37 | const { query, isReady } = router; 38 | const [handler, token] = query.slug || []; 39 | 40 | const Page = pages[handler]; 41 | 42 | if (!isReady) { 43 | return ( 44 |
45 | ); 46 | } 47 | 48 | if (!Page) { 49 | return ( 50 |
51 |

52 | 404 53 |

54 |

This page could not be found.

55 |
56 | ); 57 | } 58 | 59 | return ( 60 | 79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /packages/nextjs-auth-ui/src/forms/enroll-account-form.tsx: -------------------------------------------------------------------------------- 1 | import { Password } from '@rakered/accounts/lib/types'; 2 | import { handleSubmit } from '@rakered/forms'; 3 | import { useRouter } from 'next/router'; 4 | import { ReactElement, useEffect, useState } from 'react'; 5 | import { useStore } from '../store'; 6 | import Field from '../shared/field'; 7 | import Input from '../shared/input'; 8 | import Button from '../shared/button'; 9 | import { getError } from '../utils'; 10 | import { State } from '../types'; 11 | 12 | export type EnrollAccountFormProps = { 13 | token: string; 14 | onEnrollAccount?: ({ redirectTo }: { redirectTo: string }) => Promise; 15 | }; 16 | 17 | export type EnrollAccountFormData = { name: string; password: Password }; 18 | 19 | export function EnrollAccountForm({ 20 | token, 21 | onEnrollAccount, 22 | }: EnrollAccountFormProps): ReactElement { 23 | const router = useRouter(); 24 | const redirectTo = router.query.redirect as string; 25 | 26 | const [state, setState] = useState>({ 27 | status: 'idle', 28 | }); 29 | 30 | const enroll = useStore((state) => state.enroll); 31 | 32 | const onSubmit = handleSubmit<{ 33 | name: string; 34 | password: Password; 35 | }>(async (values) => { 36 | setState({ status: 'loading', values }); 37 | }); 38 | 39 | const transition = async () => { 40 | switch (state.status) { 41 | case 'loading': { 42 | const res = await enroll({ ...state.values, token }); 43 | if ('error' in res) { 44 | setState({ ...state, status: 'error', error: res.error }); 45 | } else { 46 | setState({ status: 'success' }); 47 | } 48 | break; 49 | } 50 | case 'success': { 51 | if (typeof onEnrollAccount === 'function') { 52 | await onEnrollAccount({ redirectTo }); 53 | } else { 54 | await router.push(redirectTo || '/'); 55 | } 56 | break; 57 | } 58 | } 59 | }; 60 | 61 | useEffect(() => { 62 | transition(); 63 | }, [state.status]); 64 | 65 | return ( 66 |
67 | 68 | 75 | 76 | 77 | 78 | 86 | 87 | 88 | 80 |
81 | 82 |
83 | 84 | Return to sign in. 85 | 86 |
87 |
88 | 89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /packages/nextjs-auth-ui/src/forms/reset-password-form.tsx: -------------------------------------------------------------------------------- 1 | import { handleSubmit, HashedPassword } from '@rakered/forms'; 2 | import { useRouter } from 'next/router'; 3 | import { ReactElement, useEffect, useState } from 'react'; 4 | 5 | import { useStore } from '../store'; 6 | import { State } from '../types'; 7 | import Field from '../shared/field'; 8 | import Input from '../shared/input'; 9 | import Button from '../shared/button'; 10 | 11 | export type ResetPasswordFormData = { password: HashedPassword }; 12 | 13 | export type ResetPasswordFormProps = { 14 | token: string; 15 | }; 16 | 17 | export function ResetPasswordForm({ 18 | token, 19 | }: ResetPasswordFormProps): ReactElement { 20 | const router = useRouter(); 21 | const resetPassword = useStore((state) => state.resetPassword); 22 | 23 | const [state, setState] = useState>({ 24 | status: 'idle', 25 | }); 26 | 27 | const onSubmit = handleSubmit(async (values) => { 28 | setState({ status: 'loading', values }); 29 | }); 30 | 31 | const transition = async () => { 32 | switch (state.status) { 33 | case 'loading': { 34 | const res = await resetPassword({ ...state.values, token }); 35 | if ('error' in res) { 36 | setState({ ...state, status: 'error', error: res.error }); 37 | } else { 38 | setState({ status: 'success' }); 39 | } 40 | break; 41 | } 42 | case 'success': { 43 | await router.push('/auth/login'); 44 | } 45 | } 46 | }; 47 | 48 | useEffect(() => { 49 | transition(); 50 | }, [state.status]); 51 | 52 | return ( 53 |
54 |

55 | Enter the new password that you'll wish to associate with account.{' '} 56 | Please use a password manager, if 57 | in any way possible. 58 |

59 | 63 | 72 | 73 | 74 |