add user confirmation, add validation for submission data

This commit is contained in:
Michael Schramm 2022-01-02 15:41:20 +01:00
parent 11e95cb9c2
commit 29a74ea9c9
52 changed files with 573 additions and 142 deletions

View File

@ -24,6 +24,7 @@ module.exports = {
jest: true, jest: true,
}, },
rules: { rules: {
'@typescript-eslint/no-unsafe-call': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn', '@typescript-eslint/no-unsafe-argument': 'warn',
'@typescript-eslint/no-unsafe-assignment': 'warn', '@typescript-eslint/no-unsafe-assignment': 'warn',
'@typescript-eslint/no-unsafe-member-access': 'warn', '@typescript-eslint/no-unsafe-member-access': 'warn',

View File

@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- forms now have multiple notification - forms now have multiple notification
- layout for forms - layout for forms
- mariadb / mysql support - mariadb / mysql support
- user confirmation tokens
- email verification
### Changed ### Changed
@ -20,6 +22,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- colors object removed the "colors" postfix - colors object removed the "colors" postfix
- if unsupported database engine is used error is thrown during startup - if unsupported database engine is used error is thrown during startup
- improved eslint checks - improved eslint checks
- validate submission field data and store it json encoded
### Fixed ### Fixed

View File

@ -1,43 +1,46 @@
# Environment Variables # Environment Variables
| Name | Default Value | Description | | Name | Default Value | Description |
| ---- | ------------- | ----------- | |------------------------------|----------------------------|---------------------------------------------------------------------------------------|
| DISABLE_INSTALLATION_METRICS | *not set* | Per default installations are [publishing](./installation.metrics.md) their existence | | DISABLE_INSTALLATION_METRICS | *not set* | Per default installations are [publishing](./installation.metrics.md) their existence |
| SECRET_KEY | `changeMe` | JWT Secret for authentication | | SECRET_KEY | `changeMe` | JWT Secret for authentication |
| CLI | *automatically* | activates pretty print for log output | | CLI | *automatically* | activates pretty print for log output |
| NODE_ENV | `production` | | | NODE_ENV | `production` | |
| HIDE_CONTRIB | `false` | decide if backlings to ohmyform should be added | | HIDE_CONTRIB | `false` | decide if backlings to ohmyform should be added |
| SIGNUP_DISABLED | `false` | if users can sign up | | SIGNUP_DISABLED | `false` | if users can sign up |
| LOGIN_NOTE | *not set* | Info box on top of login screen | | LOGIN_NOTE | *not set* | Info box on top of login screen |
| LOCALES_PATH | *not set* | Path to translated elementes in backend like emails | | LOCALES_PATH | *not set* | Path to translated elementes in backend like emails |
| LOCALE | `en` | Default Locale |
| BASE_URL | `http://localhost` | Url to Frontend root |
| USER_CONFIRM_PATH | `/confirm?token={{token}}` | Path to confirm user |
## Default Account ## Default Account
*username and email are unique on an instance* *username and email are unique on an instance*
| Name | Default Value | Description | | Name | Default Value | Description |
| ---- | ------------- | ----------- | |----------------|----------------------|-------------------------------------|
| CREATE_ADMIN | `false` | if `true` will create a super admin | | CREATE_ADMIN | `false` | if `true` will create a super admin |
| ADMIN_USERNAME | `root` | username for the default admin user | | ADMIN_USERNAME | `root` | username for the default admin user |
| ADMIN_EMAIL | `admin@ohmyform.com` | email to send notifications | | ADMIN_EMAIL | `admin@ohmyform.com` | email to send notifications |
| ADMIN_PASSWORD | `root` | password for user | | ADMIN_PASSWORD | `root` | password for user |
## Mailing ## Mailing
| Name | Default Value | Description | | Name | Default Value | Description |
| ---- | ------------- | ----------- | |-------------|---------------------------------|-----------------------------------------------------------------------------------|
| MAILER_URI | `smtp://localhost:1025` | [Mail Connection](https://nodemailer.com/smtp/) | | MAILER_URI | `smtp://localhost:1025` | [Mail Connection](https://nodemailer.com/smtp/) |
| MAILER_FROM | `OhMyForm <no-reply@localhost>` | Default From path, make sure that your mail server supports the given from addres | | MAILER_FROM | `OhMyForm <no-reply@localhost>` | Default From path, make sure that your mail server supports the given from addres |
## Database Variables ## Database Variables
| Name | Default Value | Description | | Name | Default Value | Description |
| ---- | ------------- | ----------- | |-----------------------|------------------------|--------------------------------------------------------------------------------------------------------------------------------------------|
| DATABASE_DRIVER | `sqlite` | database driver, either `sqlite` or `postgres` | | DATABASE_DRIVER | `sqlite` | database driver, either `sqlite` or `postgres` |
| DATABASE_URL | `sqlite://data.sqlite` | url in the format `TYPE://USER:PASS@HOST:PORT/NAME?EXTRA` ([read more](https://typeorm.io/#/connection-options/common-connection-options)) | | DATABASE_URL | `sqlite://data.sqlite` | url in the format `TYPE://USER:PASS@HOST:PORT/NAME?EXTRA` ([read more](https://typeorm.io/#/connection-options/common-connection-options)) |
| DATABASE_TABLE_PREFIX | *empty* | prefix all tables if used within same database as other applications. | | DATABASE_TABLE_PREFIX | *empty* | prefix all tables if used within same database as other applications. |
| DATABASE_LOGGING | `false` | if `true` all db interactions will be logged to stdout | | DATABASE_LOGGING | `false` | if `true` all db interactions will be logged to stdout |
| DATABASE_MIGRATE | `true` | can be used in load balanced environments to only allow one container to perform migrations / manually execute migrations | DATABASE_MIGRATE | `true` | can be used in load balanced environments to only allow one container to perform migrations / manually execute migrations |
| DATABASE_SSL | `false` | if `true` will require ssl database connection | | DATABASE_SSL | `false` | if `true` will require ssl database connection |
| REDIS_HOST | *not set* | required in multinode environments | | REDIS_HOST | *not set* | required in multinode environments |
| REDIS_PORT | `6379` | port for redis | | REDIS_PORT | `6379` | port for redis |

View File

@ -10,12 +10,13 @@
"src/entity/**/*.ts" "src/entity/**/*.ts"
], ],
"migrations": [ "migrations": [
"src/migrations/maria/**/*.ts" "src/migrations/mariadb/**/*.ts"
], ],
"migrationsTransactionMode": "each",
"subscribers": [ "subscribers": [
"src/subscriber/**/*.ts" "src/subscriber/**/*.ts"
], ],
"cli": { "cli": {
"migrationsDir": "src/migrations/maria" "migrationsDir": "src/migrations/mariadb"
} }
} }

View File

@ -12,6 +12,7 @@
"migrations": [ "migrations": [
"src/migrations/postgres/**/*.ts" "src/migrations/postgres/**/*.ts"
], ],
"migrationsTransactionMode": "each",
"subscribers": [ "subscribers": [
"src/subscriber/**/*.ts" "src/subscriber/**/*.ts"
], ],

View File

@ -9,6 +9,7 @@
"migrations": [ "migrations": [
"src/migrations/sqlite/**/*.ts" "src/migrations/sqlite/**/*.ts"
], ],
"migrationsTransactionMode": "each",
"cli": { "cli": {
"migrationsDir": "src/migrations/sqlite" "migrationsDir": "src/migrations/sqlite"
} }

View File

@ -22,7 +22,7 @@
"test:e2e": "jest --config ./test/jest-e2e.json", "test:e2e": "jest --config ./test/jest-e2e.json",
"typeorm:sqlite": "cross-env TS_NODE_TRANSPILE_ONLY=true ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -f ormconfig_sqlite.json", "typeorm:sqlite": "cross-env TS_NODE_TRANSPILE_ONLY=true ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -f ormconfig_sqlite.json",
"typeorm:postgres": "cross-env TS_NODE_TRANSPILE_ONLY=true ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -f ormconfig_postgres.json", "typeorm:postgres": "cross-env TS_NODE_TRANSPILE_ONLY=true ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -f ormconfig_postgres.json",
"typeorm:maria": "cross-env TS_NODE_TRANSPILE_ONLY=true ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -f ormconfig_maria.json" "typeorm:mariadb": "cross-env TS_NODE_TRANSPILE_ONLY=true ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -f ormconfig_mariadb.json"
}, },
"dependencies": { "dependencies": {
"@nestjs-modules/mailer": "^1.6.0", "@nestjs-modules/mailer": "^1.6.0",
@ -82,6 +82,7 @@
"@types/html-to-text": "^8.0.1", "@types/html-to-text": "^8.0.1",
"@types/inquirer": "^8.1.3", "@types/inquirer": "^8.1.3",
"@types/jest": "26.0.23", "@types/jest": "26.0.23",
"@types/mjml": "^4.7.0",
"@types/node": "^16.11.17", "@types/node": "^16.11.17",
"@types/passport-jwt": "^3.0.6", "@types/passport-jwt": "^3.0.6",
"@types/passport-local": "^1.0.34", "@types/passport-local": "^1.0.34",

View File

@ -49,7 +49,7 @@ export const imports = [
}), }),
ServeStaticModule.forRoot({ ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'public'), rootPath: join(__dirname, '..', 'public'),
exclude: [], exclude: ['/graphql'],
}), }),
ConfigModule.forRoot({ ConfigModule.forRoot({
load: [ load: [
@ -122,8 +122,8 @@ export const imports = [
break break
case 'mysql': case 'mysql':
case 'maria': case 'mariadb':
migrationFolder = 'maria' migrationFolder = 'mariadb'
break break
case 'sqlite': case 'sqlite':
@ -146,6 +146,7 @@ export const imports = [
entities, entities,
migrations: [`${__dirname}/**/migrations/${migrationFolder}/**/*{.ts,.js}`], migrations: [`${__dirname}/**/migrations/${migrationFolder}/**/*{.ts,.js}`],
migrationsRun: configService.get<boolean>('DATABASE_MIGRATE', true), migrationsRun: configService.get<boolean>('DATABASE_MIGRATE', true),
migrationsTransactionMode: 'each',
}) })
}, },
}), }),

View File

@ -1,4 +1,5 @@
import { Field, ID, InputType } from '@nestjs/graphql' import { Field, ID, InputType } from '@nestjs/graphql'
import { FormFieldLogicAction } from '../../entity/form.field.logic.entity'
@InputType() @InputType()
export class FormFieldLogicInput { export class FormFieldLogicInput {
@ -8,8 +9,9 @@ export class FormFieldLogicInput {
@Field({ nullable: true }) @Field({ nullable: true })
readonly formula: string readonly formula: string
@Field({ nullable: true }) // TODO verify action value
readonly action: string @Field(() => String, { nullable: true })
readonly action: FormFieldLogicAction
@Field(() => ID, { nullable: true }) @Field(() => ID, { nullable: true })
readonly jumpTo?: string readonly jumpTo?: string

View File

@ -14,7 +14,7 @@ export class SubmissionFieldModel {
constructor(field: SubmissionFieldEntity) { constructor(field: SubmissionFieldEntity) {
this.id = field.id.toString() this.id = field.id.toString()
this.value = field.fieldValue this.value = JSON.stringify(field.content)
this.type = field.fieldType this.type = field.type
} }
} }

View File

@ -6,9 +6,15 @@ export class UserModel {
@Field(() => ID) @Field(() => ID)
readonly id: string readonly id: string
@Field() /**
* @deprecated use emailVerified instead
*/
@Field({ deprecationReason: 'use emailVerified instead' })
readonly verifiedEmail: boolean readonly verifiedEmail: boolean
@Field()
readonly emailVerified: boolean
@Field() @Field()
readonly username: string readonly username: string
@ -39,7 +45,8 @@ export class UserModel {
this.firstName = user.firstName this.firstName = user.firstName
this.lastName = user.lastName this.lastName = user.lastName
this.verifiedEmail = !user.token this.verifiedEmail = user.emailVerified
this.emailVerified = user.emailVerified
this.created = user.created this.created = user.created
this.lastModified = user.lastModified this.lastModified = user.lastModified

View File

@ -1,7 +1,7 @@
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm' import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'
import { FormFieldEntity } from './form.field.entity' import { FormFieldEntity } from './form.field.entity'
type LogicAction = 'visible' | 'require' | 'disable' | 'jumpTo' export type FormFieldLogicAction = 'visible' | 'require' | 'disable' | 'jumpTo'
@Entity({ name: 'form_field_logic' }) @Entity({ name: 'form_field_logic' })
export class FormFieldLogicEntity { export class FormFieldLogicEntity {
@ -15,7 +15,7 @@ export class FormFieldLogicEntity {
public formula: string public formula: string
@Column({ type: 'varchar', length: 10 }) @Column({ type: 'varchar', length: 10 })
public action: LogicAction public action: FormFieldLogicAction
@Column({ nullable: true }) @Column({ nullable: true })
public visible?: boolean public visible?: boolean

View File

@ -2,6 +2,10 @@ import { Column, Entity, ManyToOne, PrimaryGeneratedColumn, RelationId } from 't
import { FormFieldEntity } from './form.field.entity' import { FormFieldEntity } from './form.field.entity'
import { SubmissionEntity } from './submission.entity' import { SubmissionEntity } from './submission.entity'
export interface SubmissionFieldContent {
[key: string]: string | string[] | number | number[] | boolean | boolean[]
}
@Entity({ name: 'submission_field' }) @Entity({ name: 'submission_field' })
export class SubmissionFieldEntity { export class SubmissionFieldEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
@ -17,8 +21,8 @@ export class SubmissionFieldEntity {
readonly fieldId: number readonly fieldId: number
@Column() @Column()
public fieldType: string public type: string
@Column() @Column('simple-json')
public fieldValue: string public content: SubmissionFieldContent
} }

View File

@ -15,6 +15,9 @@ export class UserEntity {
@Column({ length: 255, unique: true }) @Column({ length: 255, unique: true })
public email: string public email: string
@Column('boolean', { default: false })
public emailVerified = false
@Column({ length: 255, unique: true }) @Column({ length: 255, unique: true })
public username: string public username: string

View File

@ -1,13 +1,11 @@
import { NestApplicationOptions, ValidationPipe } from '@nestjs/common' import { NestApplicationOptions, ValidationPipe } from '@nestjs/common'
import { NestFactory } from '@nestjs/core' import { NestFactory } from '@nestjs/core'
import cors from 'cors' import cors from 'cors'
import { Logger, PinoLogger } from 'nestjs-pino' import { Logger } from 'nestjs-pino'
import { LoggerConfig } from './app.imports'
import { AppModule } from './app.module' import { AppModule } from './app.module'
(async () => { (async () => {
const options: NestApplicationOptions = { const options: NestApplicationOptions = {
logger: new Logger(new PinoLogger(LoggerConfig), {}),
bufferLogs: true, bufferLogs: true,
} }

View File

@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class submission1641124349039 implements MigrationInterface {
name = 'submission1641124349039'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE `submission_field` RENAME COLUMN `fieldType` TO `type`');
await queryRunner.query('ALTER TABLE `submission_field` RENAME COLUMN `fieldValue` TO `content`');
await queryRunner.query('ALTER TABLE `submission_field` MODIFY COLUMN `content` text NOT NULL');
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE `submission_field` MODIFY COLUMN `content` varchar(255) NOT NULL');
await queryRunner.query('ALTER TABLE `submission_field` RENAME COLUMN `content` TO `fieldValue`');
await queryRunner.query('ALTER TABLE `submission_field` RENAME COLUMN `type` TO `fieldType`');
}
}

View File

@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class confirm1641132645227 implements MigrationInterface {
name = 'confirm1641132645227'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE `user` ADD `emailVerified` tinyint NOT NULL DEFAULT 0');
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE `user` DROP COLUMN `emailVerified`');
}
}

View File

@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class submission1641124349039 implements MigrationInterface {
name = 'submission1641124349039'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE "submission_field" RENAME COLUMN "fieldType" TO "type"');
await queryRunner.query('ALTER TABLE "submission_field" RENAME COLUMN "fieldValue" TO "content"');
await queryRunner.query('ALTER TABLE "submission_field" ALTER COLUMN "content" type text');
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE "submission_field" ALTER COLUMN "content" type character varying');
await queryRunner.query('ALTER TABLE "submission_field" RENAME COLUMN "content" TO "fieldValue"');
await queryRunner.query('ALTER TABLE "submission_field" RENAME COLUMN "type" TO "fieldType"');
}
}

View File

@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class confirm1641132645227 implements MigrationInterface {
name = 'confirm1641132645227'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE "user" ADD "emailVerified" boolean NOT NULL DEFAULT false');
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE "user" DROP COLUMN "emailVerified"');
}
}

View File

@ -15,6 +15,10 @@ export class initial1619723437787 implements MigrationInterface {
await queryRunner.query('CREATE TABLE "submission_field" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "fieldType" varchar NOT NULL, "fieldValue" varchar NOT NULL, "submissionId" integer, "fieldId" integer, CONSTRAINT "FK_16fae661ce5b10f27abe2e524a0" FOREIGN KEY ("submissionId") REFERENCES "submission" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_5befa92da2370b7eb1cab6ae30a" FOREIGN KEY ("fieldId") REFERENCES "form_field" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)'); await queryRunner.query('CREATE TABLE "submission_field" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "fieldType" varchar NOT NULL, "fieldValue" varchar NOT NULL, "submissionId" integer, "fieldId" integer, CONSTRAINT "FK_16fae661ce5b10f27abe2e524a0" FOREIGN KEY ("submissionId") REFERENCES "submission" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_5befa92da2370b7eb1cab6ae30a" FOREIGN KEY ("fieldId") REFERENCES "form_field" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)');
await queryRunner.query('CREATE TABLE "form_visitor" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "referrer" varchar, "ipAddr" varchar NOT NULL, "created" datetime NOT NULL DEFAULT (datetime(\'now\')), "updated" datetime NOT NULL DEFAULT (datetime(\'now\')), "formId" integer, "geoLocationCountry" varchar, "geoLocationCity" varchar, "deviceLanguage" varchar, "deviceType" varchar, "deviceName" varchar, CONSTRAINT "FK_72ade6c3a3e55d1fce94300f8b6" FOREIGN KEY ("formId") REFERENCES "form" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)'); await queryRunner.query('CREATE TABLE "form_visitor" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "referrer" varchar, "ipAddr" varchar NOT NULL, "created" datetime NOT NULL DEFAULT (datetime(\'now\')), "updated" datetime NOT NULL DEFAULT (datetime(\'now\')), "formId" integer, "geoLocationCountry" varchar, "geoLocationCity" varchar, "deviceLanguage" varchar, "deviceType" varchar, "deviceName" varchar, CONSTRAINT "FK_72ade6c3a3e55d1fce94300f8b6" FOREIGN KEY ("formId") REFERENCES "form" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)');
await queryRunner.query('CREATE TABLE "submission" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ipAddr" varchar NOT NULL, "tokenHash" varchar NOT NULL, "timeElapsed" numeric NOT NULL, "percentageComplete" numeric NOT NULL, "created" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastModified" datetime NOT NULL DEFAULT (datetime(\'now\')), "formId" integer, "visitorId" integer, "userId" integer, "geoLocationCountry" varchar, "geoLocationCity" varchar, "deviceLanguage" varchar, "deviceType" varchar, "deviceName" varchar, CONSTRAINT "FK_6090e1d5cbf3433ffd14e3b53e7" FOREIGN KEY ("formId") REFERENCES "form" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_95b73c7faf2c199f005fda5e8c8" FOREIGN KEY ("visitorId") REFERENCES "form_visitor" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_7bd626272858ef6464aa2579094" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)'); await queryRunner.query('CREATE TABLE "submission" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ipAddr" varchar NOT NULL, "tokenHash" varchar NOT NULL, "timeElapsed" numeric NOT NULL, "percentageComplete" numeric NOT NULL, "created" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastModified" datetime NOT NULL DEFAULT (datetime(\'now\')), "formId" integer, "visitorId" integer, "userId" integer, "geoLocationCountry" varchar, "geoLocationCity" varchar, "deviceLanguage" varchar, "deviceType" varchar, "deviceName" varchar, CONSTRAINT "FK_6090e1d5cbf3433ffd14e3b53e7" FOREIGN KEY ("formId") REFERENCES "form" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_95b73c7faf2c199f005fda5e8c8" FOREIGN KEY ("visitorId") REFERENCES "form_visitor" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_7bd626272858ef6464aa2579094" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)');
await queryRunner.query('CREATE TABLE "form" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "title" varchar NOT NULL, "language" varchar(10) NOT NULL, "showFooter" boolean NOT NULL, "isLive" boolean NOT NULL, "created" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastModified" datetime NOT NULL DEFAULT (datetime(\'now\')), "adminId" integer, "startPageId" integer, "endPageId" integer, "analyticsGacode" varchar, "designFont" varchar, "designColorsBackground" varchar, "designColorsQuestion" varchar, "designColorsAnswer" varchar, "designColorsButton" varchar, "designColorsButtonactive" varchar, "designColorsButtontext" varchar, CONSTRAINT "FK_a7cb33580bca2b362e5e34fdfcd" FOREIGN KEY ("adminId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_023d9cf1d97e93facc96c86ca70" FOREIGN KEY ("startPageId") REFERENCES "page" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_e5d158932e43cfbf9958931ee01" FOREIGN KEY ("endPageId") REFERENCES "page" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)'); await queryRunner.query('CREATE TABLE "form" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "title" varchar NOT NULL, "language" varchar(10) NOT NULL, "showFooter" boolean NOT NULL, "isLive" boolean NOT NULL, "created" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastModified" datetime NOT NULL DEFAULT (datetime(\'now\')), "adminId" integer, "startPageId" integer, "endPageId" integer, "analyticsGacode" varchar, "designFont" varchar, "designColorsBackground" varchar, "designColorsQuestion" varchar, "designColorsAnswer" varchar, "designColorsButton" varchar, "designColorsButtonactive" varchar, "designColorsButtontext" varchar, CONSTRAINT "FK_a7cb33580bca2b362e5e34fdfcd" FOREIGN KEY ("adminId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_023d9cf1d97e93facc96c86ca70" FOREIGN KEY ("startPageId") REFERENCES "page" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_e5d158932e43cfbf9958931ee01" FOREIGN KEY ("endPageId") REFERENCES "page" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)');
} }

View File

@ -4,11 +4,17 @@ export class layout1621078163528 implements MigrationInterface {
name = 'layout1621078163528' name = 'layout1621078163528'
public async up(queryRunner: QueryRunner): Promise<void> { public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE "form" ADD "designLayout" character varying'); await queryRunner.query('CREATE TABLE "temporary_form" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "title" varchar NOT NULL, "language" varchar(10) NOT NULL, "showFooter" boolean NOT NULL, "isLive" boolean NOT NULL, "created" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastModified" datetime NOT NULL DEFAULT (datetime(\'now\')), "adminId" integer, "startPageId" integer, "endPageId" integer, "analyticsGacode" varchar, "designFont" varchar, "designColorsBackground" varchar, "designColorsQuestion" varchar, "designColorsAnswer" varchar, "designColorsButton" varchar, "designColorsButtonactive" varchar, "designColorsButtontext" varchar, "designLayout" varchar, CONSTRAINT "FK_e5d158932e43cfbf9958931ee01" FOREIGN KEY ("endPageId") REFERENCES "page" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_023d9cf1d97e93facc96c86ca70" FOREIGN KEY ("startPageId") REFERENCES "page" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_a7cb33580bca2b362e5e34fdfcd" FOREIGN KEY ("adminId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)');
await queryRunner.query('INSERT INTO "temporary_form"("id", "title", "language", "showFooter", "isLive", "created", "lastModified", "adminId", "startPageId", "endPageId", "analyticsGacode", "designFont", "designColorsBackground", "designColorsQuestion", "designColorsAnswer", "designColorsButton", "designColorsButtonactive", "designColorsButtontext") SELECT "id", "title", "language", "showFooter", "isLive", "created", "lastModified", "adminId", "startPageId", "endPageId", "analyticsGacode", "designFont", "designColorsBackground", "designColorsQuestion", "designColorsAnswer", "designColorsButton", "designColorsButtonactive", "designColorsButtontext" FROM "form"');
await queryRunner.query('DROP TABLE "form"');
await queryRunner.query('ALTER TABLE "temporary_form" RENAME TO "form"');
} }
public async down(queryRunner: QueryRunner): Promise<void> { public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE "form" DROP COLUMN "designLayout"'); await queryRunner.query('ALTER TABLE "form" RENAME TO "temporary_form"');
await queryRunner.query('CREATE TABLE "form" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "title" varchar NOT NULL, "language" varchar(10) NOT NULL, "showFooter" boolean NOT NULL, "isLive" boolean NOT NULL, "created" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastModified" datetime NOT NULL DEFAULT (datetime(\'now\')), "adminId" integer, "startPageId" integer, "endPageId" integer, "analyticsGacode" varchar, "designFont" varchar, "designColorsBackground" varchar, "designColorsQuestion" varchar, "designColorsAnswer" varchar, "designColorsButton" varchar, "designColorsButtonactive" varchar, "designColorsButtontext" varchar, CONSTRAINT "FK_e5d158932e43cfbf9958931ee01" FOREIGN KEY ("endPageId") REFERENCES "page" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_023d9cf1d97e93facc96c86ca70" FOREIGN KEY ("startPageId") REFERENCES "page" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_a7cb33580bca2b362e5e34fdfcd" FOREIGN KEY ("adminId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)');
await queryRunner.query('INSERT INTO "form"("id", "title", "language", "showFooter", "isLive", "created", "lastModified", "adminId", "startPageId", "endPageId", "analyticsGacode", "designFont", "designColorsBackground", "designColorsQuestion", "designColorsAnswer", "designColorsButton", "designColorsButtonactive", "designColorsButtontext") SELECT "id", "title", "language", "showFooter", "isLive", "created", "lastModified", "adminId", "startPageId", "endPageId", "analyticsGacode", "designFont", "designColorsBackground", "designColorsQuestion", "designColorsAnswer", "designColorsButton", "designColorsButtonactive", "designColorsButtontext" FROM "temporary_form"');
await queryRunner.query('DROP TABLE "temporary_form"');
} }
} }

View File

@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class submission1641124349039 implements MigrationInterface {
name = 'submission1641124349039'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('CREATE TABLE "temporary_submission_field" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "submissionId" integer, "fieldId" integer, "type" varchar NOT NULL, "content" text NOT NULL, CONSTRAINT "FK_5befa92da2370b7eb1cab6ae30a" FOREIGN KEY ("fieldId") REFERENCES "form_field" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_16fae661ce5b10f27abe2e524a0" FOREIGN KEY ("submissionId") REFERENCES "submission" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)');
await queryRunner.query('INSERT INTO "temporary_submission_field"("id", "submissionId", "type", "content", "fieldId") SELECT "id", "submissionId", "fieldType", "fieldValue", "fieldId" FROM "submission_field"');
await queryRunner.query('DROP TABLE "submission_field"');
await queryRunner.query('ALTER TABLE "temporary_submission_field" RENAME TO "submission_field"');
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE "submission_field" RENAME TO "temporary_submission_field"');
await queryRunner.query('CREATE TABLE "submission_field" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "fieldType" varchar NOT NULL, "fieldValue" varchar NOT NULL, "submissionId" integer, "fieldId" integer, CONSTRAINT "FK_5befa92da2370b7eb1cab6ae30a" FOREIGN KEY ("fieldId") REFERENCES "form_field" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_16fae661ce5b10f27abe2e524a0" FOREIGN KEY ("submissionId") REFERENCES "submission" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)');
await queryRunner.query('INSERT INTO "submission_field"("id", "submissionId", "fieldType", "fieldValue", "fieldId") SELECT "id", "submissionId", "type", "content", "fieldId" FROM "temporary_submission_field"');
await queryRunner.query('DROP TABLE "temporary_submission_field"');
}
}

View File

@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class confirm1641132645227 implements MigrationInterface {
name = 'confirm1641132645227'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "firstName" varchar, "lastName" varchar, "email" varchar(255) NOT NULL, "username" varchar(255) NOT NULL, "passwordHash" varchar NOT NULL, "salt" varchar, "provider" varchar NOT NULL, "roles" text NOT NULL, "language" varchar NOT NULL, "resetPasswordToken" varchar, "resetPasswordExpires" datetime, "token" varchar, "apiKey" varchar, "created" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastModified" datetime NOT NULL DEFAULT (datetime(\'now\')), "emailVerified" boolean NOT NULL DEFAULT (0), CONSTRAINT "UQ_78a916df40e02a9deb1c4b75edb" UNIQUE ("username"), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))');
await queryRunner.query('INSERT INTO "temporary_user"("id", "firstName", "lastName", "email", "username", "passwordHash", "salt", "provider", "roles", "language", "resetPasswordToken", "resetPasswordExpires", "token", "apiKey", "created", "lastModified") SELECT "id", "firstName", "lastName", "email", "username", "passwordHash", "salt", "provider", "roles", "language", "resetPasswordToken", "resetPasswordExpires", "token", "apiKey", "created", "lastModified" FROM "user"');
await queryRunner.query('DROP TABLE "user"');
await queryRunner.query('ALTER TABLE "temporary_user" RENAME TO "user"');
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE "user" RENAME TO "temporary_user"');
await queryRunner.query('CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "firstName" varchar, "lastName" varchar, "email" varchar(255) NOT NULL, "username" varchar(255) NOT NULL, "passwordHash" varchar NOT NULL, "salt" varchar, "provider" varchar NOT NULL, "roles" text NOT NULL, "language" varchar NOT NULL, "resetPasswordToken" varchar, "resetPasswordExpires" datetime, "token" varchar, "apiKey" varchar, "created" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastModified" datetime NOT NULL DEFAULT (datetime(\'now\')), CONSTRAINT "UQ_78a916df40e02a9deb1c4b75edb" UNIQUE ("username"), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))');
await queryRunner.query('INSERT INTO "user"("id", "firstName", "lastName", "email", "username", "passwordHash", "salt", "provider", "roles", "language", "resetPasswordToken", "resetPasswordExpires", "token", "apiKey", "created", "lastModified") SELECT "id", "firstName", "lastName", "email", "username", "passwordHash", "salt", "provider", "roles", "language", "resetPasswordToken", "resetPasswordExpires", "token", "apiKey", "created", "lastModified" FROM "temporary_user"');
await queryRunner.query('DROP TABLE "temporary_user"');
}
}

View File

@ -23,7 +23,7 @@ export class FormDeleteMutation {
): Promise<DeletedModel> { ): Promise<DeletedModel> {
const form = await this.formService.findById(id) const form = await this.formService.findById(id)
if (!form.isLive && !await this.formService.isAdmin(form, user)) { if (!form.isLive && !this.formService.isAdmin(form, user)) {
throw new Error('invalid form') throw new Error('invalid form')
} }

View File

@ -21,7 +21,7 @@ export class FormQuery {
): Promise<FormModel> { ): Promise<FormModel> {
const form = await this.formService.findById(id) const form = await this.formService.findById(id)
if (!form.isLive && !await this.formService.isAdmin(form, user)) { if (!form.isLive && !this.formService.isAdmin(form, user)) {
throw new Error('invalid form') throw new Error('invalid form')
} }

View File

@ -51,7 +51,7 @@ export class FormResolver {
): Promise<boolean> { ): Promise<boolean> {
const form = await cache.get<FormEntity>(cache.getCacheKey(FormEntity.name, parent.id)) const form = await cache.get<FormEntity>(cache.getCacheKey(FormEntity.name, parent.id))
if (!await this.formService.isAdmin(form, user)) { if (!this.formService.isAdmin(form, user)) {
throw new Error('no access to field') throw new Error('no access to field')
} }
@ -67,7 +67,7 @@ export class FormResolver {
): Promise<FormNotificationModel[]> { ): Promise<FormNotificationModel[]> {
const form = await cache.get<FormEntity>(cache.getCacheKey(FormEntity.name, parent.id)) const form = await cache.get<FormEntity>(cache.getCacheKey(FormEntity.name, parent.id))
if (!await this.formService.isAdmin(form, user)) { if (!this.formService.isAdmin(form, user)) {
throw new Error('no access to field') throw new Error('no access to field')
} }

View File

@ -27,7 +27,7 @@ export class FormUpdateMutation {
): Promise<FormModel> { ): Promise<FormModel> {
const form = await this.formService.findById(input.id) const form = await this.formService.findById(input.id)
if (!form.isLive && !await this.formService.isAdmin(form, user)) { if (!form.isLive && !this.formService.isAdmin(form, user)) {
throw new Error('invalid form') throw new Error('invalid form')
} }

View File

@ -8,10 +8,10 @@ import { ContextCache } from '../context.cache'
export class ProfileResolver { export class ProfileResolver {
@Query(() => ProfileModel) @Query(() => ProfileModel)
@Roles('user') @Roles('user')
async me( public me(
@User() user: UserEntity, @User() user: UserEntity,
@Context('cache') cache: ContextCache, @Context('cache') cache: ContextCache,
): Promise<ProfileModel> { ): ProfileModel {
cache.add(cache.getCacheKey(UserEntity.name, user.id), user) cache.add(cache.getCacheKey(UserEntity.name, user.id), user)
return new ProfileModel(user) return new ProfileModel(user)

View File

@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common' import { Injectable } from '@nestjs/common'
import { Args, Context, Mutation } from '@nestjs/graphql' import { Args, Context, Mutation } from '@nestjs/graphql'
import { Roles } from '../../decorator/roles.decorator'
import { User } from '../../decorator/user.decorator' import { User } from '../../decorator/user.decorator'
import { ProfileModel } from '../../dto/profile/profile.model' import { ProfileModel } from '../../dto/profile/profile.model'
import { ProfileUpdateInput } from '../../dto/profile/profile.update.input' import { ProfileUpdateInput } from '../../dto/profile/profile.update.input'
@ -15,6 +16,7 @@ export class ProfileUpdateMutation {
} }
@Mutation(() => ProfileModel) @Mutation(() => ProfileModel)
@Roles('user')
async updateProfile( async updateProfile(
@User() user: UserEntity, @User() user: UserEntity,
@Args({ name: 'user', type: () => ProfileUpdateInput }) input: ProfileUpdateInput, @Args({ name: 'user', type: () => ProfileUpdateInput }) input: ProfileUpdateInput,
@ -26,4 +28,18 @@ export class ProfileUpdateMutation {
return new ProfileModel(user) return new ProfileModel(user)
} }
@Mutation(() => ProfileModel)
@Roles('user')
async verifyEmail(
@User() user: UserEntity,
@Args({ name: 'token' }) token: string,
@Context('cache') cache: ContextCache,
): Promise<ProfileModel> {
await this.updateService.verifyEmail(user, token)
cache.add(cache.getCacheKey(UserEntity.name, user.id), user)
return new ProfileModel(user)
}
} }

View File

@ -27,14 +27,14 @@ export class SettingResolver {
} }
@Query(() => SettingModel) @Query(() => SettingModel)
async getSetting( getSetting(
@Args('key', {type: () => ID}) key: string, @Args('key', {type: () => ID}) key: string,
@User() user: UserEntity, @User() user: UserEntity,
): Promise<SettingModel> { ): SettingModel {
if (!this.settingService.isPublicKey(key) && !user.roles.includes('superuser')) { if (!this.settingService.isPublicKey(key) && !user.roles.includes('superuser')) {
throw new Error(`no access to key ${key}`) throw new Error(`no access to key ${key}`)
} }
return await this.settingService.getByKey(key) return this.settingService.getByKey(key)
} }
} }

View File

@ -18,9 +18,11 @@ export class FormDeleteService {
await this.submissionRepository.createQueryBuilder('s') await this.submissionRepository.createQueryBuilder('s')
.delete() .delete()
.where('s.form = :form', { form: id }) .where('s.form = :form', { form: id })
.execute()
await this.formRepository.createQueryBuilder('f') await this.formRepository.createQueryBuilder('f')
.delete() .delete()
.where('f.id = :form', { form: id }) .where('f.id = :form', { form: id })
.execute()
} }
} }

View File

@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common' import { Injectable } from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm' import { InjectRepository } from '@nestjs/typeorm'
import { PinoLogger } from 'nestjs-pino'
import { Repository } from 'typeorm' import { Repository } from 'typeorm'
import { FormEntity } from '../../entity/form.entity' import { FormEntity } from '../../entity/form.entity'
import { UserEntity } from '../../entity/user.entity' import { UserEntity } from '../../entity/user.entity'
@ -9,10 +10,12 @@ export class FormService {
constructor( constructor(
@InjectRepository(FormEntity) @InjectRepository(FormEntity)
private readonly formRepository: Repository<FormEntity>, private readonly formRepository: Repository<FormEntity>,
private readonly logger: PinoLogger,
) { ) {
logger.setContext(this.constructor.name)
} }
async isAdmin(form: FormEntity, user: UserEntity): Promise<boolean> { isAdmin(form: FormEntity, user: UserEntity): boolean {
if (!user) { if (!user) {
return false return false
} }
@ -24,7 +27,12 @@ export class FormService {
return form.admin.id === user.id return form.admin.id === user.id
} }
async find(start: number, limit: number, sort: any = {}, user?: UserEntity): Promise<[FormEntity[], number]> { async find(
start: number,
limit: number,
sort: any = {},
user?: UserEntity
): Promise<[FormEntity[], number]> {
const qb = this.formRepository.createQueryBuilder('f') const qb = this.formRepository.createQueryBuilder('f')
qb.leftJoinAndSelect('f.admin', 'a') qb.leftJoinAndSelect('f.admin', 'a')
@ -34,6 +42,9 @@ export class FormService {
} }
// TODO readd sort // TODO readd sort
this.logger.debug({
sort,
}, 'ignored sorting for submissions')
qb.skip(start) qb.skip(start)
qb.take(limit) qb.take(limit)

View File

@ -41,8 +41,12 @@ export class FormUpdateService {
} }
if (input.fields !== undefined) { if (input.fields !== undefined) {
form.fields = await Promise.all(input.fields.map(async (nextField) => { form.fields = input.fields.map((nextField) => {
let field = form.fields.find(field => field.id?.toString() === nextField.id) let field = this.findByIdInList(
form.fields,
nextField.id,
null
)
if (!field) { if (!field) {
field = new FormFieldEntity() field = new FormFieldEntity()
@ -75,7 +79,11 @@ export class FormUpdateService {
if (nextField.logic !== undefined) { if (nextField.logic !== undefined) {
field.logic = nextField.logic.map(nextLogic => { field.logic = nextField.logic.map(nextLogic => {
const logic = field.logic?.find(logic => logic.id?.toString() === nextLogic.id) || new FormFieldLogicEntity() const logic = this.findByIdInList(
field.logic,
nextLogic.id,
new FormFieldLogicEntity()
)
logic.field = field logic.field = field
@ -83,7 +91,7 @@ export class FormUpdateService {
logic.formula = nextLogic.formula logic.formula = nextLogic.formula
} }
if (nextLogic.action !== undefined) { if (nextLogic.action !== undefined) {
logic.action = nextLogic.action as any logic.action = nextLogic.action
} }
if (nextLogic.visible !== undefined) { if (nextLogic.visible !== undefined) {
logic.visible = nextLogic.visible logic.visible = nextLogic.visible
@ -95,7 +103,11 @@ export class FormUpdateService {
logic.disable = nextLogic.disable logic.disable = nextLogic.disable
} }
if (nextLogic.jumpTo !== undefined) { if (nextLogic.jumpTo !== undefined) {
logic.jumpTo = form.fields.find(value => value.id?.toString() === nextLogic.jumpTo) logic.jumpTo = this.findByIdInList(
form.fields,
nextLogic.jumpTo,
null
)
} }
if (nextLogic.enabled !== undefined) { if (nextLogic.enabled !== undefined) {
logic.enabled = nextLogic.enabled logic.enabled = nextLogic.enabled
@ -107,7 +119,11 @@ export class FormUpdateService {
if (nextField.options !== undefined) { if (nextField.options !== undefined) {
field.options = nextField.options.map(nextOption => { field.options = nextField.options.map(nextOption => {
const option = field.options?.find(option => option.id?.toString() === nextOption.id) || new FormFieldOptionEntity() const option = this.findByIdInList(
field.options,
nextOption.id,
new FormFieldOptionEntity()
)
option.field = field option.field = field
@ -124,13 +140,16 @@ export class FormUpdateService {
} }
return field return field
})) })
} }
if (input.hooks !== undefined) { if (input.hooks !== undefined) {
form.hooks = input.hooks.map((nextHook) => { form.hooks = input.hooks.map((nextHook) => {
const hook = form.hooks?.find(hook => hook.id?.toString() === nextHook.id) || new FormHookEntity() const hook = this.findByIdInList(
form.hooks,
nextHook.id,
new FormHookEntity()
)
// ability for other fields to apply mapping // ability for other fields to apply mapping
hook.url = nextHook.url hook.url = nextHook.url
@ -179,7 +198,11 @@ export class FormUpdateService {
if (input.notifications !== undefined) { if (input.notifications !== undefined) {
form.notifications = input.notifications.map(notificationInput => { form.notifications = input.notifications.map(notificationInput => {
const notification = form.notifications?.find(value => value.id?.toString() === notificationInput.id) || new FormNotificationEntity() const notification = this.findByIdInList(
form.notifications,
notificationInput.id,
new FormNotificationEntity()
)
notification.form = form notification.form = form
notification.enabled = notificationInput.enabled notification.enabled = notificationInput.enabled
@ -188,7 +211,11 @@ export class FormUpdateService {
notification.fromEmail = notificationInput.fromEmail notification.fromEmail = notificationInput.fromEmail
} }
if (notificationInput.fromField !== undefined) { if (notificationInput.fromField !== undefined) {
notification.fromField = form.fields.find(value => value.id?.toString() === notificationInput.fromField) notification.fromField = this.findByIdInList(
form.fields,
notificationInput.fromField,
null
)
} }
if (notificationInput.subject !== undefined) { if (notificationInput.subject !== undefined) {
notification.subject = notificationInput.subject notification.subject = notificationInput.subject
@ -200,7 +227,11 @@ export class FormUpdateService {
notification.toEmail = notificationInput.toEmail notification.toEmail = notificationInput.toEmail
} }
if (notificationInput.toField !== undefined) { if (notificationInput.toField !== undefined) {
notification.toField = form.fields.find(value => value.id?.toString() === notificationInput.toField) notification.toField = this.findByIdInList(
form.fields,
notificationInput.toField,
null
)
} }
return notification return notification
@ -240,7 +271,11 @@ export class FormUpdateService {
if (input.startPage.buttons !== undefined) { if (input.startPage.buttons !== undefined) {
form.startPage.buttons = input.startPage.buttons.map(buttonInput => { form.startPage.buttons = input.startPage.buttons.map(buttonInput => {
const entity = form.startPage?.buttons?.find(value => value.id?.toString() === buttonInput.id) || new PageButtonEntity() const entity = this.findByIdInList(
form.startPage?.buttons,
buttonInput.id,
new PageButtonEntity()
)
entity.page = form.startPage entity.page = form.startPage
entity.url = buttonInput.url entity.url = buttonInput.url
entity.action = buttonInput.action entity.action = buttonInput.action
@ -278,7 +313,11 @@ export class FormUpdateService {
if (input.endPage.buttons !== undefined) { if (input.endPage.buttons !== undefined) {
form.endPage.buttons = input.endPage.buttons.map(buttonInput => { form.endPage.buttons = input.endPage.buttons.map(buttonInput => {
const entity = form.endPage?.buttons?.find(value => value.id?.toString() === buttonInput.id) || new PageButtonEntity() const entity = this.findByIdInList(
form.endPage?.buttons,
buttonInput.id,
new PageButtonEntity()
)
entity.page = form.endPage entity.page = form.endPage
entity.url = buttonInput.url entity.url = buttonInput.url
entity.action = buttonInput.action entity.action = buttonInput.action
@ -296,4 +335,18 @@ export class FormUpdateService {
return form return form
} }
private findByIdInList<T>(list: T[], id: string, fallback: T): T {
if (!list) {
return fallback
}
const found = list.find((value: any) => String(value.id) === String(id))
if (found) {
return found
}
return fallback
}
} }

View File

@ -14,7 +14,7 @@ export class InstallationMetricsService implements OnApplicationBootstrap {
logger.setContext(this.constructor.name) logger.setContext(this.constructor.name)
} }
async onApplicationBootstrap(): Promise<void> { onApplicationBootstrap(): void {
if (this.configService.get<boolean>('DISABLE_INSTALLATION_METRICS')) { if (this.configService.get<boolean>('DISABLE_INSTALLATION_METRICS')) {
this.logger.info('installation metrics are disabled') this.logger.info('installation metrics are disabled')
return return
@ -42,6 +42,4 @@ export class InstallationMetricsService implements OnApplicationBootstrap {
}) })
}, 24 * 60 * 60 * 1000) }, 24 * 60 * 60 * 1000)
} }
} }

View File

@ -7,6 +7,7 @@ import htmlToText from 'html-to-text'
import mjml2html from 'mjml' import mjml2html from 'mjml'
import { PinoLogger } from 'nestjs-pino' import { PinoLogger } from 'nestjs-pino'
import { join } from 'path' import { join } from 'path'
import { serializeError } from 'serialize-error'
import { defaultLanguage } from '../config/languages' import { defaultLanguage } from '../config/languages'
@Injectable() @Injectable()
@ -19,10 +20,19 @@ export class MailService {
logger.setContext(this.constructor.name) logger.setContext(this.constructor.name)
} }
async send(to: string, template: string, context: { [key: string]: any }, language: string = defaultLanguage): Promise<boolean> { async send(
this.logger.info({ to: string,
template: string,
context: { [key: string]: any },
forceLanguage?: string
): Promise<boolean> {
const language = forceLanguage || this.configService.get('LOCALE', defaultLanguage)
this.logger.debug({
email: to, email: to,
}, `send email ${template}`) template,
}, 'try to send email')
try { try {
const path = this.getTemplatePath(template, language) const path = this.getTemplatePath(template, language)
@ -35,23 +45,36 @@ export class MailService {
} }
).html ).html
const text = htmlToText.fromString(html) const text = htmlToText.htmlToText(html)
const subject = /<title>(.*?)<\/title>/gi.test(html) ? /<title>(.*?)<\/title>/gi.exec(html)[1] : template const subject = this.extractSubject(html, template)
await this.nestMailer.sendMail({ to, subject, html, text }) await this.nestMailer.sendMail({ to, subject, html, text })
this.logger.info('sent email') this.logger.info({
email: to,
template,
language,
}, 'sent email')
} catch (error) { } catch (error) {
this.logger.error({ this.logger.error({
error: error.message, error: serializeError(error),
email: to, email: to,
}, `failed to send email ${template}`) template,
}, 'failed to send email')
return false return false
} }
return true return true
} }
private extractSubject(html: string, template: string): string {
if (/<title>(.*?)<\/title>/gi.test(html)) {
return /<title>(.*?)<\/title>/gi.exec(html)[1]
}
return template
}
private getTemplatePath(template: string, language: string): string { private getTemplatePath(template: string, language: string): string {
let templatePath = join(this.configService.get<string>('LOCALES_PATH'), language, 'mail', `${template}.mjml`) let templatePath = join(this.configService.get<string>('LOCALES_PATH'), language, 'mail', `${template}.mjml`)

View File

@ -4,6 +4,7 @@ import { Repository } from 'typeorm'
import { ProfileUpdateInput } from '../../dto/profile/profile.update.input' import { ProfileUpdateInput } from '../../dto/profile/profile.update.input'
import { UserEntity } from '../../entity/user.entity' import { UserEntity } from '../../entity/user.entity'
import { PasswordService } from '../auth/password.service' import { PasswordService } from '../auth/password.service'
import { UserTokenService } from '../user/user.token.service'
@Injectable() @Injectable()
export class ProfileUpdateService { export class ProfileUpdateService {
@ -11,9 +12,18 @@ export class ProfileUpdateService {
@InjectRepository(UserEntity) @InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>, private readonly userRepository: Repository<UserEntity>,
private readonly passwordService: PasswordService, private readonly passwordService: PasswordService,
private readonly userTokenService: UserTokenService,
) { ) {
} }
async verifyEmail(user: UserEntity, token: string): Promise<UserEntity> {
if (!await this.userTokenService.verify(token, user.token)) {
throw new Error('invalid token')
}
return await this.userRepository.save(user)
}
async update(user: UserEntity, input: ProfileUpdateInput): Promise<UserEntity> { async update(user: UserEntity, input: ProfileUpdateInput): Promise<UserEntity> {
if (input.firstName !== undefined) { if (input.firstName !== undefined) {
user.firstName = input.firstName user.firstName = input.firstName
@ -25,6 +35,7 @@ export class ProfileUpdateService {
if (input.email !== undefined && user.email !== input.email) { if (input.email !== undefined && user.email !== input.email) {
user.email = input.email user.email = input.email
user.emailVerified = false
// TODO request email verification // TODO request email verification
if (undefined !== await this.userRepository.findOne({ email: input.email })) { if (undefined !== await this.userRepository.findOne({ email: input.email })) {

View File

@ -17,7 +17,7 @@ export class SettingService {
].includes(key) ].includes(key)
} }
async getByKey(key: string): Promise<SettingModel> { getByKey(key: string): SettingModel {
switch (key) { switch (key) {
case 'SIGNUP_DISABLED': case 'SIGNUP_DISABLED':
case 'LOGIN_NOTE': case 'LOGIN_NOTE':
@ -29,11 +29,11 @@ export class SettingService {
throw new Error(`no config stored for key ${key}`) throw new Error(`no config stored for key ${key}`)
} }
async isTrue(key: string): Promise<boolean> { isTrue(key: string): boolean {
return (await this.getByKey(key)).isTrue return this.getByKey(key).isTrue
} }
async isFalse(key: string): Promise<boolean> { isFalse(key: string): boolean {
return (await this.getByKey(key)).isFalse return this.getByKey(key).isFalse
} }
} }

View File

@ -2,6 +2,8 @@ import { HttpService } from '@nestjs/axios'
import { Injectable } from '@nestjs/common' import { Injectable } from '@nestjs/common'
import handlebars from 'handlebars' import handlebars from 'handlebars'
import { PinoLogger } from 'nestjs-pino' import { PinoLogger } from 'nestjs-pino'
import { lastValueFrom } from 'rxjs'
import { serializeError } from 'serialize-error'
import { SubmissionEntity } from '../../entity/submission.entity' import { SubmissionEntity } from '../../entity/submission.entity'
@Injectable() @Injectable()
@ -20,15 +22,19 @@ export class SubmissionHookService {
} }
try { try {
const response = await this.httpService.post( const response = await lastValueFrom(this.httpService.post(
hook.url, hook.url,
this.format(submission, hook.format) this.format(submission, hook.format)
).toPromise() ))
console.log('sent hook', response.data) console.log('sent hook', response.data)
} catch (e) { } catch (e) {
this.logger.error(`failed to post to "${hook.url}: ${e.message}`) this.logger.error({
this.logger.error(e.stack) submission: submission.id,
form: submission.formId,
webhook: hook.url,
error: serializeError(e),
}, 'failed to post webhook')
throw e throw e
} }
})) }))

View File

@ -4,6 +4,7 @@ import handlebars from 'handlebars'
import htmlToText from 'html-to-text' import htmlToText from 'html-to-text'
import mjml2html from 'mjml' import mjml2html from 'mjml'
import { PinoLogger } from 'nestjs-pino' import { PinoLogger } from 'nestjs-pino'
import { serializeError } from 'serialize-error'
import { SubmissionEntity } from '../../entity/submission.entity' import { SubmissionEntity } from '../../entity/submission.entity'
@Injectable() @Injectable()
@ -22,15 +23,25 @@ export class SubmissionNotificationService {
} }
try { try {
const to = this.getEmail(submission.fields.find(field => field.fieldId === notification.toField.id )?.fieldValue, notification.toEmail) const to = this.getEmail(
const from = this.getEmail(submission.fields.find(field => field.fieldId === notification.fromField.id )?.fieldValue, notification.fromEmail) submission,
notification.toField.id,
notification.toEmail
)
const from = this.getEmail(
submission,
notification.fromField.id,
notification.fromEmail
)
const html = mjml2html( const template = handlebars.compile(
handlebars.compile( notification.htmlTemplate
notification.htmlTemplate )
)({
const html: string = mjml2html(
template({
// TODO add variables // TODO add variables
}), }) ,
{ {
minify: true, minify: true,
} }
@ -41,29 +52,30 @@ export class SubmissionNotificationService {
replyTo: from, replyTo: from,
subject: notification.subject, subject: notification.subject,
html, html,
text: htmlToText.fromString(html), text: htmlToText.htmlToText(html),
}) })
console.log('sent notification to', to) console.log('sent notification to', to)
} catch (e) { } catch (e) {
this.logger.error(e.stack) this.logger.error({
form: submission.formId,
submission: submission.id,
notification: notification.id,
error: serializeError(e),
}, 'failed to process notification')
throw e throw e
} }
})) }))
} }
private getEmail(raw: string, fallback: string): string { private getEmail(submission: SubmissionEntity, fieldId: number, fallback: string): string {
if (!raw) { const data = submission.fields.find(field => field.fieldId === fieldId)?.content
if (!data) {
return fallback return fallback
} }
try { if (typeof data.value === 'string') {
const data = JSON.parse(raw) return data.value
if (data.value) {
return data.value
}
} catch (e) {
this.logger.error('could not decode field value', raw)
} }
return fallback return fallback

View File

@ -1,4 +1,5 @@
import { InjectRepository } from '@nestjs/typeorm' import { InjectRepository } from '@nestjs/typeorm'
import { PinoLogger } from 'nestjs-pino'
import { Repository } from 'typeorm' import { Repository } from 'typeorm'
import { FormEntity } from '../../entity/form.entity' import { FormEntity } from '../../entity/form.entity'
import { SubmissionEntity } from '../../entity/submission.entity' import { SubmissionEntity } from '../../entity/submission.entity'
@ -8,15 +9,22 @@ export class SubmissionService {
constructor( constructor(
@InjectRepository(SubmissionEntity) @InjectRepository(SubmissionEntity)
private readonly submissionRepository: Repository<SubmissionEntity>, private readonly submissionRepository: Repository<SubmissionEntity>,
private readonly tokenService: SubmissionTokenService private readonly tokenService: SubmissionTokenService,
private readonly logger: PinoLogger,
) { ) {
this.logger.setContext(this.constructor.name)
} }
async isOwner(submission: SubmissionEntity, token: string): Promise<boolean> { async isOwner(submission: SubmissionEntity, token: string): Promise<boolean> {
return await this.tokenService.verify(token, submission.tokenHash) return this.tokenService.verify(token, submission.tokenHash)
} }
async find(form: FormEntity, start: number, limit: number, sort: any = {}): Promise<[SubmissionEntity[], number]> { async find(
form: FormEntity,
start: number,
limit: number,
sort: any = {}
): Promise<[SubmissionEntity[], number]> {
const qb = this.submissionRepository.createQueryBuilder('s') const qb = this.submissionRepository.createQueryBuilder('s')
qb.leftJoinAndSelect('s.fields', 'fields') qb.leftJoinAndSelect('s.fields', 'fields')
@ -24,6 +32,9 @@ export class SubmissionService {
qb.where('s.form = :form', { form: form.id }) qb.where('s.form = :form', { form: form.id })
// TODO readd sort // TODO readd sort
this.logger.debug({
sort,
}, 'ignored sorting for submissions')
qb.skip(start) qb.skip(start)
qb.take(limit) qb.take(limit)

View File

@ -2,10 +2,11 @@ import { Injectable } from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm' import { InjectRepository } from '@nestjs/typeorm'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { PinoLogger } from 'nestjs-pino' import { PinoLogger } from 'nestjs-pino'
import { serializeError } from 'serialize-error'
import { Repository } from 'typeorm' import { Repository } from 'typeorm'
import { SubmissionSetFieldInput } from '../../dto/submission/submission.set.field.input' import { SubmissionSetFieldInput } from '../../dto/submission/submission.set.field.input'
import { SubmissionEntity } from '../../entity/submission.entity' import { SubmissionEntity } from '../../entity/submission.entity'
import { SubmissionFieldEntity } from '../../entity/submission.field.entity' import { SubmissionFieldContent, SubmissionFieldEntity } from '../../entity/submission.field.entity'
import { SubmissionHookService } from './submission.hook.service' import { SubmissionHookService } from './submission.hook.service'
import { SubmissionNotificationService } from './submission.notification.service' import { SubmissionNotificationService } from './submission.notification.service'
@ -27,7 +28,7 @@ export class SubmissionSetFieldService {
let field = submission.fields.find(field => field.field.id.toString() === input.field) let field = submission.fields.find(field => field.field.id.toString() === input.field)
if (field) { if (field) {
field.fieldValue = JSON.parse(input.data) field.content = this.parseData(field, input.data)
await this.submissionFieldRepository.save(field) await this.submissionFieldRepository.save(field)
} else { } else {
@ -35,8 +36,8 @@ export class SubmissionSetFieldService {
field.submission = submission field.submission = submission
field.field = submission.form.fields.find(field => field.id.toString() === input.field) field.field = submission.form.fields.find(field => field.id.toString() === input.field)
field.fieldType = field.field.type field.type = field.field.type
field.fieldValue = JSON.parse(input.data) field.content = this.parseData(field, input.data)
field = await this.submissionFieldRepository.save(field) field = await this.submissionFieldRepository.save(field)
@ -51,11 +52,103 @@ export class SubmissionSetFieldService {
if (submission.percentageComplete === 1) { if (submission.percentageComplete === 1) {
this.webHook.process(submission).catch(e => { this.webHook.process(submission).catch(e => {
this.logger.error(`failed to send webhooks: ${e.message}`) this.logger.error({
submission: submission.id,
form: submission.formId,
error: serializeError(e),
}, 'failed to send webhooks')
}) })
this.notifications.process(submission).catch(e => { this.notifications.process(submission).catch(e => {
this.logger.error(`failed to send notifications: ${e.message}`) this.logger.error({
submission: submission.id,
form: submission.formId,
error: serializeError(e),
}, 'failed to send notifications')
}) })
} }
} }
private parseData(
field: SubmissionFieldEntity,
data: string
): SubmissionFieldContent {
let raw: { [key: string]: unknown }
const context = {
field: field.fieldId,
type: field.type,
}
try {
raw = JSON.parse(data) as { [key: string]: unknown }
} catch (e) {
this.logger.warn(context, 'received invalid data for field')
return null
}
if (typeof raw !== 'object' || Array.isArray(raw)) {
this.logger.warn(context, 'only object supported for data')
return null
}
// now ensure data structure
const result = {
value: null,
}
let valid = true
Object.keys(raw).forEach((key) => {
const value = raw[String(key)]
switch (typeof value) {
case 'number':
case 'string':
case 'boolean':
result[String(key)] = value
return
}
if (Array.isArray(value)) {
result[String(key)] = value.map((row: unknown, index) => {
switch (typeof value) {
case 'number':
case 'string':
case 'boolean':
case 'undefined':
return row
}
if (value === null) {
return row
}
this.logger.warn({
...context,
path: `${key}/${index}`,
}, 'invalid data in array')
valid = false
return null
})
return
}
this.logger.warn({
...context,
path: String(key),
}, 'invalid data in entry')
valid = false
})
if (!valid) {
this.logger.warn(context, 'invalid data in object entries')
return null
}
return result
}
} }

View File

@ -30,6 +30,8 @@ export class SubmissionStartService {
submission.timeElapsed = 0 submission.timeElapsed = 0
submission.percentageComplete = 0 submission.percentageComplete = 0
// TODO set country!
submission.device.language = input.device.language submission.device.language = input.device.language
submission.device.name = input.device.name submission.device.name = input.device.name
submission.device.type = input.device.type submission.device.type = input.device.type

View File

@ -1,12 +1,13 @@
import { Injectable } from '@nestjs/common' import { Injectable } from '@nestjs/common'
import * as bcrypt from 'bcrypt'
@Injectable() @Injectable()
export class SubmissionTokenService { export class SubmissionTokenService {
async hash(token: string): Promise<string> { async hash(token: string): Promise<string> {
return token return bcrypt.hash(token, 4)
} }
async verify(token: string, hash: string): Promise<boolean> { async verify(token: string, hash: string): Promise<boolean> {
return token == hash return await bcrypt.compare(token, hash)
} }
} }

View File

@ -1,6 +1,7 @@
import { Injectable, OnApplicationBootstrap } from '@nestjs/common' import { Injectable, OnApplicationBootstrap } from '@nestjs/common'
import { ConfigService } from '@nestjs/config' import { ConfigService } from '@nestjs/config'
import { PinoLogger } from 'nestjs-pino' import { PinoLogger } from 'nestjs-pino'
import { serializeError } from 'serialize-error'
import { UserCreateService } from './user.create.service' import { UserCreateService } from './user.create.service'
import { UserService } from './user.service' import { UserService } from './user.service'
@ -30,19 +31,15 @@ export class BootService implements OnApplicationBootstrap {
const email = this.configService.get<string>('ADMIN_EMAIL', 'admin@ohmyform.com') const email = this.configService.get<string>('ADMIN_EMAIL', 'admin@ohmyform.com')
const password = this.configService.get<string>('ADMIN_PASSWORD', 'root') const password = this.configService.get<string>('ADMIN_PASSWORD', 'root')
try { if (await this.userService.usernameInUse(username)) {
await this.userService.findByUsername(username)
this.logger.info('username already exists, skip creating') this.logger.info('username already exists, skip creating')
return return
} catch (e) {} }
try {
await this.userService.findByEmail(email)
if (await this.userService.emailInUse(email)) {
this.logger.info('email already exists, skip creating') this.logger.info('email already exists, skip creating')
return return
} catch (e) {} }
try { try {
await this.createService.create({ await this.createService.create({
@ -54,7 +51,7 @@ export class BootService implements OnApplicationBootstrap {
]) ])
} catch (e) { } catch (e) {
this.logger.error({ this.logger.error({
error: e, error: serializeError(e),
}, 'could not create admin user') }, 'could not create admin user')
return return
} }

View File

@ -3,6 +3,7 @@ import { UserCreateService } from './user.create.service'
import { UserDeleteService } from './user.delete.service' import { UserDeleteService } from './user.delete.service'
import { UserService } from './user.service' import { UserService } from './user.service'
import { UserStatisticService } from './user.statistic.service' import { UserStatisticService } from './user.statistic.service'
import { UserTokenService } from './user.token.service'
import { UserUpdateService } from './user.update.service' import { UserUpdateService } from './user.update.service'
export const userServices = [ export const userServices = [
@ -11,5 +12,6 @@ export const userServices = [
UserDeleteService, UserDeleteService,
UserService, UserService,
UserStatisticService, UserStatisticService,
UserTokenService,
UserUpdateService, UserUpdateService,
] ]

View File

@ -1,5 +1,7 @@
import { Injectable } from '@nestjs/common' import { Injectable } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { InjectRepository } from '@nestjs/typeorm' import { InjectRepository } from '@nestjs/typeorm'
import crypto from 'crypto'
import { PinoLogger } from 'nestjs-pino' import { PinoLogger } from 'nestjs-pino'
import { Repository } from 'typeorm' import { Repository } from 'typeorm'
import { rolesType } from '../../config/roles' import { rolesType } from '../../config/roles'
@ -8,6 +10,7 @@ import { UserEntity } from '../../entity/user.entity'
import { PasswordService } from '../auth/password.service' import { PasswordService } from '../auth/password.service'
import { MailService } from '../mail.service' import { MailService } from '../mail.service'
import { SettingService } from '../setting.service' import { SettingService } from '../setting.service'
import { UserTokenService } from './user.token.service'
@Injectable() @Injectable()
export class UserCreateService { export class UserCreateService {
@ -18,12 +21,14 @@ export class UserCreateService {
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
private readonly passwordService: PasswordService, private readonly passwordService: PasswordService,
private readonly settingService: SettingService, private readonly settingService: SettingService,
private readonly configService: ConfigService,
private readonly userTokenService: UserTokenService,
) { ) {
logger.setContext(this.constructor.name) logger.setContext(this.constructor.name)
} }
private async getDefaultRoles(): Promise<rolesType> { private getDefaultRoles(): rolesType {
const roleSetting = await this.settingService.getByKey('DEFAULT_ROLE') const roleSetting = this.settingService.getByKey('DEFAULT_ROLE')
switch (roleSetting.value) { switch (roleSetting.value) {
case 'superuser': case 'superuser':
@ -49,6 +54,8 @@ export class UserCreateService {
throw new Error('email already in use') throw new Error('email already in use')
} }
const confirmToken = crypto.randomBytes(30).toString('base64')
let user = new UserEntity() let user = new UserEntity()
user.provider = 'local' user.provider = 'local'
@ -57,17 +64,25 @@ export class UserCreateService {
user.firstName = input.firstName user.firstName = input.firstName
user.lastName = input.lastName user.lastName = input.lastName
user.language = input.language ?? 'en' user.language = input.language ?? 'en'
user.roles = roles ? roles : await this.getDefaultRoles() user.roles = roles ? roles : this.getDefaultRoles()
user.passwordHash = await this.passwordService.hash(input.password) user.passwordHash = await this.passwordService.hash(input.password)
user.token = await this.userTokenService.hash(confirmToken)
user = await this.userRepository.save(user) user = await this.userRepository.save(user)
const confirmUrl = [
this.configService.get('BASE_URL', 'http://localhost'),
this.configService.get('USER_CONFIRM_PATH', '/confirm?token={{token}}'),
]
.join('')
.replace('{{token}}', confirmToken)
const sent = await this.mailerService.send( const sent = await this.mailerService.send(
user.email, user.email,
'user/created', 'user/created',
{ {
username: user.username, username: user.username,
confirm: 'https://www.google.com', // TODO confirm url confirm: confirmUrl,
} }
) )

View File

@ -59,4 +59,16 @@ export class UserService {
return user return user
} }
async usernameInUse(username: string): Promise<boolean> {
return 0 !== await this.userRepository.count({
username,
})
}
async emailInUse(email: string): Promise<boolean> {
return 0 !== await this.userRepository.count({
email,
})
}
} }

View File

@ -0,0 +1,13 @@
import { Injectable } from '@nestjs/common'
import * as bcrypt from 'bcrypt'
@Injectable()
export class UserTokenService {
async hash(token: string): Promise<string> {
return bcrypt.hash(token, 4)
}
async verify(token: string, hash: string): Promise<boolean> {
return await bcrypt.compare(token, hash)
}
}

View File

@ -26,6 +26,12 @@ export class UserUpdateService {
if (input.email !== undefined) { if (input.email !== undefined) {
user.email = input.email user.email = input.email
user.emailVerified = false
// TODO request email verification
if (undefined !== await this.userRepository.findOne({ email: input.email })) {
throw new Error('email already in use')
}
} }
if (input.username !== undefined) { if (input.username !== undefined) {

View File

@ -898,7 +898,7 @@
tslib "~2.3.0" tslib "~2.3.0"
value-or-promise "1.0.11" value-or-promise "1.0.11"
"@graphql-tools/utils@7.10.0": "@graphql-tools/utils@7.10.0", "@graphql-tools/utils@^7.0.0":
version "7.10.0" version "7.10.0"
resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-7.10.0.tgz#07a4cb5d1bec1ff1dc1d47a935919ee6abd38699" resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-7.10.0.tgz#07a4cb5d1bec1ff1dc1d47a935919ee6abd38699"
integrity sha512-d334r6bo9mxdSqZW6zWboEnnOOFRrAPVQJ7LkU8/6grglrbcu6WhwCLzHb90E94JI3TD3ricC3YGbUqIi9Xg0w== integrity sha512-d334r6bo9mxdSqZW6zWboEnnOOFRrAPVQJ7LkU8/6grglrbcu6WhwCLzHb90E94JI3TD3ricC3YGbUqIi9Xg0w==
@ -907,7 +907,7 @@
camel-case "4.1.2" camel-case "4.1.2"
tslib "~2.2.0" tslib "~2.2.0"
"@graphql-tools/utils@^7.0.0", "@graphql-tools/utils@^7.1.2": "@graphql-tools/utils@^7.1.2":
version "7.8.0" version "7.8.0"
resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-7.8.0.tgz#74290863b5c84c1bf1d8749e7a05b1b029a0c55e" resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-7.8.0.tgz#74290863b5c84c1bf1d8749e7a05b1b029a0c55e"
integrity sha512-nORIltDwBdsc3Ew+vuXISTZw6gpRd3UkK+6HNY3knNca6apTDj7ygcJmgsFKjSyUf7xtukddTcF6Js9kPuJr6g== integrity sha512-nORIltDwBdsc3Ew+vuXISTZw6gpRd3UkK+6HNY3knNca6apTDj7ygcJmgsFKjSyUf7xtukddTcF6Js9kPuJr6g==
@ -1781,6 +1781,18 @@
resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.1.tgz#283f669ff76d7b8260df8ab7a4262cc83d988256" resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.1.tgz#283f669ff76d7b8260df8ab7a4262cc83d988256"
integrity sha512-fZQQafSREFyuZcdWFAExYjBiCL7AUCdgsk80iO0q4yihYYdcIiH28CcuPTGFgLOCC8RlW49GSQxdHwZP+I7CNg== integrity sha512-fZQQafSREFyuZcdWFAExYjBiCL7AUCdgsk80iO0q4yihYYdcIiH28CcuPTGFgLOCC8RlW49GSQxdHwZP+I7CNg==
"@types/mjml-core@*":
version "4.7.1"
resolved "https://registry.yarnpkg.com/@types/mjml-core/-/mjml-core-4.7.1.tgz#c2627499045b54eccfca38e2b532566fb0689189"
integrity sha512-k5IRafi93tyZBGF+0BTrcBDvG47OueI+Q7TC4V4UjGQn0AMVvL3Y+S26QF/UHMmMJW5r1hxLyv3StX2/+FatFg==
"@types/mjml@^4.7.0":
version "4.7.0"
resolved "https://registry.yarnpkg.com/@types/mjml/-/mjml-4.7.0.tgz#ea31b58008f54119efda9e673af674757d35981b"
integrity sha512-aWWu8Lxq2SexXGs+lBPRUpN3kFf0sDRo3Y4jz7BQ15cQvMfyZOadgFJsNlHmDqI6D2Qjx0PIK+1f9IMXgq9vTA==
dependencies:
"@types/mjml-core" "*"
"@types/node@*": "@types/node@*":
version "14.6.2" version "14.6.2"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.6.2.tgz#264b44c5a28dfa80198fc2f7b6d3c8a054b9491f" resolved "https://registry.yarnpkg.com/@types/node/-/node-14.6.2.tgz#264b44c5a28dfa80198fc2f7b6d3c8a054b9491f"