├── .DS_Store ├── .dockerignore ├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ └── main.yml ├── .gitignore ├── .prettierrc.js ├── Dockerfile ├── LICENSE ├── README.md ├── backend ├── .env.example ├── jest.config.js ├── nodemon.json ├── src │ ├── controllers │ │ ├── api.ts │ │ ├── application-controller.ts │ │ ├── dto.ts │ │ ├── settings-controller.ts │ │ ├── system-controller.ts │ │ └── users-controller.ts │ ├── entities │ │ ├── answer.ts │ │ ├── application-settings.ts │ │ ├── form-settings.ts │ │ ├── question-type.ts │ │ ├── question.ts │ │ ├── settings.ts │ │ ├── user-role.ts │ │ └── user.ts │ ├── interceptors │ │ └── response-interceptor.ts │ ├── middlewares │ │ ├── 404-middleware.ts │ │ └── error-handler-middleware.ts │ ├── services │ │ ├── application-service.ts │ │ ├── boot-shutdown-notification-service.ts │ │ ├── config-service.ts │ │ ├── database-service.ts │ │ ├── email-service.ts │ │ ├── email-template-service.ts │ │ ├── haveibeenpwned-service.ts │ │ ├── http-service.ts │ │ ├── index.ts │ │ ├── logger-service.ts │ │ ├── prune-service.ts │ │ ├── question-service.ts │ │ ├── settings-service.ts │ │ ├── slack-service.ts │ │ ├── tilt-service.ts │ │ ├── token-service.ts │ │ ├── unix-signal-service.ts │ │ └── user-service.ts │ ├── tilt.ts │ ├── usermod.ts │ └── utils │ │ └── switch.ts ├── test │ ├── controllers │ │ ├── settings-controller.spec.ts │ │ └── users-controller.spec.ts │ ├── middlewares │ │ ├── error-handler-middleware.spec.ts │ │ └── mock │ │ │ └── express.ts │ ├── services │ │ ├── application-service.spec.ts │ │ ├── config-service.spec.ts │ │ ├── haveibeenpwned-service.spec.ts │ │ ├── http-service.spec.ts │ │ ├── logger-service.spec.ts │ │ ├── mock │ │ │ ├── index.ts │ │ │ ├── mock-application-service.ts │ │ │ ├── mock-boot-shutdown-notification-service.ts │ │ │ ├── mock-config-service.ts │ │ │ ├── mock-database-service.ts │ │ │ ├── mock-email-service.ts │ │ │ ├── mock-email-template-service.ts │ │ │ ├── mock-haveibeenpwned-service.ts │ │ │ ├── mock-http-service.ts │ │ │ ├── mock-logger-service.ts │ │ │ ├── mock-prune-service.ts │ │ │ ├── mock-question-graph-service.ts │ │ │ ├── mock-settings-service.ts │ │ │ ├── mock-slack-service.ts │ │ │ ├── mock-token-service.ts │ │ │ ├── mock-unix-signal-service.ts │ │ │ └── mock-user-service.ts │ │ ├── question-service.spec.ts │ │ ├── settings-service.spec.ts │ │ ├── tilt-service.spec.ts │ │ ├── token-service.spec.ts │ │ └── user-service.spec.ts │ ├── setup.ts │ └── tilt.spec.ts ├── tsconfig.json └── tsconfig.prod.json ├── codecov.yml ├── docker-compose.yml ├── entrypoint.sh ├── frontend ├── .DS_Store ├── jest.config.js ├── src │ ├── api │ │ ├── index.ts │ │ └── types │ │ │ ├── controllers.ts │ │ │ ├── dto.ts │ │ │ └── enums.ts │ ├── authentication.ts │ ├── components │ │ ├── app.tsx │ │ ├── base │ │ │ ├── button.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── chevron.tsx │ │ │ ├── circle-chart.tsx │ │ │ ├── code.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── copyable-text.tsx │ │ │ ├── divider.tsx │ │ │ ├── elevated.tsx │ │ │ ├── error-boundary.tsx │ │ │ ├── flex.tsx │ │ │ ├── form-field-button.tsx │ │ │ ├── form-field.tsx │ │ │ ├── headings.tsx │ │ │ ├── image.tsx │ │ │ ├── link.tsx │ │ │ ├── markdown.tsx │ │ │ ├── message.tsx │ │ │ ├── muted.tsx │ │ │ ├── notification.tsx │ │ │ ├── placeholder.tsx │ │ │ ├── progress-step.tsx │ │ │ ├── select.tsx │ │ │ ├── simple-card.tsx │ │ │ ├── spinner.tsx │ │ │ ├── suspense-fallback.tsx │ │ │ ├── text-input.tsx │ │ │ ├── text.tsx │ │ │ ├── time-chart.tsx │ │ │ ├── titled-number.tsx │ │ │ └── worldmap.tsx │ │ ├── forms │ │ │ ├── choices-question-editor.tsx │ │ │ ├── choices-question.tsx │ │ │ ├── country-question-editor.tsx │ │ │ ├── country-question.tsx │ │ │ ├── form.tsx │ │ │ ├── number-question-editor.tsx │ │ │ ├── number-question.tsx │ │ │ ├── sorting-buttons.tsx │ │ │ ├── stringified-unified-question.tsx │ │ │ ├── text-question-editor.tsx │ │ │ ├── text-question.tsx │ │ │ ├── unified-question-editor.tsx │ │ │ └── unified-question.tsx │ │ ├── pages │ │ │ ├── admission.tsx │ │ │ ├── challenges.tsx │ │ │ ├── confirmation-form.tsx │ │ │ ├── forgot-password.tsx │ │ │ ├── lazy-admission.tsx │ │ │ ├── lazy-settings.tsx │ │ │ ├── lazy-statistics.tsx │ │ │ ├── lazy-system.tsx │ │ │ ├── login-form.tsx │ │ │ ├── map.tsx │ │ │ ├── page-not-found.tsx │ │ │ ├── page.tsx │ │ │ ├── profile-form.tsx │ │ │ ├── register-form.tsx │ │ │ ├── reset-password.tsx │ │ │ ├── settings.tsx │ │ │ ├── signup-done.tsx │ │ │ ├── statistics.tsx │ │ │ ├── status.tsx │ │ │ ├── system.tsx │ │ │ └── verify-email.tsx │ │ ├── routers │ │ │ ├── authenticated-router.tsx │ │ │ ├── lazy-authenticated-router.tsx │ │ │ ├── sidebar │ │ │ │ ├── sidebar-menu.tsx │ │ │ │ ├── sidebar-toggle.tsx │ │ │ │ └── sidebar.tsx │ │ │ └── unauthenticated-router.tsx │ │ └── settings │ │ │ ├── application-settings.tsx │ │ │ ├── email-settings.tsx │ │ │ ├── email-template-editor.tsx │ │ │ ├── form-editor.tsx │ │ │ ├── frontend-settings.tsx │ │ │ ├── question-editor.tsx │ │ │ ├── save-button.tsx │ │ │ └── settings-section.tsx │ ├── config.ts │ ├── contexts │ │ ├── login-context.tsx │ │ ├── notification-context.tsx │ │ └── settings-context.tsx │ ├── fortunes.ts │ ├── heuristics.ts │ ├── hooks │ │ ├── use-api.ts │ │ ├── use-context-or-throw.ts │ │ ├── use-derived-state.ts │ │ ├── use-focus.ts │ │ ├── use-fortune.ts │ │ ├── use-is-responsive.ts │ │ ├── use-media-query.ts │ │ ├── use-toggle.ts │ │ └── use-uniqe-id.ts │ ├── index.html │ ├── index.tsx │ ├── routes.ts │ ├── theme.tsx │ └── util.ts ├── test │ ├── __mocks__ │ │ └── api.ts │ ├── components │ │ └── button.spec.tsx │ └── setup.ts ├── tsconfig.json └── webpack.config.js ├── package-lock.json ├── package.json ├── proxy └── nginx.conf ├── tslint.json ├── unused-exports.ts └── yarn.lock /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackaburg/tilt/93a25d1fb389e886c812fa06e340b4be23978d34/.DS_Store -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | */dist/ 3 | .git/ 4 | */bundle/ 5 | */coverage/ 6 | db/ 7 | backend/.env 8 | */*.log 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | tab_width = 2 10 | trim_trailing_whitespace = true 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Tests and push apps 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | 11 | style: 12 | runs-on: ubuntu-latest 13 | steps: 14 | 15 | - name: Checkout repository 16 | uses: actions/checkout@master 17 | 18 | - name: Setup node.js 19 | uses: actions/setup-node@v2 20 | with: 21 | node-version: '16' 22 | 23 | - name: Install dependencies 24 | uses: borales/actions-yarn@v3.0.0 25 | with: 26 | cmd: install 27 | 28 | - name: prettier 29 | uses: borales/actions-yarn@v3.0.0 30 | with: 31 | cmd: prettier --check 32 | 33 | - name: lint 34 | uses: borales/actions-yarn@v3.0.0 35 | with: 36 | cmd: lint 37 | 38 | backend: 39 | runs-on: ubuntu-latest 40 | steps: 41 | 42 | - name: Checkout repository 43 | uses: actions/checkout@master 44 | 45 | - name: Setup node.js 46 | uses: actions/setup-node@v2 47 | with: 48 | node-version: '16' 49 | 50 | - name: Install dependencies 51 | uses: borales/actions-yarn@v3.0.0 52 | with: 53 | cmd: install 54 | 55 | - name: test 56 | uses: borales/actions-yarn@v3.0.0 57 | with: 58 | cmd: backend::test 59 | 60 | - name: codecov 61 | uses: borales/actions-yarn@v3.0.0 62 | with: 63 | cmd: backend::codecov 64 | 65 | frontend: 66 | runs-on: ubuntu-latest 67 | steps: 68 | 69 | - name: Checkout repository 70 | uses: actions/checkout@master 71 | 72 | - name: Setup node.js 73 | uses: actions/setup-node@v2 74 | with: 75 | node-version: '16' 76 | 77 | - name: Install dependencies 78 | uses: borales/actions-yarn@v3.0.0 79 | with: 80 | cmd: install 81 | 82 | - name: typecheck 83 | uses: borales/actions-yarn@v3.0.0 84 | with: 85 | cmd: frontend::typecheck 86 | 87 | - name: test 88 | uses: borales/actions-yarn@v3.0.0 89 | with: 90 | cmd: frontend::test 91 | 92 | - name: codecov 93 | uses: borales/actions-yarn@v3.0.0 94 | with: 95 | cmd: frontend::codecov 96 | 97 | build: 98 | runs-on: ubuntu-latest 99 | needs: [ style, backend, frontend ] 100 | #if: github.ref == 'refs/heads/main' 101 | steps: 102 | 103 | - name: Checkout repository 104 | uses: actions/checkout@master 105 | 106 | - name: Set up QEMU 107 | uses: docker/setup-qemu-action@v1 108 | 109 | - name: Set up Docker Buildx 110 | uses: docker/setup-buildx-action@v1 111 | 112 | - name: Login to DockerHub 113 | uses: docker/login-action@v1 114 | with: 115 | username: ${{ secrets.DOCKER_USER }} 116 | password: ${{ secrets.DOCKER_TOKEN }} 117 | 118 | - name: Build and push 119 | uses: docker/build-push-action@v2 120 | with: 121 | push: true 122 | tags: | 123 | hackaburg/tilt:latest 124 | 125 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | coverage/ 4 | .env 5 | *.log 6 | db/ 7 | *.swp 8 | frontend/bundle/ 9 | .vscode/ 10 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | bracketSpacing: true, 3 | printWidth: 80, 4 | parser: "typescript", 5 | trailingComma: "all", 6 | arrowParens: "always", 7 | overrides: [ 8 | { 9 | files: "README.md", 10 | options: { 11 | parser: "markdown", 12 | }, 13 | }, 14 | ], 15 | }; 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine AS build 2 | 3 | WORKDIR /app 4 | 5 | # bcrypt depends on node-pre-gyp 6 | RUN apk add --no-cache --virtual .gyp python3 make g++ 7 | 8 | COPY --chown=node:node package.json yarn.lock ./ 9 | RUN yarn install 10 | 11 | COPY --chown=node:node entrypoint.sh /app 12 | COPY --chown=node:node backend/ /app/backend/ 13 | COPY --chown=node:node frontend/ /app/frontend/ 14 | 15 | ENV NODE_OPTIONS=--openssl-legacy-provider 16 | 17 | RUN yarn backend::build && \ 18 | API_BASE_URL=/api yarn frontend::build && \ 19 | mv backend/dist/ tmp && rm -rf backend/ && mv tmp backend && \ 20 | mv frontend/dist/ tmp && rm -rf frontend/ && mv tmp frontend 21 | 22 | RUN yarn install --production 23 | 24 | FROM node:alpine 25 | 26 | WORKDIR /app 27 | USER node:node 28 | COPY --from=build --chown=node:node /app /app 29 | ENV HTTP_PUBLIC_DIRECTORY=/app/frontend 30 | CMD [ "/bin/sh", "/app/entrypoint.sh" ] 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright © 2019 ratisbona coding e.V. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /backend/.env.example: -------------------------------------------------------------------------------- 1 | # http port to listen on 2 | PORT=3000 3 | HTTP_PUBLIC_DIRECTORY=/app/public 4 | 5 | # log config 6 | LOG_LEVEL=info 7 | LOG_FILENAME=tilt.log 8 | 9 | # mail config 10 | MAIL_HOST=localhost 11 | MAIL_PORT=25 12 | MAIL_USERNAME=root@localhost 13 | MAIL_PASSWORD=password 14 | 15 | # mariadb database connection config 16 | DATABASE_NAME=tilt 17 | DATABASE_USERNAME=root 18 | DATABASE_PASSWORD=password 19 | DATABASE_PORT=3306 20 | DATABASE_HOST=localhost 21 | 22 | # secrets 23 | SECRET_JWT=wow_this_is_some_secure_password 24 | -------------------------------------------------------------------------------- /backend/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | setupFilesAfterEnv: ["/test/setup.ts"], 3 | transform: { 4 | "^.+\\.ts$": "ts-jest", 5 | }, 6 | testRegex: "/test/.*\\.spec\\.ts$", 7 | moduleFileExtensions: ["ts", "js"], 8 | }; 9 | -------------------------------------------------------------------------------- /backend/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": [ 3 | "src" 4 | ], 5 | "ext": "ts", 6 | "exec": "ts-node src/tilt.ts" 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/controllers/api.ts: -------------------------------------------------------------------------------- 1 | import { User } from "../entities/user"; 2 | 3 | type ExtractArguments = T extends (...args: infer Args) => any 4 | ? Args 5 | : never; 6 | 7 | type IgnoredArguments = User | string | undefined; 8 | type ExtractFirstArgument = ExtractArguments[0] extends IgnoredArguments 9 | ? never 10 | : ExtractArguments[0]; 11 | 12 | type ExtractReturnType = T extends (...args: any[]) => infer R ? R : never; 13 | type ExtractPromise = T extends Promise ? K : T; 14 | type ExtractAllFunctions = { 15 | [K in keyof T]: T[K] extends (...args: any[]) => any ? T[K] : never; 16 | }; 17 | 18 | /** 19 | * Type definitions for an API method. 20 | */ 21 | export interface IApiMethod { 22 | takes: TArgument; 23 | returns: TReturnValue; 24 | } 25 | 26 | /** 27 | * A successful response from the API. 28 | */ 29 | export interface ISuccessfulApiResponse { 30 | status: "ok"; 31 | data: T; 32 | } 33 | 34 | /** 35 | * A failed API response. 36 | */ 37 | export interface IErrorApiResponse { 38 | status: "error"; 39 | error: string; 40 | } 41 | 42 | /** 43 | * A response from the API. 44 | */ 45 | export type IApiResponse = ISuccessfulApiResponse | IErrorApiResponse; 46 | 47 | /** 48 | * Gets the actual value sent from the API. 49 | */ 50 | export type ExtractSuccessfulApiResponse = T extends IApiResponse 51 | ? K 52 | : never; 53 | 54 | /** 55 | * A request body to the API. 56 | */ 57 | export interface IApiRequest { 58 | data: T; 59 | } 60 | 61 | /** 62 | * Extracts all API methods from the given function into a new type. 63 | */ 64 | export type ExtractControllerMethods = { 65 | [K in keyof ExtractAllFunctions]: IApiMethod< 66 | ExtractFirstArgument, 67 | ISuccessfulApiResponse>> 68 | >; 69 | }; 70 | -------------------------------------------------------------------------------- /backend/src/controllers/settings-controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Authorized, 3 | BadRequestError, 4 | Body, 5 | Get, 6 | JsonController, 7 | Put, 8 | } from "routing-controllers"; 9 | import { Inject } from "typedi"; 10 | import { Settings } from "../entities/settings"; 11 | import { UserRole } from "../entities/user-role"; 12 | import { 13 | ISettingsService, 14 | SettingsServiceToken, 15 | UpdateSettingsError, 16 | } from "../services/settings-service"; 17 | import { 18 | convertBetweenEntityAndDTO, 19 | SettingsDTO, 20 | UpdateSettingsRequestDTO, 21 | } from "./dto"; 22 | 23 | @JsonController("/settings") 24 | export class SettingsController { 25 | public constructor( 26 | @Inject(SettingsServiceToken) private readonly _settings: ISettingsService, 27 | ) {} 28 | 29 | /** 30 | * Gets the application settings. 31 | */ 32 | @Get() 33 | public async getSettings(): Promise { 34 | const settings = await this._settings.getSettings(); 35 | return convertBetweenEntityAndDTO(settings, SettingsDTO); 36 | } 37 | 38 | /** 39 | * Updates the application settings. 40 | */ 41 | @Put() 42 | @Authorized(UserRole.Root) 43 | public async updateSettings( 44 | @Body() { data: settingsDTO }: UpdateSettingsRequestDTO, 45 | ): Promise { 46 | try { 47 | const settings = convertBetweenEntityAndDTO(settingsDTO, Settings); 48 | const updatedSettings = await this._settings.updateSettings(settings); 49 | return convertBetweenEntityAndDTO(updatedSettings, SettingsDTO); 50 | } catch (error) { 51 | if (error instanceof UpdateSettingsError) { 52 | throw new BadRequestError(error.message); 53 | } 54 | 55 | throw error; 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /backend/src/controllers/system-controller.ts: -------------------------------------------------------------------------------- 1 | import { Authorized, Delete, JsonController } from "routing-controllers"; 2 | import { Inject } from "typedi"; 3 | import { UserRole } from "../entities/user-role"; 4 | import { IPruneService, PruneServiceToken } from "../services/prune-service"; 5 | 6 | @JsonController("/system") 7 | export class SystemController { 8 | public constructor( 9 | @Inject(PruneServiceToken) private readonly _prune: IPruneService, 10 | ) {} 11 | 12 | /** 13 | * Prunes all user data. 14 | */ 15 | @Delete("/prune") 16 | @Authorized(UserRole.Root) 17 | public async prune(): Promise { 18 | await this._prune.prune(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /backend/src/entities/answer.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; 2 | import { Question } from "./question"; 3 | import { User } from "./user"; 4 | 5 | @Entity() 6 | export class Answer { 7 | @PrimaryGeneratedColumn() 8 | public readonly id!: number; 9 | @ManyToOne(() => User, { eager: true }) 10 | public user!: User; 11 | @ManyToOne(() => Question, { eager: true }) 12 | public question!: Question; 13 | @Column({ length: 1024 }) 14 | public value!: string; 15 | } 16 | -------------------------------------------------------------------------------- /backend/src/entities/application-settings.ts: -------------------------------------------------------------------------------- 1 | import { Type } from "class-transformer"; 2 | import { 3 | Column, 4 | Entity, 5 | JoinColumn, 6 | OneToOne, 7 | PrimaryGeneratedColumn, 8 | } from "typeorm"; 9 | import { FormSettings } from "./form-settings"; 10 | 11 | @Entity() 12 | export class ApplicationSettings { 13 | @PrimaryGeneratedColumn() 14 | public readonly id!: number; 15 | @Type(() => FormSettings) 16 | @OneToOne(() => FormSettings, { cascade: true, eager: true }) 17 | @JoinColumn() 18 | public profileForm!: FormSettings; 19 | @Type(() => FormSettings) 20 | @OneToOne(() => FormSettings, { cascade: true, eager: true }) 21 | @JoinColumn() 22 | public confirmationForm!: FormSettings; 23 | @Column() 24 | public allowProfileFormFrom!: Date; 25 | @Column() 26 | public allowProfileFormUntil!: Date; 27 | @Column() 28 | public hoursToConfirm!: number; 29 | } 30 | -------------------------------------------------------------------------------- /backend/src/entities/form-settings.ts: -------------------------------------------------------------------------------- 1 | import { Type } from "class-transformer"; 2 | import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm"; 3 | import { Question } from "./question"; 4 | 5 | @Entity() 6 | export class FormSettings { 7 | @PrimaryGeneratedColumn() 8 | public readonly id!: number; 9 | @Column() 10 | public title!: string; 11 | @Type(() => Question) 12 | @OneToMany(() => Question, (question) => question.form, { 13 | cascade: true, 14 | eager: true, 15 | }) 16 | public questions!: Question[]; 17 | } 18 | -------------------------------------------------------------------------------- /backend/src/entities/question-type.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * An enum containing all known question types. 3 | */ 4 | export const enum QuestionType { 5 | Text = "text", 6 | Number = "number", 7 | Choices = "choices", 8 | Country = "country", 9 | } 10 | -------------------------------------------------------------------------------- /backend/src/entities/question.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | ManyToOne, 6 | PrimaryGeneratedColumn, 7 | } from "typeorm"; 8 | import { FormSettings } from "./form-settings"; 9 | import { QuestionType } from "./question-type"; 10 | 11 | /** 12 | * A text question. 13 | */ 14 | export interface ITextQuestionConfiguration { 15 | type: QuestionType.Text; 16 | placeholder: string; 17 | multiline: boolean; 18 | convertAnswerToUrl: boolean; 19 | } 20 | 21 | /** 22 | * A number question. 23 | */ 24 | export interface INumberQuestionConfiguration { 25 | type: QuestionType.Number; 26 | placeholder: string; 27 | minValue?: number; 28 | maxValue?: number; 29 | allowDecimals: boolean; 30 | } 31 | 32 | /** 33 | * A question with choices. 34 | */ 35 | export interface IChoicesQuestionConfiguration { 36 | type: QuestionType.Choices; 37 | choices: string[]; 38 | allowMultiple: boolean; 39 | displayAsDropdown: boolean; 40 | } 41 | 42 | /** 43 | * A question with different countries. 44 | */ 45 | export interface ICountryQuestionConfiguration { 46 | type: QuestionType.Country; 47 | } 48 | 49 | /** 50 | * Union type for all known question configurations. 51 | */ 52 | export type IQuestionConfiguration = 53 | | ITextQuestionConfiguration 54 | | INumberQuestionConfiguration 55 | | IChoicesQuestionConfiguration 56 | | ICountryQuestionConfiguration; 57 | 58 | @Entity({ 59 | orderBy: { 60 | order: "ASC", 61 | }, 62 | }) 63 | export class Question { 64 | @PrimaryGeneratedColumn() 65 | public readonly id!: number; 66 | @CreateDateColumn() 67 | public readonly createdAt!: Date; 68 | @Column("simple-json") 69 | public configuration!: TQuestionConfiguration; 70 | @Column({ length: "1024" }) 71 | public description!: string; 72 | @Column() 73 | public title!: string; 74 | @Column() 75 | public mandatory!: boolean; 76 | @Column({ default: null, type: "int" }) 77 | public parentID!: number | null; 78 | @Column({ name: "parentValue", default: null, type: "varchar" }) 79 | public showIfParentHasValue!: string | null; 80 | @ManyToOne(() => FormSettings) 81 | public readonly form!: FormSettings; 82 | @Column({ default: 0 }) 83 | public order!: number; 84 | } 85 | -------------------------------------------------------------------------------- /backend/src/entities/settings.ts: -------------------------------------------------------------------------------- 1 | import { Type } from "class-transformer"; 2 | import { 3 | Column, 4 | Entity, 5 | JoinColumn, 6 | OneToOne, 7 | PrimaryGeneratedColumn, 8 | UpdateDateColumn, 9 | } from "typeorm"; 10 | import { ApplicationSettings } from "./application-settings"; 11 | 12 | @Entity() 13 | export class Settings { 14 | @PrimaryGeneratedColumn() 15 | public readonly id!: number; 16 | @UpdateDateColumn() 17 | public readonly updatedAt!: Date; 18 | @Type(() => ApplicationSettings) 19 | @OneToOne(() => ApplicationSettings, { cascade: true, eager: true }) 20 | @JoinColumn() 21 | public application!: ApplicationSettings; 22 | @Type(() => FrontendSettings) 23 | @Column(() => FrontendSettings) 24 | public frontend!: FrontendSettings; 25 | @Type(() => EmailSettings) 26 | @Column(() => EmailSettings) 27 | public email!: EmailSettings; 28 | } 29 | 30 | export class FrontendSettings { 31 | @Column() 32 | public colorGradientStart!: string; 33 | @Column() 34 | public colorGradientEnd!: string; 35 | @Column() 36 | public colorLink!: string; 37 | @Column() 38 | public colorLinkHover!: string; 39 | @Column() 40 | public loginSignupImage!: string; 41 | @Column() 42 | public sidebarImage!: string; 43 | } 44 | 45 | export class EmailSettings { 46 | @Column() 47 | public sender!: string; 48 | @Type(() => EmailTemplate) 49 | @Column(() => EmailTemplate) 50 | public verifyEmail!: EmailTemplate; 51 | @Type(() => EmailTemplate) 52 | @Column(() => EmailTemplate) 53 | public admittedEmail!: EmailTemplate; 54 | @Column(() => EmailTemplate) 55 | public forgotPasswordEmail!: EmailTemplate; 56 | } 57 | 58 | export class EmailTemplate { 59 | @Column() 60 | public subject!: string; 61 | @Column("text") 62 | public htmlTemplate!: string; 63 | @Column("text") 64 | public textTemplate!: string; 65 | } 66 | 67 | /** 68 | * Value number of characters that can be stored 69 | * in the database for the email template 70 | */ 71 | export const EmailTemplateSize = 65_536; 72 | -------------------------------------------------------------------------------- /backend/src/entities/user-role.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A user's role in tilt. 3 | */ 4 | export enum UserRole { 5 | /** 6 | * Superuser, can do everything. 7 | */ 8 | Root = "root", 9 | /** 10 | * Slightly elevated user. 11 | */ 12 | Moderator = "moderator", 13 | /** 14 | * Basic user. 15 | */ 16 | User = "user", 17 | } 18 | -------------------------------------------------------------------------------- /backend/src/entities/user.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | PrimaryGeneratedColumn, 6 | UpdateDateColumn, 7 | } from "typeorm"; 8 | import { UserRole } from "./user-role"; 9 | 10 | @Entity() 11 | export class User { 12 | @PrimaryGeneratedColumn() 13 | public readonly id!: number; 14 | @CreateDateColumn() 15 | public readonly createdAt!: Date; 16 | @UpdateDateColumn() 17 | public readonly updatedAt!: Date; 18 | @Column() 19 | public firstName!: string; 20 | @Column() 21 | public lastName!: string; 22 | @Column({ unique: true }) 23 | public email!: string; 24 | @Column({ select: false }) 25 | public password!: string; 26 | @Column() 27 | public tokenSecret!: string; 28 | @Column() 29 | public verifyToken!: string; 30 | @Column() 31 | public forgotPasswordToken!: string; 32 | @Column() 33 | public role!: UserRole; 34 | @Column({ default: null, type: "datetime" }) 35 | public initialProfileFormSubmittedAt!: Date | null; 36 | @Column({ default: null, type: "datetime" }) 37 | public confirmationExpiresAt!: Date | null; 38 | @Column({ default: false }) 39 | public admitted!: boolean; 40 | @Column({ default: false }) 41 | public confirmed!: boolean; 42 | @Column({ default: false }) 43 | public declined!: boolean; 44 | @Column({ default: false }) 45 | public checkedIn!: boolean; 46 | } 47 | -------------------------------------------------------------------------------- /backend/src/interceptors/response-interceptor.ts: -------------------------------------------------------------------------------- 1 | import { classToPlain } from "class-transformer"; 2 | import { Action, Interceptor, InterceptorInterface } from "routing-controllers"; 3 | import { IApiResponse } from "../controllers/api"; 4 | 5 | /** 6 | * An interceptor to modify responses according to @see IApiResponse 7 | */ 8 | @Interceptor() 9 | export class ResponseInterceptor implements InterceptorInterface { 10 | /** 11 | * Adjusts the response according to @see IApiResponse 12 | * @param action The action performed in the controller 13 | * @param result The controller's response 14 | */ 15 | public intercept(_action: Action, data: any): IApiResponse { 16 | return { 17 | data: classToPlain(data, { strategy: "excludeAll" }), 18 | status: "ok", 19 | }; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /backend/src/middlewares/404-middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from "express"; 2 | import { ExpressMiddlewareInterface, Middleware } from "routing-controllers"; 3 | import { IApiResponse } from "../controllers/api"; 4 | import { apiRoutePrefix } from "../services/http-service"; 5 | 6 | @Middleware({ type: "after", priority: 1000 }) 7 | export class FinalMiddleware implements ExpressMiddlewareInterface { 8 | /** 9 | * Sends a 404 error api response. 10 | * @param req The incoming request 11 | * @param res The response 12 | * @param next The express next function 13 | */ 14 | public use(req: Request, res: Response, next: NextFunction): void { 15 | if (!res.headersSent && req.path.startsWith(apiRoutePrefix)) { 16 | res.status(404); 17 | res.send({ 18 | error: `route ${req.path} not found`, 19 | status: "error", 20 | } as IApiResponse); 21 | res.end(); 22 | return; 23 | } 24 | 25 | next(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /backend/src/middlewares/error-handler-middleware.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from "class-validator"; 2 | import { NextFunction, Request, Response } from "express"; 3 | import { 4 | ExpressErrorMiddlewareInterface, 5 | Middleware, 6 | } from "routing-controllers"; 7 | import { Inject } from "typedi"; 8 | import { IApiResponse } from "../controllers/api"; 9 | import { 10 | ConfigurationServiceToken, 11 | IConfigurationService, 12 | } from "../services/config-service"; 13 | import { ILoggerService, LoggerServiceToken } from "../services/logger-service"; 14 | import { 15 | ISlackNotificationService, 16 | SlackNotificationServiceToken, 17 | } from "../services/slack-service"; 18 | 19 | /** 20 | * Get the first validation error message from an array of validation errors. 21 | * @param errors Errors from validating an object 22 | */ 23 | const findFirstValidationError = (errors: ValidationError[]): string => { 24 | for (const { constraints, children } of errors) { 25 | if (constraints) { 26 | const keys = Object.keys(constraints); 27 | 28 | if (keys.length > 0) { 29 | return constraints[keys[0]]; 30 | } 31 | } 32 | 33 | if (children) { 34 | const childrenConstraint = findFirstValidationError(children); 35 | 36 | if (childrenConstraint) { 37 | return childrenConstraint; 38 | } 39 | } 40 | } 41 | 42 | return ""; 43 | }; 44 | 45 | /** 46 | * An error handler, which transforms errors to @see IApiResponse. 47 | */ 48 | @Middleware({ type: "after", priority: 100 }) 49 | export class ErrorHandlerMiddleware implements ExpressErrorMiddlewareInterface { 50 | public constructor( 51 | @Inject(ConfigurationServiceToken) 52 | private readonly _config: IConfigurationService, 53 | @Inject(LoggerServiceToken) private readonly _logger: ILoggerService, 54 | @Inject(SlackNotificationServiceToken) 55 | private readonly _slack: ISlackNotificationService, 56 | ) {} 57 | 58 | /** 59 | * Sends an error message as defined in @see IApiResponse 60 | * @param error The thing that happened 61 | * @param _req The express request 62 | * @param res The express response 63 | * @param _next The express next function 64 | */ 65 | public error( 66 | error: any, 67 | _req: Request, 68 | res: Response, 69 | _next: NextFunction, 70 | ): void { 71 | const response: IApiResponse = { 72 | error: this._config.isProductionEnabled 73 | ? "An internal error ocurred" 74 | : error.message, 75 | status: "error", 76 | }; 77 | 78 | if (Array.isArray(error.errors)) { 79 | const validations = error.errors as ValidationError[]; 80 | response.error = 81 | findFirstValidationError(validations) || "unknown validation error"; 82 | } 83 | 84 | if (!error.httpCode) { 85 | this._logger.error(error.message, { stack: error.stack }); 86 | this._slack.sendMessage(`\`\`\`${error.stack}\`\`\``); 87 | } 88 | 89 | res.status(error.httpCode || 500); 90 | res.json(response); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /backend/src/services/boot-shutdown-notification-service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Service, Token } from "typedi"; 2 | import { IService } from "."; 3 | import { ILoggerService, LoggerServiceToken } from "./logger-service"; 4 | import { 5 | ISlackNotificationService, 6 | SlackNotificationServiceToken, 7 | } from "./slack-service"; 8 | import { 9 | IUnixSignalService, 10 | UnixSignalServiceToken, 11 | } from "./unix-signal-service"; 12 | 13 | /** 14 | * A service to notify about pending shutdowns. 15 | */ 16 | // tslint:disable-next-line: no-empty-interface 17 | export interface IBootShutdownNotificationService extends IService {} 18 | 19 | /** 20 | * A token used to inject a concrete boot shutdown notifier. 21 | */ 22 | export const BootShutdownNotificationServiceToken = 23 | new Token(); 24 | 25 | @Service(BootShutdownNotificationServiceToken) 26 | export class BootShutdownNotificationService 27 | implements IBootShutdownNotificationService 28 | { 29 | constructor( 30 | @Inject(LoggerServiceToken) private readonly _logger: ILoggerService, 31 | @Inject(SlackNotificationServiceToken) 32 | private readonly _slack: ISlackNotificationService, 33 | @Inject(UnixSignalServiceToken) 34 | private readonly _signals: IUnixSignalService, 35 | ) {} 36 | 37 | /** 38 | * Registers signals to listen on and notifies the configured Slack webhook about state changes. 39 | */ 40 | public async bootstrap(): Promise { 41 | this._signals.registerSignalHandler("SIGINT", async (signal) => { 42 | const message = `received signal "${signal}", shutting down`; 43 | 44 | await this._slack.sendMessage(message); 45 | this._logger.debug(message); 46 | }); 47 | 48 | await this._slack.sendMessage("tilt started"); 49 | this._logger.debug("registered signal handler"); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /backend/src/services/database-service.ts: -------------------------------------------------------------------------------- 1 | import { join } from "path"; 2 | import { Container, Inject, Service, Token } from "typedi"; 3 | import { 4 | Connection, 5 | createConnection, 6 | Repository, 7 | useContainer, 8 | } from "typeorm"; 9 | import { IService } from "."; 10 | import { 11 | ConfigurationServiceToken, 12 | IConfigurationService, 13 | } from "./config-service"; 14 | import { ILoggerService, LoggerServiceToken } from "./logger-service"; 15 | 16 | type Entity = new () => T; 17 | 18 | /** 19 | * An interface describing database access. 20 | */ 21 | export interface IDatabaseService extends IService { 22 | /** 23 | * Gets a repository for the given entity. 24 | * @param entity The entity to retrieve a repository for 25 | */ 26 | getRepository(entity: Entity): Repository; 27 | } 28 | 29 | /** 30 | * A token used to inject a database service implementation. 31 | */ 32 | export const DatabaseServiceToken = new Token(); 33 | 34 | /** 35 | * A service providing access to a database. 36 | */ 37 | @Service(DatabaseServiceToken) 38 | export class DatabaseService implements IDatabaseService { 39 | private _connection!: Connection; 40 | 41 | public constructor( 42 | @Inject(ConfigurationServiceToken) 43 | private readonly _config: IConfigurationService, 44 | @Inject(LoggerServiceToken) private readonly _logger: ILoggerService, 45 | ) {} 46 | 47 | /** 48 | * Connects to a database. 49 | */ 50 | public async bootstrap(): Promise { 51 | useContainer(Container); 52 | 53 | try { 54 | this._connection = await createConnection({ 55 | database: this._config.config.database.databaseName, 56 | entities: [join(__dirname, "../entities/*")], 57 | host: this._config.config.database.host, 58 | password: this._config.config.database.password, 59 | port: this._config.config.database.port, 60 | synchronize: true, 61 | type: "mariadb", 62 | username: this._config.config.database.username, 63 | }); 64 | 65 | this._logger.info( 66 | `connected to database on ${this._config.config.database.host}`, 67 | ); 68 | } catch (error) { 69 | this._logger.error(`unable to connect to database: ${error}`); 70 | process.exit(1); 71 | } 72 | } 73 | 74 | /** 75 | * Gets a repository for the given entity. 76 | * @param entity The entity to get a repository for 77 | */ 78 | public getRepository(entity: Entity): Repository { 79 | return this._connection.manager.getRepository(entity); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /backend/src/services/email-service.ts: -------------------------------------------------------------------------------- 1 | import { createTransport, Transporter } from "nodemailer"; 2 | import { Inject, Service, Token } from "typedi"; 3 | import { IService } from "."; 4 | import { 5 | ConfigurationServiceToken, 6 | IConfigurationService, 7 | } from "./config-service"; 8 | import { ILoggerService, LoggerServiceToken } from "./logger-service"; 9 | 10 | /** 11 | * Describes how to send emails. 12 | */ 13 | export interface IEmailService extends IService { 14 | /** 15 | * Sends a mail with the given properties. 16 | * @param from The sender email 17 | * @param to The receiver email 18 | * @param subject The subject 19 | * @param htmlBody The body in HTML 20 | * @param textBody The body in plaintext 21 | */ 22 | sendEmail( 23 | from: string, 24 | to: string, 25 | subject: string, 26 | htmlBody: string, 27 | textBody: string, 28 | ): Promise; 29 | } 30 | 31 | /** 32 | * A token used to inject a concrete email service. 33 | */ 34 | export const EmailServiceToken = new Token(); 35 | 36 | @Service(EmailServiceToken) 37 | export class EmailService implements IEmailService { 38 | private _transporter!: Transporter; 39 | 40 | public constructor( 41 | @Inject(ConfigurationServiceToken) 42 | private readonly _config: IConfigurationService, 43 | @Inject(LoggerServiceToken) private readonly _logger: ILoggerService, 44 | ) {} 45 | 46 | /** 47 | * Sets up the email service. 48 | */ 49 | public async bootstrap(): Promise { 50 | const { username, password, host, port } = this._config.config.mail; 51 | this._transporter = createTransport({ 52 | auth: { 53 | pass: password, 54 | user: username, 55 | }, 56 | host, 57 | pool: true, 58 | port, 59 | }); 60 | 61 | this._logger.info(`connected to smtp on ${host}`); 62 | } 63 | 64 | /** 65 | * Sends a mail with the given properties. 66 | * @param from The sender email 67 | * @param to The receiver email 68 | * @param subject The subject 69 | * @param htmlBody The body in HTML 70 | * @param textBody The body in plaintext 71 | */ 72 | public async sendEmail( 73 | from: string, 74 | to: string, 75 | subject: string, 76 | htmlBody: string, 77 | textBody: string, 78 | ): Promise { 79 | const info = await this._transporter.sendMail({ 80 | from, 81 | html: htmlBody, 82 | subject, 83 | text: textBody, 84 | to, 85 | }); 86 | 87 | this._logger.debug(`sent email to ${to}`, { ...info }); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /backend/src/services/haveibeenpwned-service.ts: -------------------------------------------------------------------------------- 1 | import { pwnedPassword } from "hibp"; 2 | import { Inject, Service, Token } from "typedi"; 3 | import { IService } from "."; 4 | import { 5 | ConfigurationServiceToken, 6 | IConfigurationService, 7 | } from "./config-service"; 8 | 9 | /** 10 | * A service to integrate haveibeenpwned. 11 | */ 12 | export interface IHaveibeenpwnedService extends IService { 13 | /** 14 | * Check how often a password was found in haveibeenpwned. 15 | * @param password The password to query against haveibeenpwned 16 | */ 17 | getPasswordUsedCount(password: string): Promise; 18 | } 19 | 20 | /** 21 | * A token used to inject a concrete haveibeenpwned service. 22 | */ 23 | export const HaveibeenpwnedServiceToken = new Token(); 24 | 25 | @Service(HaveibeenpwnedServiceToken) 26 | export class HaveibeenpwnedService implements IHaveibeenpwnedService { 27 | public constructor( 28 | @Inject(ConfigurationServiceToken) 29 | private readonly _config: IConfigurationService, 30 | ) {} 31 | 32 | /** 33 | * Sets up the haveibeenpwned integration. 34 | */ 35 | public async bootstrap(): Promise { 36 | return; 37 | } 38 | 39 | /** 40 | * Check how often a password was found in haveibeenpwned. 41 | * @param password The password to query against haveibeenpwned 42 | */ 43 | public async getPasswordUsedCount(password: string): Promise { 44 | if (!this._config.config.services.enableHaveibeenpwnedService) { 45 | return 0; 46 | } 47 | 48 | return await pwnedPassword(password); 49 | } 50 | } 51 | 52 | /** 53 | * An error indicating a reused password. 54 | */ 55 | export class PasswordReuseError extends Error { 56 | constructor(reuseCount: number) { 57 | super(`password was found ${reuseCount} times on haveibeenpwned.com`); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /backend/src/services/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * An interface describing services. 3 | */ 4 | export interface IService { 5 | /** 6 | * Bootstraps the service, i.e. does setup work. 7 | */ 8 | bootstrap(): Promise; 9 | } 10 | -------------------------------------------------------------------------------- /backend/src/services/prune-service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Service, Token } from "typedi"; 2 | import { Repository } from "typeorm"; 3 | import { IService } from "."; 4 | import { Answer } from "../entities/answer"; 5 | import { User } from "../entities/user"; 6 | import { DatabaseServiceToken, IDatabaseService } from "./database-service"; 7 | 8 | /** 9 | * A service to prune system data. 10 | */ 11 | export interface IPruneService extends IService { 12 | /** 13 | * Prunes user data but keeps all settings. 14 | */ 15 | prune(): Promise; 16 | } 17 | 18 | /** 19 | * A token used to inject a concrete prune service. 20 | */ 21 | export const PruneServiceToken = new Token(); 22 | 23 | @Service(PruneServiceToken) 24 | export class PruneService implements IPruneService { 25 | private _users!: Repository; 26 | private _answers!: Repository; 27 | 28 | public constructor( 29 | @Inject(DatabaseServiceToken) private readonly _database: IDatabaseService, 30 | ) {} 31 | 32 | /** 33 | * @inheritdoc 34 | */ 35 | public async bootstrap(): Promise { 36 | this._users = this._database.getRepository(User); 37 | this._answers = this._database.getRepository(Answer); 38 | } 39 | 40 | /** 41 | * @inheritdoc 42 | */ 43 | public async prune(): Promise { 44 | await this._answers.delete({}); 45 | await this._users.delete({}); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /backend/src/services/question-service.ts: -------------------------------------------------------------------------------- 1 | import { Service, Token } from "typedi"; 2 | import { IService } from "."; 3 | import { Question } from "../entities/question"; 4 | 5 | interface IQuestionGraphNode { 6 | question: Question; 7 | parentNode: IQuestionGraphNode | null; 8 | childNodes: IQuestionGraphNode[]; 9 | } 10 | 11 | type QuestionID = Question["id"]; 12 | 13 | /** 14 | * A graph of parent and child questions. 15 | */ 16 | export type QuestionGraph = ReadonlyMap; 17 | 18 | /** 19 | * A service to build question graphs. 20 | */ 21 | export interface IQuestionGraphService extends IService { 22 | /** 23 | * Generates a map containing a graph of the questions indexed by the 24 | * questions' reference names. 25 | * @param questions A list of questions to build a graph from 26 | */ 27 | buildQuestionGraph(questions: readonly Question[]): QuestionGraph; 28 | } 29 | 30 | /** 31 | * A token used to inject a concrete question graph service. 32 | */ 33 | export const QuestionGraphServiceToken = new Token(); 34 | 35 | @Service(QuestionGraphServiceToken) 36 | export class QuestionGraphService implements IQuestionGraphService { 37 | /** 38 | * @inheritdoc 39 | */ 40 | public async bootstrap(): Promise { 41 | return; 42 | } 43 | 44 | /** 45 | * Validates that no child questions from this question reference a previous question. 46 | * @param node The current node in the graph 47 | * @param path All already seen nodes in this search 48 | */ 49 | private throwOnCycleInNode( 50 | node: IQuestionGraphNode, 51 | path: IQuestionGraphNode[], 52 | ) { 53 | for (const child of node.childNodes) { 54 | if (path.includes(child)) { 55 | throw new CyclicQuestionGraphError([ 56 | ...path.map(({ question: { id } }) => id), 57 | child.question.id, 58 | ]); 59 | } 60 | 61 | this.throwOnCycleInNode(child, [...path, child]); 62 | } 63 | } 64 | 65 | /** 66 | * Walks the graph to verify there are no cycles. 67 | * @param graph The graph to validate 68 | */ 69 | private throwOnCycles(graph: QuestionGraph): void { 70 | for (const node of graph.values()) { 71 | this.throwOnCycleInNode(node, [node]); 72 | } 73 | } 74 | 75 | /** 76 | * @inheritdoc 77 | */ 78 | public buildQuestionGraph(questions: readonly Question[]): QuestionGraph { 79 | const graph = new Map(); 80 | 81 | for (const question of questions) { 82 | graph.set(question.id, { 83 | childNodes: [], 84 | parentNode: null, 85 | question, 86 | }); 87 | } 88 | 89 | for (const question of questions) { 90 | const node = graph.get(question.id); 91 | const parentQuestionID = node?.question?.parentID; 92 | 93 | if (!node || parentQuestionID == null) { 94 | continue; 95 | } 96 | 97 | const parentNode = graph.get(parentQuestionID); 98 | 99 | if (!parentNode) { 100 | throw new InvalidQuestionGraphError(); 101 | } 102 | 103 | if (node.parentNode != null) { 104 | throw new InvalidQuestionGraphError(); 105 | } 106 | 107 | node.parentNode = parentNode; 108 | parentNode.childNodes.push(node); 109 | } 110 | 111 | this.throwOnCycles(graph); 112 | 113 | return graph; 114 | } 115 | } 116 | 117 | export class InvalidQuestionGraphError extends Error { 118 | constructor() { 119 | super("Question graph is malformed"); 120 | } 121 | } 122 | 123 | export class CyclicQuestionGraphError extends Error { 124 | constructor(questionIDs: number[]) { 125 | super(`Cycle in question graph detected: ${questionIDs.join(" => ")}`); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /backend/src/services/slack-service.ts: -------------------------------------------------------------------------------- 1 | import { IncomingWebhook } from "@slack/webhook"; 2 | import { Inject, Service, Token } from "typedi"; 3 | import { IService } from "."; 4 | import { 5 | ConfigurationServiceToken, 6 | IConfigurationService, 7 | } from "./config-service"; 8 | 9 | /** 10 | * Describes a service to send Slack notifications. 11 | */ 12 | export interface ISlackNotificationService extends IService { 13 | /** 14 | * Sends a message to the configured Slack webhook. 15 | * @param text The message to send 16 | */ 17 | sendMessage(text: string): Promise; 18 | } 19 | 20 | /** 21 | * A token used to inject a concrete Slack notification service. 22 | */ 23 | export const SlackNotificationServiceToken = 24 | new Token(); 25 | 26 | @Service(SlackNotificationServiceToken) 27 | export class SlackNotificationService implements ISlackNotificationService { 28 | private _hook?: IncomingWebhook; 29 | 30 | constructor( 31 | @Inject(ConfigurationServiceToken) 32 | private readonly _config: IConfigurationService, 33 | ) {} 34 | 35 | /** 36 | * Sets up the Slack webhook. 37 | */ 38 | public async bootstrap(): Promise { 39 | const url = this._config.config.log.slackWebhookUrl; 40 | 41 | if (url) { 42 | this._hook = new IncomingWebhook(url); 43 | } 44 | } 45 | 46 | /** 47 | * Sends a message to the configured Slack webhook. 48 | * @param text The text to send 49 | */ 50 | public async sendMessage(text: string): Promise { 51 | if (!this._hook) { 52 | return; 53 | } 54 | 55 | await this._hook.send({ 56 | text, 57 | }); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /backend/src/services/tilt-service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Service } from "typedi"; 2 | import { IService } from "."; 3 | import { 4 | ApplicationServiceToken, 5 | IApplicationService, 6 | } from "./application-service"; 7 | import { 8 | BootShutdownNotificationServiceToken, 9 | IBootShutdownNotificationService, 10 | } from "./boot-shutdown-notification-service"; 11 | import { 12 | ConfigurationServiceToken, 13 | IConfigurationService, 14 | } from "./config-service"; 15 | import { DatabaseServiceToken, IDatabaseService } from "./database-service"; 16 | import { EmailServiceToken, IEmailService } from "./email-service"; 17 | import { 18 | EmailTemplateServiceToken, 19 | IEmailTemplateService, 20 | } from "./email-template-service"; 21 | import { 22 | HaveibeenpwnedServiceToken, 23 | IHaveibeenpwnedService, 24 | } from "./haveibeenpwned-service"; 25 | import { HttpServiceToken, IHttpService } from "./http-service"; 26 | import { ILoggerService, LoggerServiceToken } from "./logger-service"; 27 | import { IPruneService, PruneServiceToken } from "./prune-service"; 28 | import { 29 | IQuestionGraphService, 30 | QuestionGraphServiceToken, 31 | } from "./question-service"; 32 | import { ISettingsService, SettingsServiceToken } from "./settings-service"; 33 | import { 34 | ISlackNotificationService, 35 | SlackNotificationServiceToken, 36 | } from "./slack-service"; 37 | import { ITokenService, TokenServiceToken } from "./token-service"; 38 | import { 39 | IUnixSignalService, 40 | UnixSignalServiceToken, 41 | } from "./unix-signal-service"; 42 | import { IUserService, UserServiceToken } from "./user-service"; 43 | 44 | /** 45 | * The tilt service in a nutshell. Contains all services required to run tilt. 46 | */ 47 | @Service() 48 | export class Tilt implements IService { 49 | private readonly _services: IService[]; 50 | 51 | public constructor( 52 | @Inject(UnixSignalServiceToken) signals: IUnixSignalService, 53 | @Inject(HaveibeenpwnedServiceToken) haveibeenpwned: IHaveibeenpwnedService, 54 | @Inject(ConfigurationServiceToken) config: IConfigurationService, 55 | @Inject(LoggerServiceToken) logger: ILoggerService, 56 | @Inject(SlackNotificationServiceToken) slack: ISlackNotificationService, 57 | @Inject(BootShutdownNotificationServiceToken) 58 | bootShutdownNotifier: IBootShutdownNotificationService, 59 | @Inject(DatabaseServiceToken) database: IDatabaseService, 60 | @Inject(EmailServiceToken) email: IEmailService, 61 | @Inject(EmailTemplateServiceToken) emailTemplates: IEmailTemplateService, 62 | @Inject(TokenServiceToken) tokens: ITokenService, 63 | @Inject(UserServiceToken) users: IUserService, 64 | @Inject(SettingsServiceToken) settings: ISettingsService, 65 | @Inject(QuestionGraphServiceToken) questions: IQuestionGraphService, 66 | @Inject(ApplicationServiceToken) application: IApplicationService, 67 | @Inject(PruneServiceToken) prune: IPruneService, 68 | @Inject(HttpServiceToken) http: IHttpService, 69 | ) { 70 | this._services = [ 71 | signals, 72 | haveibeenpwned, 73 | config, 74 | logger, 75 | slack, 76 | bootShutdownNotifier, 77 | database, 78 | email, 79 | emailTemplates, 80 | tokens, 81 | users, 82 | settings, 83 | questions, 84 | application, 85 | prune, 86 | http, 87 | ]; 88 | } 89 | 90 | /** 91 | * Starts all tilt related services. 92 | */ 93 | public async bootstrap(): Promise { 94 | for (const service of this._services) { 95 | try { 96 | await service.bootstrap(); 97 | } catch (error) { 98 | const message = 99 | error instanceof Error ? `${error}\n${error.stack}` : String(error); 100 | 101 | // tslint:disable-next-line: no-console 102 | console.error(`unable to load service: ${message}`); 103 | process.exit(1); 104 | } 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /backend/src/services/token-service.ts: -------------------------------------------------------------------------------- 1 | import { decode, sign, verify } from "jsonwebtoken"; 2 | import { Inject, Service, Token } from "typedi"; 3 | import { IService } from "."; 4 | import { 5 | ConfigurationServiceToken, 6 | IConfigurationService, 7 | } from "./config-service"; 8 | 9 | /** 10 | * An interface providing access to JsonWebTokens. 11 | */ 12 | export interface ITokenService extends IService { 13 | /** 14 | * Encrypts data. 15 | * @param data The data to encrypt 16 | */ 17 | sign(data: T): string; 18 | 19 | /** 20 | * Decrypts the given data. 21 | */ 22 | decode(data: string): T; 23 | } 24 | 25 | /** 26 | * A token used to inject a concrete JsonWebToken service. 27 | */ 28 | export const TokenServiceToken = new Token>(); 29 | 30 | /** 31 | * A JsonWebToken service. 32 | */ 33 | @Service(TokenServiceToken) 34 | export class TokenService implements ITokenService { 35 | public constructor( 36 | @Inject(ConfigurationServiceToken) 37 | private readonly _config: IConfigurationService, 38 | ) {} 39 | 40 | /** 41 | * Bootstraps the service, i.e. noop. 42 | */ 43 | public async bootstrap(): Promise { 44 | return; 45 | } 46 | 47 | /** 48 | * Encrypts data. 49 | * @param data The data to encrypt 50 | */ 51 | public sign(data: any): string { 52 | return sign(data, this._config.config.secrets.jwtSecret, { 53 | expiresIn: "2w", 54 | }); 55 | } 56 | 57 | /** 58 | * Decrypts the given data. 59 | */ 60 | public decode(token: string): T { 61 | verify(token, this._config.config.secrets.jwtSecret); 62 | return decode(token) as T; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /backend/src/services/unix-signal-service.ts: -------------------------------------------------------------------------------- 1 | import { Service, Token } from "typedi"; 2 | import { IService } from "."; 3 | 4 | type ISignalHandler = (signal: string) => Promise; 5 | 6 | /** 7 | * A service to interact with unix signals. 8 | */ 9 | export interface IUnixSignalService extends IService { 10 | /** 11 | * Registers a handler for the given unix signal. 12 | * @param signal The signal to register 13 | * @param handler The handler to use for the signal 14 | */ 15 | registerSignalHandler( 16 | signal: NodeJS.Signals, 17 | handler: ISignalHandler, 18 | ): Promise; 19 | } 20 | 21 | /** 22 | * A token used to inject a concrete unix signal service. 23 | */ 24 | export const UnixSignalServiceToken = new Token(); 25 | 26 | @Service(UnixSignalServiceToken) 27 | export class UnixSignalService implements IUnixSignalService { 28 | private readonly _handlers: Map; 29 | 30 | constructor() { 31 | this._handlers = new Map(); 32 | } 33 | 34 | /** 35 | * Bootstraps the signal service, i.e. noop. 36 | */ 37 | public async bootstrap(): Promise { 38 | return; 39 | } 40 | 41 | /** 42 | * Executes all handlers for the given signal. 43 | * @param signal The signal to handle 44 | */ 45 | private async handleSignal(signal: NodeJS.Signals): Promise { 46 | const handlers = this._handlers.get(signal) || []; 47 | 48 | for (const handler of handlers) { 49 | await handler(signal); 50 | } 51 | } 52 | 53 | /** 54 | * Registers the given handler to execute for the given signal. 55 | * @param signal The signal to register 56 | * @param handler The handler to use for the signal 57 | */ 58 | public async registerSignalHandler( 59 | signal: NodeJS.Signals, 60 | handler: ISignalHandler, 61 | ): Promise { 62 | if (!this._handlers.has(signal)) { 63 | this._handlers.set(signal, [() => process.exit()]); 64 | 65 | process.on(signal, () => this.handleSignal(signal)); 66 | } 67 | 68 | this._handlers.get(signal)?.unshift(handler); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /backend/src/tilt.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | import { Container } from "typedi"; 3 | import { Tilt } from "./services/tilt-service"; 4 | 5 | const tilt = Container.get(Tilt); 6 | tilt.bootstrap(); 7 | -------------------------------------------------------------------------------- /backend/src/usermod.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | import { Container } from "typedi"; 3 | import { User } from "./entities/user"; 4 | import { UserRole } from "./entities/user-role"; 5 | import { ConfigurationServiceToken } from "./services/config-service"; 6 | import { DatabaseServiceToken } from "./services/database-service"; 7 | import { LoggerServiceToken } from "./services/logger-service"; 8 | 9 | (async () => { 10 | const config = Container.get(ConfigurationServiceToken); 11 | await config.bootstrap(); 12 | 13 | const logger = Container.get(LoggerServiceToken); 14 | await logger.bootstrap(); 15 | 16 | const argv = process.argv 17 | .slice(2) 18 | .map((arg) => arg.trim()) 19 | .filter((arg) => arg.length > 0); 20 | 21 | if (argv.length !== 2) { 22 | logger.error("invalid argument count provided. expected email and role"); 23 | process.exit(1); 24 | } 25 | 26 | const [email, role] = argv as [string, UserRole]; 27 | const validRoles = Object.values(UserRole); 28 | const isValidRole = validRoles.includes(role); 29 | 30 | if (!isValidRole) { 31 | const availableRoles = validRoles.join(", "); 32 | 33 | logger.error( 34 | `'${role}' is not a valid user role (available roles: ${availableRoles})`, 35 | ); 36 | 37 | process.exit(1); 38 | } 39 | 40 | const database = Container.get(DatabaseServiceToken); 41 | await database.bootstrap(); 42 | 43 | const repo = database.getRepository(User); 44 | const user = await repo.findOne({ 45 | email, 46 | }); 47 | 48 | if (!user) { 49 | logger.error(`no user with email '${email}' found`); 50 | process.exit(1); 51 | } 52 | 53 | user.role = role; 54 | await repo.save(user); 55 | 56 | logger.info(`user ${user.id} (${user.email}) set to ${user.role}`); 57 | process.exit(0); 58 | })(); 59 | -------------------------------------------------------------------------------- /backend/src/utils/switch.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Enforces switch statements to be exhaustive. 3 | * @example 4 | * ```ts 5 | * switch (foo) { 6 | * case Enum.Foo: 7 | * return something; 8 | * 9 | * default: enforceExhaustiveSwitch(foo); 10 | * } 11 | * ``` 12 | * @param argument The switch's argument 13 | */ 14 | export const enforceExhaustiveSwitch = (argument: never) => argument; 15 | -------------------------------------------------------------------------------- /backend/test/controllers/settings-controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { SettingsDTO } from "../../src/controllers/dto"; 2 | import { SettingsController } from "../../src/controllers/settings-controller"; 3 | import { ISettingsService } from "../../src/services/settings-service"; 4 | import { MockedService } from "../services/mock"; 5 | import { MockSettingsService } from "../services/mock/mock-settings-service"; 6 | 7 | describe("SettingsController", () => { 8 | let service: MockedService; 9 | let controller: SettingsController; 10 | 11 | beforeEach(async () => { 12 | service = new MockSettingsService(); 13 | controller = new SettingsController(service.instance); 14 | }); 15 | 16 | it("gets all settings", async () => { 17 | expect.assertions(1); 18 | 19 | const value = new SettingsDTO(); 20 | service.mocks.getSettings.mockResolvedValue(value); 21 | 22 | const settings = await controller.getSettings(); 23 | expect(settings).toMatchObject(value); 24 | }); 25 | 26 | it("updates settings", async () => { 27 | expect.assertions(1); 28 | 29 | const settings = new SettingsDTO(); 30 | await controller.updateSettings({ data: settings }); 31 | expect(service.mocks.updateSettings).toBeCalledWith(settings); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /backend/test/middlewares/mock/express.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { MockedService } from "../../services/mock"; 3 | 4 | /** 5 | * A mocked express request. 6 | */ 7 | export const MockRequest = jest.fn(() => new MockedService({} as any)); 8 | 9 | /** 10 | * A mocked express response. 11 | */ 12 | export const MockResponse = jest.fn( 13 | () => 14 | new MockedService({ 15 | json: jest.fn(), 16 | status: jest.fn(), 17 | } as Partial as Response), 18 | ); 19 | -------------------------------------------------------------------------------- /backend/test/services/config-service.spec.ts: -------------------------------------------------------------------------------- 1 | import { ConfigurationService } from "../../src/services/config-service"; 2 | 3 | describe("ConfigurationService", () => { 4 | let env: any; 5 | 6 | beforeAll(() => { 7 | env = process.env; 8 | process.env = { 9 | ...env, 10 | }; 11 | }); 12 | 13 | afterAll(() => { 14 | process.env = env; 15 | }); 16 | 17 | it("uses default config without a .env file", async () => { 18 | const dotenv = jest.fn(() => ({ error: new Error("no .env file found") })); 19 | jest.setMock("dotenv", dotenv); 20 | 21 | const service = new ConfigurationService(); 22 | await service.bootstrap(); 23 | expect(service.config.http.port).toBe(3000); 24 | }); 25 | 26 | it("extracts the environment into the config", async () => { 27 | const dotenv = jest.fn(() => ({})); 28 | jest.setMock("dotenv", dotenv); 29 | 30 | const port = 1337; 31 | process.env.PORT = `${port}`; 32 | 33 | const service = new ConfigurationService(); 34 | await service.bootstrap(); 35 | expect(service.config.http.port).toBe(port); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /backend/test/services/haveibeenpwned-service.spec.ts: -------------------------------------------------------------------------------- 1 | import { IConfigurationService } from "../../src/services/config-service"; 2 | import { 3 | HaveibeenpwnedService, 4 | IHaveibeenpwnedService, 5 | } from "../../src/services/haveibeenpwned-service"; 6 | import { MockedService } from "./mock"; 7 | import { MockConfigurationService } from "./mock/mock-config-service"; 8 | 9 | interface IMockedHIBP { 10 | pwnedPassword: jest.Mock; 11 | } 12 | 13 | jest.mock( 14 | "hibp", 15 | jest.fn( 16 | () => 17 | ({ 18 | pwnedPassword: jest.fn(), 19 | } as IMockedHIBP), 20 | ), 21 | ); 22 | 23 | describe("HaveibeenpwnedService", () => { 24 | let hibp: IMockedHIBP; 25 | let service: IHaveibeenpwnedService; 26 | let config: MockedService; 27 | 28 | beforeAll(() => { 29 | hibp = require("hibp"); 30 | }); 31 | 32 | beforeEach(async () => { 33 | config = new MockConfigurationService({ 34 | config: { 35 | services: { 36 | enableHaveibeenpwnedService: true, 37 | }, 38 | }, 39 | } as any); 40 | 41 | service = new HaveibeenpwnedService(config.instance); 42 | await service.bootstrap(); 43 | }); 44 | 45 | it("queries passwords", async () => { 46 | expect.assertions(1); 47 | 48 | const value = 100; 49 | hibp.pwnedPassword.mockResolvedValue(value); 50 | 51 | const count = await service.getPasswordUsedCount("password"); 52 | expect(count).toBe(value); 53 | }); 54 | 55 | it("ignores checks when not enabled", async () => { 56 | expect.assertions(1); 57 | 58 | config.instance.config.services.enableHaveibeenpwnedService = false; 59 | 60 | const value = 100; 61 | hibp.pwnedPassword.mockResolvedValue(value); 62 | 63 | const count = await service.getPasswordUsedCount("password"); 64 | expect(count).toBe(0); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /backend/test/services/mock/index.ts: -------------------------------------------------------------------------------- 1 | type MockedMethods = { 2 | [K in keyof T]: T[K] extends (...args: any[]) => any 3 | ? jest.Mock 4 | : T[K]; 5 | }; 6 | 7 | export class MockedService { 8 | public readonly mocks: MockedMethods; 9 | 10 | public constructor(public readonly instance: T) { 11 | this.mocks = instance as MockedMethods; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /backend/test/services/mock/mock-application-service.ts: -------------------------------------------------------------------------------- 1 | import { MockedService } from "."; 2 | import { IApplicationService } from "../../../src/services/application-service"; 3 | 4 | /** 5 | * A mocked application service. 6 | */ 7 | export const MockApplicationService = jest.fn( 8 | () => 9 | new MockedService({ 10 | admit: jest.fn(), 11 | bootstrap: jest.fn(), 12 | checkIn: jest.fn(), 13 | declineSpot: jest.fn(), 14 | deleteAnswers: jest.fn(), 15 | getAll: jest.fn(), 16 | getConfirmationForm: jest.fn(), 17 | getProfileForm: jest.fn(), 18 | storeConfirmationFormAnswers: jest.fn(), 19 | storeProfileFormAnswers: jest.fn(), 20 | }), 21 | ); 22 | -------------------------------------------------------------------------------- /backend/test/services/mock/mock-boot-shutdown-notification-service.ts: -------------------------------------------------------------------------------- 1 | import { MockedService } from "."; 2 | import { IBootShutdownNotificationService } from "../../../src/services/boot-shutdown-notification-service"; 3 | 4 | /** 5 | * A mocked boot shutdown notifier service. 6 | */ 7 | export const MockBootShutdownNotifier = jest.fn( 8 | () => 9 | new MockedService({ 10 | bootstrap: jest.fn(), 11 | }), 12 | ); 13 | -------------------------------------------------------------------------------- /backend/test/services/mock/mock-config-service.ts: -------------------------------------------------------------------------------- 1 | import { MockedService } from "."; 2 | import { IConfigurationService } from "../../../src/services/config-service"; 3 | 4 | /** 5 | * A mocked configuration service. 6 | */ 7 | export const MockConfigurationService = jest.fn( 8 | ({ config, isProductionEnabled }: Partial) => 9 | new MockedService({ 10 | bootstrap: jest.fn(), 11 | config: config!, 12 | isProductionEnabled: isProductionEnabled!, 13 | }), 14 | ); 15 | -------------------------------------------------------------------------------- /backend/test/services/mock/mock-database-service.ts: -------------------------------------------------------------------------------- 1 | import { join } from "path"; 2 | import { Connection, createConnection, Repository } from "typeorm"; 3 | import { MockedService } from "."; 4 | import { IDatabaseService } from "../../../src/services/database-service"; 5 | 6 | /** 7 | * A mocked database service. 8 | */ 9 | export const MockDatabaseService = jest.fn( 10 | () => 11 | new MockedService({ 12 | bootstrap: jest.fn(), 13 | getRepository: jest.fn(), 14 | }), 15 | ); 16 | 17 | export class TestDatabaseService implements IDatabaseService { 18 | private _connection!: Connection; 19 | 20 | /** 21 | * Completely drops the database and recreates the schema. 22 | */ 23 | public async nuke(): Promise { 24 | await this._connection.synchronize(true); 25 | } 26 | 27 | /** 28 | * @inheritdoc 29 | */ 30 | public getRepository(entity: new () => T): Repository { 31 | return this._connection.getRepository(entity); 32 | } 33 | 34 | /** 35 | * @inheritdoc 36 | */ 37 | public async bootstrap(): Promise { 38 | this._connection = await createConnection({ 39 | database: ":memory:", 40 | entities: [join(__dirname, "../../../src/entities/*")], 41 | synchronize: true, 42 | type: "sqlite", 43 | }); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /backend/test/services/mock/mock-email-service.ts: -------------------------------------------------------------------------------- 1 | import { MockedService } from "."; 2 | import { IEmailService } from "../../../src/services/email-service"; 3 | 4 | /** 5 | * A mocked email service. 6 | */ 7 | export const MockEmailService = jest.fn( 8 | () => 9 | new MockedService({ 10 | bootstrap: jest.fn(), 11 | sendEmail: jest.fn(), 12 | }), 13 | ); 14 | -------------------------------------------------------------------------------- /backend/test/services/mock/mock-email-template-service.ts: -------------------------------------------------------------------------------- 1 | import { MockedService } from "."; 2 | import { IEmailTemplateService } from "../../../src/services/email-template-service"; 3 | 4 | /** 5 | * A mocked email template service. 6 | */ 7 | export const MockEmailTemplateService = jest.fn( 8 | () => 9 | new MockedService({ 10 | bootstrap: jest.fn(), 11 | sendAdmittedEmail: jest.fn(), 12 | sendVerifyEmail: jest.fn(), 13 | sendForgotPasswordEmail: jest.fn(), 14 | }), 15 | ); 16 | -------------------------------------------------------------------------------- /backend/test/services/mock/mock-haveibeenpwned-service.ts: -------------------------------------------------------------------------------- 1 | import { MockedService } from "."; 2 | import { IHaveibeenpwnedService } from "../../../src/services/haveibeenpwned-service"; 3 | 4 | /** 5 | * A mocked haveibeenpwned service. 6 | */ 7 | export const MockHaveibeenpwnedService = jest.fn( 8 | () => 9 | new MockedService({ 10 | bootstrap: jest.fn(), 11 | getPasswordUsedCount: jest.fn(), 12 | }), 13 | ); 14 | -------------------------------------------------------------------------------- /backend/test/services/mock/mock-http-service.ts: -------------------------------------------------------------------------------- 1 | import { MockedService } from "."; 2 | import { IHttpService } from "../../../src/services/http-service"; 3 | 4 | /** 5 | * A mocked http service. 6 | */ 7 | export const MockHttpService = jest.fn( 8 | () => 9 | new MockedService({ 10 | bootstrap: jest.fn(), 11 | getCurrentUser: jest.fn(), 12 | isActionAuthorized: jest.fn(), 13 | }), 14 | ); 15 | -------------------------------------------------------------------------------- /backend/test/services/mock/mock-logger-service.ts: -------------------------------------------------------------------------------- 1 | import { MockedService } from "."; 2 | import { ILoggerService } from "../../../src/services/logger-service"; 3 | 4 | /** 5 | * A mocked logger service. 6 | */ 7 | export const MockLoggerService = jest.fn( 8 | () => 9 | new MockedService({ 10 | bootstrap: jest.fn(), 11 | debug: jest.fn(), 12 | error: jest.fn(), 13 | info: jest.fn(), 14 | }), 15 | ); 16 | -------------------------------------------------------------------------------- /backend/test/services/mock/mock-prune-service.ts: -------------------------------------------------------------------------------- 1 | import { MockedService } from "."; 2 | import { IPruneService } from "../../../src/services/prune-service"; 3 | 4 | /** 5 | * A mocked system prune service. 6 | */ 7 | export const MockPruneService = jest.fn( 8 | () => 9 | new MockedService({ 10 | bootstrap: jest.fn(), 11 | prune: jest.fn(), 12 | }), 13 | ); 14 | -------------------------------------------------------------------------------- /backend/test/services/mock/mock-question-graph-service.ts: -------------------------------------------------------------------------------- 1 | import { MockedService } from "."; 2 | import { IQuestionGraphService } from "../../../src/services/question-service"; 3 | 4 | /** 5 | * A mocked question graph service. 6 | */ 7 | export const MockQuestionGraphService = jest.fn( 8 | () => 9 | new MockedService({ 10 | bootstrap: jest.fn(), 11 | buildQuestionGraph: jest.fn(), 12 | }), 13 | ); 14 | -------------------------------------------------------------------------------- /backend/test/services/mock/mock-settings-service.ts: -------------------------------------------------------------------------------- 1 | import { MockedService } from "."; 2 | import { ISettingsService } from "../../../src/services/settings-service"; 3 | 4 | /** 5 | * A mocked settings service. 6 | */ 7 | export const MockSettingsService = jest.fn( 8 | () => 9 | new MockedService({ 10 | bootstrap: jest.fn(), 11 | getSettings: jest.fn(), 12 | updateSettings: jest.fn(), 13 | }), 14 | ); 15 | -------------------------------------------------------------------------------- /backend/test/services/mock/mock-slack-service.ts: -------------------------------------------------------------------------------- 1 | import { MockedService } from "."; 2 | import { ISlackNotificationService } from "../../../src/services/slack-service"; 3 | 4 | /** 5 | * A mocked slack service. 6 | */ 7 | export const MockSlackNotificationService = jest.fn( 8 | () => 9 | new MockedService({ 10 | bootstrap: jest.fn(), 11 | sendMessage: jest.fn(), 12 | }), 13 | ); 14 | -------------------------------------------------------------------------------- /backend/test/services/mock/mock-token-service.ts: -------------------------------------------------------------------------------- 1 | import { MockedService } from "."; 2 | import { ITokenService } from "../../../src/services/token-service"; 3 | 4 | /** 5 | * A mocked token service. 6 | */ 7 | export const MockTokenService = jest.fn( 8 | () => 9 | new MockedService>({ 10 | bootstrap: jest.fn(), 11 | decode: jest.fn(), 12 | sign: jest.fn(), 13 | }), 14 | ); 15 | -------------------------------------------------------------------------------- /backend/test/services/mock/mock-unix-signal-service.ts: -------------------------------------------------------------------------------- 1 | import { MockedService } from "."; 2 | import { IUnixSignalService } from "../../../src/services/unix-signal-service"; 3 | 4 | /** 5 | * A mocked unix signal service. 6 | */ 7 | export const MockUnixSignalService = jest.fn( 8 | () => 9 | new MockedService({ 10 | bootstrap: jest.fn(), 11 | registerSignalHandler: jest.fn(), 12 | }), 13 | ); 14 | -------------------------------------------------------------------------------- /backend/test/services/mock/mock-user-service.ts: -------------------------------------------------------------------------------- 1 | import { MockedService } from "."; 2 | import { IUserService } from "../../../src/services/user-service"; 3 | 4 | /** 5 | * A mocked user service. 6 | */ 7 | export const MockUserService = jest.fn( 8 | () => 9 | new MockedService({ 10 | bootstrap: jest.fn(), 11 | deleteUser: jest.fn(), 12 | findAll: jest.fn(), 13 | findUserByLoginToken: jest.fn(), 14 | findUserWithCredentials: jest.fn(), 15 | findUsersByIDs: jest.fn(), 16 | generateLoginToken: jest.fn(), 17 | signup: jest.fn(), 18 | updateUser: jest.fn(), 19 | updateUsers: jest.fn(), 20 | verifyUserByVerifyToken: jest.fn(), 21 | verifyUserResetPassword: jest.fn(), 22 | forgotPassword: jest.fn(), 23 | getUser: jest.fn(), 24 | }), 25 | ); 26 | -------------------------------------------------------------------------------- /backend/test/services/question-service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Question } from "../../src/entities/question"; 2 | import { 3 | IQuestionGraphService, 4 | QuestionGraphService, 5 | } from "../../src/services/question-service"; 6 | 7 | describe("QuestionGraphService", () => { 8 | let service: IQuestionGraphService; 9 | 10 | const createQuestion = (id: number, parent?: Question): Question => { 11 | const question = new Question(); 12 | (question as any).id = id; 13 | question.parentID = parent?.id ?? null; 14 | return question; 15 | }; 16 | 17 | beforeEach(async () => { 18 | service = new QuestionGraphService(); 19 | await service.bootstrap(); 20 | }); 21 | 22 | it("builds graphs", () => { 23 | const question1 = createQuestion(1); 24 | const question2 = createQuestion(2); 25 | const question3 = createQuestion(3); 26 | 27 | const question4 = createQuestion(4, question1); 28 | const question5 = createQuestion(5, question2); 29 | 30 | const question6 = createQuestion(6, question4); 31 | const question7 = createQuestion(7, question6); 32 | const question8 = createQuestion(8, question6); 33 | 34 | const questions = [ 35 | question1, 36 | question2, 37 | question3, 38 | question4, 39 | question5, 40 | question6, 41 | question7, 42 | question8, 43 | ]; 44 | 45 | const graph = service.buildQuestionGraph(questions); 46 | 47 | const allNodes = [...graph.values()]; 48 | expect(allNodes).toHaveLength(questions.length); 49 | 50 | const node4 = graph.get(question4.id); 51 | expect(node4).toBeDefined(); 52 | expect(node4?.childNodes).toHaveLength(1); 53 | expect(node4?.parentNode?.question.id).toBe(question4.parentID); 54 | 55 | const node6 = graph.get(question6.id); 56 | expect(node6).toBeDefined(); 57 | expect(node6?.parentNode?.question.id).toBe(question4.id); 58 | expect(node6?.childNodes).toHaveLength(2); 59 | expect(node6?.childNodes[0].question.id).toBe(question7.id); 60 | expect(node6?.childNodes[1].question.id).toBe(question8.id); 61 | }); 62 | 63 | it("detects cycles", () => { 64 | const question1 = createQuestion(1); 65 | const question2 = createQuestion(2, question1); 66 | const question3 = createQuestion(3, question2); 67 | const question4 = createQuestion(4, question3); 68 | 69 | // 1 => 2 => 3 => 4 => 1 70 | question1.parentID = question4.id; 71 | 72 | const questions = [question1, question2, question3, question4]; 73 | expect(() => service.buildQuestionGraph(questions)).toThrow(); 74 | }); 75 | 76 | it("throws on invalid graphs", () => { 77 | const question1 = createQuestion(1); 78 | const question2 = createQuestion(2, question1); 79 | 80 | // question1 is an unknown question 81 | const questions = [question2]; 82 | expect(() => service.buildQuestionGraph(questions)).toThrow(); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /backend/test/services/tilt-service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Tilt } from "../../src/services/tilt-service"; 2 | import { MockedService } from "./mock"; 3 | import { MockApplicationService } from "./mock/mock-application-service"; 4 | import { MockBootShutdownNotifier } from "./mock/mock-boot-shutdown-notification-service"; 5 | import { MockConfigurationService } from "./mock/mock-config-service"; 6 | import { MockDatabaseService } from "./mock/mock-database-service"; 7 | import { MockEmailService } from "./mock/mock-email-service"; 8 | import { MockEmailTemplateService } from "./mock/mock-email-template-service"; 9 | import { MockHaveibeenpwnedService } from "./mock/mock-haveibeenpwned-service"; 10 | import { MockHttpService } from "./mock/mock-http-service"; 11 | import { MockLoggerService } from "./mock/mock-logger-service"; 12 | import { MockPruneService } from "./mock/mock-prune-service"; 13 | import { MockQuestionGraphService } from "./mock/mock-question-graph-service"; 14 | import { MockSettingsService } from "./mock/mock-settings-service"; 15 | import { MockSlackNotificationService } from "./mock/mock-slack-service"; 16 | import { MockTokenService } from "./mock/mock-token-service"; 17 | import { MockUnixSignalService } from "./mock/mock-unix-signal-service"; 18 | import { MockUserService } from "./mock/mock-user-service"; 19 | 20 | describe("TiltService", () => { 21 | it("bootstraps all services", async () => { 22 | const services: MockedService[] = []; 23 | const addService = >(service: T) => { 24 | services.push(service); 25 | return service; 26 | }; 27 | 28 | const signals = addService(new MockUnixSignalService()); 29 | const logger = addService(new MockLoggerService()); 30 | const config = addService(new MockConfigurationService({})); 31 | const database = addService(new MockDatabaseService()); 32 | const users = addService(new MockUserService()); 33 | const http = addService(new MockHttpService()); 34 | const tokens = addService(new MockTokenService()); 35 | const settings = addService(new MockSettingsService()); 36 | const questions = addService(new MockQuestionGraphService()); 37 | const application = addService(new MockApplicationService()); 38 | const email = addService(new MockEmailService()); 39 | const haveibeenpwned = addService(new MockHaveibeenpwnedService()); 40 | const emailTemplates = addService(new MockEmailTemplateService()); 41 | const slack = addService(new MockSlackNotificationService()); 42 | const bootShutdownNotifier = addService(new MockBootShutdownNotifier()); 43 | const prune = addService(new MockPruneService()); 44 | 45 | const instances: ConstructorParameters = [ 46 | signals.instance, 47 | haveibeenpwned.instance, 48 | config.instance, 49 | logger.instance, 50 | slack.instance, 51 | bootShutdownNotifier.instance, 52 | database.instance, 53 | email.instance, 54 | emailTemplates.instance, 55 | tokens.instance, 56 | users.instance, 57 | settings.instance, 58 | questions.instance, 59 | application.instance, 60 | prune.instance, 61 | http.instance, 62 | ]; 63 | 64 | expect.assertions(instances.length); 65 | 66 | const tilt = new Tilt(...instances); 67 | await tilt.bootstrap(); 68 | 69 | for (const { mocks } of services) { 70 | expect(mocks.bootstrap).toBeCalled(); 71 | } 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /backend/test/services/token-service.spec.ts: -------------------------------------------------------------------------------- 1 | import { IConfigurationService } from "../../src/services/config-service"; 2 | import { ITokenService, TokenService } from "../../src/services/token-service"; 3 | import { MockedService } from "./mock"; 4 | import { MockConfigurationService } from "./mock/mock-config-service"; 5 | 6 | interface ITokenData { 7 | id: number; 8 | } 9 | 10 | describe("TokenService", () => { 11 | let configService: MockedService; 12 | let tokenService: ITokenService; 13 | 14 | beforeEach(() => { 15 | configService = new MockConfigurationService({ 16 | config: { 17 | secrets: { 18 | jwtSecret: "secret", 19 | }, 20 | }, 21 | } as any); 22 | 23 | tokenService = new TokenService(configService.instance); 24 | }); 25 | 26 | it("encrypts data", () => { 27 | const data: ITokenData = { 28 | id: 10, 29 | }; 30 | const encrypted = tokenService.sign(data); 31 | expect(encrypted).not.toBe(data); 32 | }); 33 | 34 | it("decrypts data", () => { 35 | const data: ITokenData = { 36 | id: 10, 37 | }; 38 | const encrypted = tokenService.sign(data); 39 | const decrypted = tokenService.decode(encrypted); 40 | expect(decrypted.id).toBe(data.id); 41 | }); 42 | 43 | it("verifies encrypted data", () => { 44 | const data: ITokenData = { 45 | id: 10, 46 | }; 47 | const encrypted = tokenService.sign(data); 48 | const [header, value, signature] = encrypted.split("."); 49 | const brokenSignature = signature.toLowerCase(); 50 | const brokenEncrypted = `${header}.${value}.${brokenSignature}`; 51 | const throws = () => tokenService.decode(brokenEncrypted); 52 | expect(throws).toThrow(); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /backend/test/setup.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | 3 | // ci might take a bit longer to run tests and the default 5s timeout is too short 4 | jest.setTimeout(30_000); 5 | -------------------------------------------------------------------------------- /backend/test/tilt.spec.ts: -------------------------------------------------------------------------------- 1 | describe("tilt", () => { 2 | it("works", () => { 3 | expect(1).toBe(1); 4 | }); 5 | }); 6 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "downlevelIteration": true, 4 | "emitDecoratorMetadata": true, 5 | "experimentalDecorators": true, 6 | "importHelpers": true, 7 | "module": "commonjs", 8 | "noUnusedLocals": true, 9 | "noUnusedParameters": true, 10 | "outDir": "dist/", 11 | "strict": true, 12 | "target": "es5", 13 | "skipLibCheck": true 14 | }, 15 | "include": [ 16 | "src/**/*.ts", 17 | "test/**/*.ts" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /backend/tsconfig.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | "src/**/*.ts" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: off 5 | backend: 6 | target: 80% 7 | flags: backend 8 | frontend: 9 | target: 80% 10 | flags: frontend 11 | 12 | flags: 13 | backend: 14 | paths: 15 | - backend/ 16 | frontend: 17 | paths: 18 | - frontend/ 19 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | proxy: 5 | image: nginx:alpine 6 | ports: 7 | - 8080:80 8 | volumes: 9 | - ./proxy/nginx.conf:/etc/nginx/nginx.conf 10 | depends_on: 11 | - tilt 12 | networks: 13 | tilt_network: 14 | ipv4_address: 172.50.0.2 15 | 16 | tilt: 17 | build: 18 | context: . 19 | dockerfile: Dockerfile 20 | image: tilt 21 | environment: 22 | - BASE_URL=http://localhost:8080/apply/ 23 | - PORT=3000 24 | - LOG_LEVEL=debug 25 | - LOG_FILENAME=tilt.log 26 | - MAIL_HOST=maildev 27 | - MAIL_PORT=1025 28 | - MAIL_USERNAME=root@localhost 29 | - MAIL_PASSWORD=password 30 | - DATABASE_NAME=tilt 31 | - DATABASE_HOST=db 32 | - DATABASE_PASSWORD=wowsuchpassword 33 | - DATABASE_PORT=3306 34 | - DATABASE_USERNAME=root 35 | - SECRET_JWT=wow_this_is_some_secure_password 36 | depends_on: 37 | - db 38 | networks: 39 | tilt_network: 40 | ipv4_address: 172.50.0.3 41 | 42 | db: 43 | image: mariadb:latest 44 | volumes: 45 | - ./db:/var/lib/mysql 46 | environment: 47 | - MYSQL_ROOT_PASSWORD=wowsuchpassword 48 | - MYSQL_DATABASE=tilt 49 | ports: 50 | - 3306:3306 51 | networks: 52 | tilt_network: 53 | ipv4_address: 172.50.0.4 54 | 55 | phpmyadmin: 56 | image: phpmyadmin/phpmyadmin:latest 57 | environment: 58 | - PMA_HOST=db 59 | - PMA_USER=root 60 | - PMA_PASSWORD=wowsuchpassword 61 | ports: 62 | - 8081:80 63 | networks: 64 | tilt_network: 65 | ipv4_address: 172.50.0.5 66 | 67 | maildev: 68 | image: djfarrelly/maildev 69 | command: bin/maildev --incoming-user root@localhost --incoming-pass password --hide-extensions STARTTLS 70 | ports: 71 | - 8082:1080 72 | - "2525:1025" 73 | networks: 74 | tilt_network: 75 | ipv4_address: 172.50.0.6 76 | 77 | networks: 78 | tilt_network: 79 | ipam: 80 | config: 81 | - subnet: 172.50.0.0/16 82 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # this change only exists as long as the container exists, which will eventually 4 | # be re-created. the base image is not affected by this replacement 5 | echo "[*] setting base url to '$BASE_URL'" 6 | sed -i 's|||' frontend/index.html 7 | 8 | echo "[*] starting tilt" 9 | node backend/tilt.js 10 | -------------------------------------------------------------------------------- /frontend/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackaburg/tilt/93a25d1fb389e886c812fa06e340b4be23978d34/frontend/.DS_Store -------------------------------------------------------------------------------- /frontend/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | setupFilesAfterEnv: [ 3 | "/test/setup.ts" 4 | ], 5 | transform: { 6 | "^.+\\.tsx?$": "ts-jest" 7 | }, 8 | testRegex: "/test/.*\\.spec\\.tsx?$", 9 | moduleNameMapper: { 10 | // mock api module. the "$" ensures the react tests won't fail, as enzyme 11 | // apparently relies on an "*api*" module, which would be resolved to 12 | // the mocked api client module 13 | "api$": "/test/__mocks__/api.ts", 14 | }, 15 | moduleFileExtensions: [ 16 | "ts", 17 | "tsx", 18 | "js", 19 | "jsx" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/api/types/controllers.ts: -------------------------------------------------------------------------------- 1 | export * from "../../../../backend/src/controllers/api"; 2 | export * from "../../../../backend/src/controllers/settings-controller"; 3 | export * from "../../../../backend/src/controllers/users-controller"; 4 | export * from "../../../../backend/src/controllers/application-controller"; 5 | export * from "../../../../backend/src/controllers/system-controller"; 6 | -------------------------------------------------------------------------------- /frontend/src/api/types/dto.ts: -------------------------------------------------------------------------------- 1 | export * from "../../../../backend/src/controllers/dto"; 2 | -------------------------------------------------------------------------------- /frontend/src/api/types/enums.ts: -------------------------------------------------------------------------------- 1 | export { UserRole } from "../../../../backend/src/entities/user-role"; 2 | export { QuestionType } from "../../../../backend/src/entities/question-type"; 3 | -------------------------------------------------------------------------------- /frontend/src/authentication.ts: -------------------------------------------------------------------------------- 1 | import decode from "jwt-decode"; 2 | 3 | interface ITokenContent { 4 | exp: number; 5 | } 6 | 7 | const tokenLocalStorageName = "tilt_login_token"; 8 | 9 | /** 10 | * Gets the login token. 11 | */ 12 | export const getLoginToken = () => 13 | localStorage.getItem(tokenLocalStorageName) as string; 14 | 15 | /** 16 | * Gets whether the login token is currently set. 17 | */ 18 | export const isLoginTokenSet = () => { 19 | const token = getLoginToken(); 20 | 21 | if (!token) { 22 | return false; 23 | } 24 | 25 | const content = decode(token) as ITokenContent; 26 | const expiresOn = new Date(content.exp * 1000); 27 | 28 | if (expiresOn.getTime() < Date.now()) { 29 | clearLoginToken(); 30 | return false; 31 | } 32 | 33 | return true; 34 | }; 35 | 36 | /** 37 | * Sets the login token. 38 | * @param token The login token 39 | */ 40 | export const setLoginToken = (token: string) => 41 | localStorage.setItem(tokenLocalStorageName, token); 42 | 43 | /** 44 | * Clears the login token. 45 | */ 46 | export const clearLoginToken = () => 47 | localStorage.removeItem(tokenLocalStorageName); 48 | -------------------------------------------------------------------------------- /frontend/src/components/app.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useEffect } from "react"; 3 | import { RouteComponentProps, withRouter } from "react-router"; 4 | import { defaultThemeColor } from "../config"; 5 | import { useLoginContext } from "../contexts/login-context"; 6 | import { useSettingsContext } from "../contexts/settings-context"; 7 | import { 8 | authenticatedRoutes, 9 | defaultAuthenticatedRoute, 10 | Routes, 11 | } from "../routes"; 12 | import { ThemeProvider } from "../theme"; 13 | import { ErrorBoundary } from "./base/error-boundary"; 14 | import { LazyAuthenticatedRouter } from "./routers/lazy-authenticated-router"; 15 | import { UnauthenticatedRouter } from "./routers/unauthenticated-router"; 16 | 17 | interface IAppProps extends RouteComponentProps {} 18 | 19 | /** 20 | * The main app component. 21 | */ 22 | export const App = ({ history, location }: IAppProps) => { 23 | const { isLoggedIn } = useLoginContext(); 24 | const { pathname } = location; 25 | 26 | useEffect(() => { 27 | if (isLoggedIn) { 28 | const isUnknownRoute = !authenticatedRoutes.includes(pathname as Routes); 29 | 30 | if (pathname === Routes.Login || isUnknownRoute) { 31 | history.push(defaultAuthenticatedRoute); 32 | } 33 | } else { 34 | if ( 35 | pathname !== Routes.VerifyEmail && 36 | pathname !== Routes.SignupDone && 37 | pathname !== Routes.RegisterForm && 38 | pathname !== Routes.ForgotPassword && 39 | pathname !== Routes.ResetPassword 40 | ) { 41 | history.push(Routes.Login); 42 | } 43 | } 44 | }, [isLoggedIn, pathname]); 45 | 46 | let theme = { 47 | colorGradientEnd: defaultThemeColor, 48 | colorGradientStart: defaultThemeColor, 49 | colorLink: defaultThemeColor, 50 | colorLinkHover: defaultThemeColor, 51 | }; 52 | 53 | const { settings } = useSettingsContext(); 54 | 55 | if (settings) { 56 | theme = { 57 | colorGradientEnd: settings.frontend.colorGradientEnd, 58 | colorGradientStart: settings.frontend.colorGradientStart, 59 | colorLink: settings.frontend.colorLink, 60 | colorLinkHover: settings.frontend.colorLinkHover, 61 | }; 62 | } 63 | 64 | const router = isLoggedIn ? ( 65 | 66 | ) : ( 67 | 68 | ); 69 | 70 | return ( 71 | 72 | {router} 73 | 74 | ); 75 | }; 76 | 77 | /** 78 | * The main app component, connected to react-router. 79 | */ 80 | export const RoutedApp = withRouter(App); 81 | -------------------------------------------------------------------------------- /frontend/src/components/base/button.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | import { useCallback } from "react"; 4 | import { borderRadius, transitionDuration } from "../../config"; 5 | import { variables } from "../../theme"; 6 | import { Spinner } from "./spinner"; 7 | 8 | const RegularButton = styled.button` 9 | position: relative; 10 | 11 | display: inline-block; 12 | padding: 0.75rem 2rem; 13 | width: 100%; 14 | 15 | border: none; 16 | border-radius: ${borderRadius}; 17 | box-shadow: 0px 7px 10px rgba(0, 0, 0, 0.1); 18 | 19 | font-size: 0.8rem; 20 | font-weight: bold; 21 | text-transform: uppercase; 22 | cursor: pointer; 23 | 24 | background: #333; 25 | background-color: #333; 26 | color: white; 27 | 28 | transition-property: box-shadow, top, background, opacity; 29 | transition-duration: ${transitionDuration}; 30 | 31 | ${(props) => 32 | props.disabled 33 | ? ` 34 | cursor: default; 35 | opacity: 0.7; 36 | ` 37 | : ` 38 | opacity: 1; 39 | 40 | &:hover { 41 | color: white; 42 | box-shadow: 0px 7px 15px rgba(0, 0, 0, 0.15); 43 | } 44 | `} 45 | `; 46 | 47 | const PrimaryButton = styled(RegularButton)` 48 | background: linear-gradient( 49 | to top right, 50 | ${variables.colorGradientStart}, 51 | ${variables.colorGradientEnd} 52 | ); 53 | `; 54 | 55 | const SpinnerContainer = styled.div` 56 | position: absolute; 57 | right: 5px; 58 | top: 50%; 59 | transform: translateY(-50%); 60 | `; 61 | 62 | interface IButtonProps { 63 | children?: string; 64 | onClick?: (event: React.MouseEvent) => any; 65 | disable?: boolean; 66 | primary?: boolean; 67 | loading?: boolean; 68 | } 69 | 70 | /** 71 | * A clickable button. 72 | */ 73 | export const Button = ({ 74 | children, 75 | onClick, 76 | disable = false, 77 | primary = false, 78 | loading = false, 79 | }: IButtonProps) => { 80 | const handleClick = useCallback( 81 | (event: React.MouseEvent) => { 82 | if (!loading && !disable && onClick != null) { 83 | onClick(event); 84 | } 85 | }, 86 | [loading, disable, onClick], 87 | ); 88 | 89 | const Component = primary ? PrimaryButton : RegularButton; 90 | 91 | return ( 92 | 93 | {children} 94 | {loading && ( 95 | 96 | 97 | 98 | )} 99 | 100 | ); 101 | }; 102 | -------------------------------------------------------------------------------- /frontend/src/components/base/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | import { useCallback } from "react"; 4 | import { useUniqueID, useUniqueIDs } from "../../hooks/use-uniqe-id"; 5 | import { 6 | FlexColumnContainer, 7 | StyleableFlexContainer, 8 | VerticallyCenteredContainer, 9 | } from "./flex"; 10 | import { FormField } from "./form-field"; 11 | import { Checkbox } from "@mui/material"; 12 | 13 | const ItemContainer = styled(StyleableFlexContainer)` 14 | padding: 0; 15 | `; 16 | 17 | const Label = styled.label` 18 | padding-left: 0.5rem; 19 | `; 20 | 21 | const label = { inputProps: { "aria-label": "Checkbox demo" } }; 22 | 23 | interface ICheckboxesProps { 24 | radio?: boolean; 25 | values: string[]; 26 | selected: string[]; 27 | onChange: (selected: string[]) => any; 28 | title: string; 29 | mandatory?: boolean; 30 | isDisabled?: boolean; 31 | } 32 | 33 | /** 34 | * A checkbox group, which can also be displayed as a radio group. 35 | */ 36 | export const Checkboxes = ({ 37 | radio, 38 | values, 39 | selected, 40 | onChange, 41 | title, 42 | mandatory, 43 | isDisabled = false, 44 | }: ICheckboxesProps) => { 45 | const groupID = useUniqueID(); 46 | const checkboxIDs = useUniqueIDs(values.length); 47 | 48 | const toggleChecked = useCallback( 49 | (event: React.ChangeEvent) => { 50 | const checkedLookup = values.map((value, index) => { 51 | const isTriggeringInput = event.target.id === checkboxIDs[index]; 52 | 53 | if (isTriggeringInput) { 54 | return event.target.checked; 55 | } else if (radio) { 56 | return false; 57 | } 58 | 59 | return selected.includes(value); 60 | }); 61 | 62 | const selectedValues = values.filter((_, index) => checkedLookup[index]); 63 | onChange(selectedValues); 64 | }, 65 | [onChange, values, selected], 66 | ); 67 | 68 | const checkboxes = values.map((checkboxValue, index) => ( 69 | 70 | 71 | 80 | 81 | 82 | 83 | )); 84 | 85 | return ( 86 | 87 | {checkboxes} 88 | 89 | ); 90 | }; 91 | -------------------------------------------------------------------------------- /frontend/src/components/base/chevron.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | import { transitionDuration } from "../../config"; 4 | 5 | const SVG = styled.svg` 6 | transition-property: transform; 7 | transition-duration: ${transitionDuration}; 8 | `; 9 | 10 | interface IChevronProps { 11 | color?: string; 12 | size?: number; 13 | rotation?: number; 14 | } 15 | 16 | /** 17 | * An arrow without the middle line. 18 | */ 19 | export const Chevron = ({ 20 | color = "black", 21 | size = 20, 22 | rotation = 0, 23 | }: IChevronProps) => { 24 | const boxWidth = 500; 25 | const boxHeight = 200; 26 | const centerX = boxWidth / 2; 27 | 28 | return ( 29 | 37 | 43 | 44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /frontend/src/components/base/circle-chart.tsx: -------------------------------------------------------------------------------- 1 | import type { ChartData } from "chart.js"; 2 | import * as React from "react"; 3 | import { useMemo } from "react"; 4 | import { Doughnut } from "react-chartjs-2"; 5 | import { chartColors } from "../../config"; 6 | import { repeatAndTake } from "../../util"; 7 | 8 | interface ICounts { 9 | [key: string]: number; 10 | } 11 | 12 | interface ICircleChartProps { 13 | counts: ICounts; 14 | } 15 | 16 | /** 17 | * A pie chart. 18 | */ 19 | export const CircleChart = ({ counts }: ICircleChartProps) => { 20 | const chartData = useMemo(() => { 21 | const entries = [...Object.entries(counts)]; 22 | const labels = entries.map(([label]) => label); 23 | const data = entries.map(([_, value]) => value); 24 | const backgroundColor = repeatAndTake(chartColors, data.length) as string[]; 25 | 26 | return { 27 | datasets: [ 28 | { 29 | backgroundColor, 30 | data, 31 | }, 32 | ], 33 | labels, 34 | } as ChartData; 35 | }, [counts]); 36 | 37 | return ; 38 | }; 39 | -------------------------------------------------------------------------------- /frontend/src/components/base/code.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | import { borderRadius } from "../../config"; 4 | 5 | const Span = styled.span` 6 | padding: 0.1rem 0.2rem; 7 | font-family: Monaco, Consolas, Inconsolata, monospace; 8 | font-size: 0.75rem; 9 | background-color: #f7f7f7; 10 | border: 1px solid #eee; 11 | border-radius: ${borderRadius}; 12 | `; 13 | 14 | interface ICodeProps { 15 | children: React.ReactNode; 16 | } 17 | 18 | /** 19 | * A text field that looks like code. But is actually just a monospace span. 20 | */ 21 | export const Code = ({ children }: ICodeProps) => {children}; 22 | -------------------------------------------------------------------------------- /frontend/src/components/base/collapsible.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | import { useCallback } from "react"; 4 | import { useToggle } from "../../hooks/use-toggle"; 5 | import { Chevron } from "./chevron"; 6 | import { 7 | FlexColumnContainer, 8 | NonGrowingFlexContainer, 9 | Spacer, 10 | VerticallyCenteredContainer, 11 | } from "./flex"; 12 | import { Subsubheading } from "./headings"; 13 | 14 | const ExpandButton = styled.button` 15 | cursor: pointer; 16 | background-color: transparent; 17 | border: none; 18 | `; 19 | 20 | interface ICollapsibleProps { 21 | autoOpen?: boolean; 22 | children: React.ReactNode; 23 | title: string; 24 | } 25 | 26 | /** 27 | * A collapsible container. 28 | */ 29 | export const Collapsible = ({ 30 | autoOpen = false, 31 | children, 32 | title, 33 | }: ICollapsibleProps) => { 34 | const [isOpen, toggleIsOpen] = useToggle(autoOpen); 35 | const handleOpen = useCallback(() => toggleIsOpen(), []); 36 | 37 | return ( 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | {isOpen && ( 51 | 52 | {children} 53 | 54 | 55 | )} 56 | 57 | ); 58 | }; 59 | -------------------------------------------------------------------------------- /frontend/src/components/base/copyable-text.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | import { useCallback, useRef } from "react"; 4 | import { useNotificationContext } from "../../contexts/notification-context"; 5 | import { Nullable } from "../../util"; 6 | import { Button } from "./button"; 7 | import { Elevated } from "./elevated"; 8 | import { FlexColumnContainer } from "./flex"; 9 | 10 | const ScrollableText = styled.textarea` 11 | resize: none; 12 | padding: 1rem; 13 | font-family: Monaco, Consolas, Inconsolata, monospace; 14 | font-size: inherit; 15 | border: none; 16 | height: 200px; 17 | `; 18 | 19 | const ignoreChange = () => 0; 20 | 21 | interface ICopyableTextProps { 22 | text: string; 23 | } 24 | 25 | /** 26 | * A textfield with a button to allow easy copy and paste. 27 | */ 28 | export const CopyableText = ({ text }: ICopyableTextProps) => { 29 | const { showNotification } = useNotificationContext(); 30 | const ref = useRef>(null); 31 | 32 | const handleCopy = useCallback(() => { 33 | const { current: area } = ref; 34 | 35 | if (area == null) { 36 | return; 37 | } 38 | 39 | area.select(); 40 | area.setSelectionRange(0, text.length); 41 | document.execCommand("copy"); 42 | area.setSelectionRange(0, 0); 43 | 44 | showNotification("Copied"); 45 | }, [showNotification, text]); 46 | 47 | return ( 48 | 49 | 50 | 56 | 57 | 58 |
59 | 62 |
63 |
64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /frontend/src/components/base/divider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { FlexColumnContainer, Spacer, StyleableFlexContainer } from "./flex"; 3 | import styled from "@emotion/styled"; 4 | 5 | const Border = styled(StyleableFlexContainer)` 6 | border-top: 1px dashed #ccc; 7 | `; 8 | /** 9 | * A vertical divider. 10 | */ 11 | export const Divider = () => ( 12 |
21 | ); 22 | 23 | /** 24 | * A vertical divider slim. 25 | */ 26 | export const DividerSlim = () => ( 27 | 28 | 29 | 30 | 31 | 32 | ); 33 | -------------------------------------------------------------------------------- /frontend/src/components/base/elevated.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | import { borderRadius, transitionDuration } from "../../config"; 4 | import { StyleableFlexContainer } from "./flex"; 5 | 6 | const ElevatedContainer = styled(StyleableFlexContainer)` 7 | border-radius: ${borderRadius}; 8 | border: 1px solid #eee; 9 | transition-property: box-shadow; 10 | transition-duration: ${transitionDuration}; 11 | background-color: white; 12 | overflow: hidden; 13 | `; 14 | 15 | interface IElevatedProps { 16 | children: React.ReactNode; 17 | level: number; 18 | className?: string; 19 | } 20 | 21 | /** 22 | * An elevated container 23 | */ 24 | export const Elevated = ({ children, level, className }: IElevatedProps) => ( 25 | 33 | {children} 34 | 35 | ); 36 | -------------------------------------------------------------------------------- /frontend/src/components/base/error-boundary.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | import { Nullable } from "../../util"; 4 | import { CopyableText } from "./copyable-text"; 5 | import { 6 | CenteredContainer, 7 | PageSizedContainer, 8 | StyleableFlexContainer, 9 | } from "./flex"; 10 | import { Heading } from "./headings"; 11 | import { ExternalLink } from "./link"; 12 | import { Text } from "./text"; 13 | 14 | const ErrorContainer = styled(StyleableFlexContainer)` 15 | width: 300px; 16 | `; 17 | 18 | interface IRenderError { 19 | exception: Error; 20 | info: React.ErrorInfo; 21 | } 22 | 23 | interface IState { 24 | error: Nullable; 25 | } 26 | 27 | interface IProps { 28 | children: React.ReactNode; 29 | } 30 | 31 | export class ErrorBoundary extends React.Component { 32 | public state: IState = { 33 | error: null, 34 | }; 35 | 36 | /** 37 | * @inheritdoc 38 | */ 39 | public componentDidCatch(exception: Error, info: React.ErrorInfo) { 40 | this.setState({ 41 | error: { 42 | exception, 43 | info, 44 | }, 45 | }); 46 | } 47 | 48 | /** 49 | * @inheritdoc 50 | */ 51 | public render() { 52 | const { error } = this.state; 53 | 54 | if (error != null) { 55 | const { exception, info } = error; 56 | const message = `${exception.name}: ${exception.message}\n${exception.stack}\n\nComponent stack:${info.componentStack}`; 57 | 58 | return ( 59 | 60 | 61 | 62 | 63 | 64 | 65 | While this shouldn't have happened in the first place, we'd be 66 | happy if you report this error on our{" "} 67 | 68 | issue tracker 69 | 70 | . 71 | 72 | 73 | 74 | You can include this error log along a quick description to 75 | reproduce this issue: 76 | 77 | 78 | 79 | 80 | 81 | 82 | ); 83 | } 84 | 85 | return this.props.children; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /frontend/src/components/base/form-field-button.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | import { mediaBreakpoints } from "../../config"; 4 | import { 5 | FlexRowColumnContainer, 6 | FlexRowContainer, 7 | Spacer, 8 | StyleableFlexContainer, 9 | } from "./flex"; 10 | 11 | const ButtonContainer = styled(StyleableFlexContainer)` 12 | padding-top: 2.6rem; 13 | 14 | @media screen and (max-width: ${mediaBreakpoints.tablet}) { 15 | padding: 0; 16 | } 17 | `; 18 | 19 | interface IFormFieldButtonProps { 20 | field: React.ReactNode; 21 | button: React.ReactNode; 22 | } 23 | 24 | /** 25 | * A wrapper around a form field with a button. 26 | */ 27 | export const FormFieldButton = ({ field, button }: IFormFieldButtonProps) => ( 28 | 29 | {field} 30 | 31 | 32 | 33 | {button} 34 | 35 | ); 36 | -------------------------------------------------------------------------------- /frontend/src/components/base/form-field.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | import { StyleableFlexContainer } from "./flex"; 4 | 5 | const FormFieldContainer = styled(StyleableFlexContainer)` 6 | padding: 0.5rem 0; 7 | `; 8 | 9 | const Title = styled.label` 10 | display: block; 11 | padding-bottom: 0.5rem; 12 | font-size: 1rem; 13 | font-weight: bold; 14 | `; 15 | 16 | const MandatoryIndicator = styled.span` 17 | color: red; 18 | `; 19 | 20 | interface IFormFieldProps { 21 | children: React.ReactChild; 22 | title: string; 23 | mandatory?: boolean; 24 | } 25 | 26 | /** 27 | * A form field. 28 | */ 29 | export const FormField = ({ title, children, mandatory }: IFormFieldProps) => ( 30 | 31 | 32 | {title} 33 | {mandatory && <MandatoryIndicator>*</MandatoryIndicator>} 34 | 35 | {children} 36 | 37 | ); 38 | -------------------------------------------------------------------------------- /frontend/src/components/base/headings.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | 4 | interface IHeadingProps { 5 | text: React.ReactNode; 6 | } 7 | 8 | const H1 = styled.h1` 9 | font-size: 2rem; 10 | margin: 0; 11 | padding: 0.25rem 0; 12 | `; 13 | 14 | /** 15 | * A heading used to head something. 16 | */ 17 | export const Heading = ({ text }: IHeadingProps) =>

{text}

; 18 | 19 | const H2 = styled.h2` 20 | font-size: 1.25rem; 21 | font-weight: normal; 22 | margin: 0; 23 | padding: 0.25rem 0; 24 | `; 25 | 26 | /** 27 | * A subheading, can be used to structure things after a @see Header. 28 | */ 29 | export const Subheading = ({ text }: IHeadingProps) =>

{text}

; 30 | 31 | const H3 = styled.h3` 32 | font-size: 1.1rem; 33 | font-weight: normal; 34 | margin: 0; 35 | padding: 0.5rem 0; 36 | `; 37 | 38 | /** 39 | * A heading beneath @see Subheading 40 | */ 41 | export const Subsubheading = ({ text }: IHeadingProps) =>

{text}

; 42 | -------------------------------------------------------------------------------- /frontend/src/components/base/image.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | 4 | const Img = styled.img` 5 | max-width: 100%; 6 | `; 7 | 8 | interface IImageProps { 9 | src?: string; 10 | label: string; 11 | } 12 | 13 | /** 14 | * An image. 15 | */ 16 | export const Image = ({ src, label }: IImageProps) => ( 17 | {label} 18 | ); 19 | -------------------------------------------------------------------------------- /frontend/src/components/base/link.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/core"; 2 | import styled from "@emotion/styled"; 3 | import * as React from "react"; 4 | import { Link as RouterLink } from "react-router-dom"; 5 | import { Routes } from "../../routes"; 6 | import { variables } from "../../theme"; 7 | 8 | const linkStyle = css` 9 | cursor: pointer; 10 | color: ${variables.colorLink}; 11 | text-decoration: none; 12 | 13 | &:hover, 14 | &:focus, 15 | &:active { 16 | color: ${variables.colorLinkHover}; 17 | } 18 | `; 19 | 20 | interface IInternalLinkProps { 21 | to: Routes; 22 | children: string; 23 | } 24 | 25 | const InternalRouterLink = styled(RouterLink)` 26 | ${linkStyle} 27 | `; 28 | 29 | /** 30 | * An internal styled link. 31 | */ 32 | export const InternalLink = ({ to, children }: IInternalLinkProps) => ( 33 | {children} 34 | ); 35 | 36 | const A = styled.a` 37 | ${linkStyle} 38 | `; 39 | 40 | interface IExternalLinkProps { 41 | to: string; 42 | children: string; 43 | } 44 | 45 | /** 46 | * An external styled link. 47 | */ 48 | export const ExternalLink = ({ to, children }: IExternalLinkProps) => ( 49 | 50 | {children} 51 | 52 | ); 53 | -------------------------------------------------------------------------------- /frontend/src/components/base/markdown.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import ReactMarkdown from "react-markdown"; 3 | import { FlexColumnContainer } from "./flex"; 4 | import { Heading, Subheading, Subsubheading } from "./headings"; 5 | import { Image } from "./image"; 6 | import { Text } from "./text"; 7 | const renderers: ReactMarkdown.TransformOptions["components"] = { 8 | h1: ({ children }) => , 9 | h2: ({ children }) => , 10 | h3: ({ children }) => , 11 | img: ({ src, alt }) => , 12 | p: ({ children }) => {children}, 13 | }; 14 | interface IMarkdownProps { 15 | text: string; 16 | } 17 | /** 18 | * Renders markdown. 19 | */ 20 | export const Markdown = ({ text }: IMarkdownProps) => ( 21 | 22 | 23 | {text} 24 | 25 | 26 | ); 27 | -------------------------------------------------------------------------------- /frontend/src/components/base/message.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Alert, AlertColor } from "@mui/material"; 3 | 4 | interface IMessageProps { 5 | type?: AlertColor; 6 | children: React.ReactNode; 7 | } 8 | 9 | /** 10 | * A message to display some text. 11 | */ 12 | export const Message = ({ type, children }: IMessageProps) => { 13 | return ( 14 | 15 | {children} 16 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /frontend/src/components/base/muted.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | import { Text } from "./text"; 4 | 5 | const FadedText = styled(Text)` 6 | opacity: 0.75; 7 | `; 8 | 9 | interface IMutedProps { 10 | children: React.ReactNode; 11 | className?: string; 12 | } 13 | 14 | /** 15 | * A slightly less opaque text. 16 | */ 17 | export const Muted = ({ children, className }: IMutedProps) => ( 18 | {children} 19 | ); 20 | -------------------------------------------------------------------------------- /frontend/src/components/base/notification.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | import { borderRadius, transitionDuration } from "../../config"; 4 | import { StyleableFlexContainer } from "./flex"; 5 | 6 | const NotificationContainer = styled(StyleableFlexContainer)` 7 | position: fixed; 8 | top: 1rem; 9 | right: -5rem; 10 | opacity: 0; 11 | 12 | padding: 0.75rem 1.5rem; 13 | 14 | text-transform: uppercase; 15 | font-weight: bold; 16 | font-size: 0.7rem; 17 | background-color: #333; 18 | color: white; 19 | border-radius: ${borderRadius}; 20 | box-shadow: 0px 5px 10px rgba(0, 0, 0, 0.05); 21 | 22 | transition-property: right, opacity; 23 | transition-duration: ${transitionDuration}; 24 | 25 | z-index: 100; 26 | `; 27 | 28 | const shownStyles = { 29 | opacity: 1, 30 | right: "1rem", 31 | }; 32 | 33 | interface INotificationProps { 34 | message: string; 35 | show: boolean; 36 | } 37 | 38 | /** 39 | * A notification displayed in the top right corner. 40 | */ 41 | export const Notification = ({ message, show }: INotificationProps) => ( 42 | 43 | {message} 44 | 45 | ); 46 | -------------------------------------------------------------------------------- /frontend/src/components/base/placeholder.tsx: -------------------------------------------------------------------------------- 1 | import { keyframes } from "@emotion/core"; 2 | import styled from "@emotion/styled"; 3 | import * as React from "react"; 4 | import { shimmerBackgroundColor, shimmerColor } from "../../config"; 5 | import { StyleableFlexContainer } from "./flex"; 6 | 7 | const ShimmerKeyframes = keyframes` 8 | from { 9 | left: -100%; 10 | } 11 | 12 | to { 13 | left: 100%; 14 | } 15 | `; 16 | 17 | const PlaceholderContainer = styled(StyleableFlexContainer)` 18 | position: relative; 19 | display: block; 20 | width: 100%; 21 | 22 | overflow: hidden; 23 | background-color: ${shimmerBackgroundColor}; 24 | border-radius: 3px; 25 | 26 | &::after { 27 | content: " "; 28 | display: block; 29 | width: 100%; 30 | height: 100%; 31 | 32 | position: absolute; 33 | top: 0%; 34 | left: 0%; 35 | 36 | animation: ${ShimmerKeyframes} 2s linear infinite; 37 | 38 | background: linear-gradient( 39 | to right, 40 | ${shimmerBackgroundColor} 10%, 41 | ${shimmerColor} 40%, 42 | ${shimmerColor} 60%, 43 | ${shimmerBackgroundColor} 90% 44 | ); 45 | background-size: 100% 200%; 46 | } 47 | `; 48 | 49 | interface IPlaceholderProps { 50 | height: string; 51 | } 52 | 53 | /** 54 | * A placeholder component, which can be displayed while content is loading. 55 | */ 56 | export const Placeholder = ({ height }: IPlaceholderProps) => ( 57 | 58 | ); 59 | -------------------------------------------------------------------------------- /frontend/src/components/base/progress-step.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | import { 4 | CenteredContainer, 5 | FlexColumnContainer, 6 | FlexRowColumnContainer, 7 | FlexRowContainer, 8 | Spacer, 9 | StyleableFlexContainer, 10 | } from "./flex"; 11 | 12 | const StepConnector = styled(StyleableFlexContainer)` 13 | position: relative; 14 | 15 | :after { 16 | position: absolute; 17 | left: 0.6rem; 18 | top: 3rem; 19 | content: ""; 20 | border-left: 2px dashed grey; 21 | margin-left: 5px; 22 | height: 100%; 23 | } 24 | 25 | :last-child:after { 26 | display: none; 27 | } 28 | `; 29 | 30 | const StepContainer = styled(StyleableFlexContainer)` 31 | position: relative; 32 | `; 33 | 34 | const StepIndex = styled(StyleableFlexContainer)` 35 | font-weight: bold; 36 | border-radius: 10rem; 37 | border: 1px dashed #333; 38 | width: 2rem; 39 | height: 2rem; 40 | background-color: white; 41 | `; 42 | 43 | /** 44 | * Indicates the state of a progress step. 45 | */ 46 | export enum ProgressStepState { 47 | Pending, 48 | Completed, 49 | Failed, 50 | } 51 | 52 | const completedStyle: React.CSSProperties = { 53 | backgroundColor: "#56d175", 54 | border: "none", 55 | color: "white", 56 | }; 57 | 58 | const failedStyle: React.CSSProperties = { 59 | backgroundColor: "#ff5086", 60 | border: "none", 61 | color: "white", 62 | }; 63 | 64 | interface IProgressStepProps { 65 | children: React.ReactNode; 66 | index: number; 67 | state: ProgressStepState; 68 | title: string; 69 | } 70 | 71 | /** 72 | * A step in a progress stepper. 73 | */ 74 | export const ProgressStep = ({ 75 | children, 76 | index, 77 | state, 78 | title, 79 | }: IProgressStepProps) => ( 80 | 87 | 96 | 97 | 98 | 99 | 100 | 101 | 110 | {index} 111 | 112 | 113 | 114 | 115 |

{title}

116 | {children} 117 |
118 | 119 |
120 |
121 |
122 | ); 123 | -------------------------------------------------------------------------------- /frontend/src/components/base/select.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useCallback } from "react"; 3 | import { FormField } from "./form-field"; 4 | import { 5 | FormControl, 6 | InputLabel, 7 | MenuItem, 8 | Select, 9 | SelectChangeEvent, 10 | } from "@mui/material"; 11 | 12 | interface ISelectProps { 13 | values: string[]; 14 | value: string; 15 | onChange: (value: string) => any; 16 | placeholder?: string; 17 | title: string; 18 | mandatory?: boolean; 19 | isDisabled?: boolean; 20 | description?: string; 21 | } 22 | 23 | /** 24 | * A select dropdown. 25 | */ 26 | export const SelectWrapper = ({ 27 | values, 28 | value, 29 | onChange, 30 | title, 31 | placeholder, 32 | mandatory, 33 | isDisabled = false, 34 | description, 35 | }: ISelectProps) => { 36 | const options = values.map((optionValue) => ( 37 | 38 | {optionValue} 39 | 40 | )); 41 | 42 | const handleChange = useCallback( 43 | (event: SelectChangeEvent) => onChange(event.target.value), 44 | [onChange], 45 | ); 46 | 47 | return ( 48 |
49 | 50 | 51 | {description} 52 | 62 | 63 | 64 |
65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /frontend/src/components/base/simple-card.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | 4 | const CardContainer = styled.span` 5 | border-radius: 1rem; 6 | box-shadow: rgba(0, 0, 0, 0.16) 0px 10px 36px 0px, 7 | rgba(0, 0, 0, 0.06) 0px 0px 0px 1px; 8 | padding: 2rem; 9 | padding-top: 1rem; 10 | margin-top: 2rem; 11 | `; 12 | 13 | interface ICodeProps { 14 | children: React.ReactNode; 15 | } 16 | 17 | /** 18 | * A form field. 19 | */ 20 | export const SimpleCard = ({ children }: ICodeProps) => ( 21 | {children} 22 | ); 23 | -------------------------------------------------------------------------------- /frontend/src/components/base/spinner.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { CenteredContainer, NonGrowingFlexContainer } from "./flex"; 3 | 4 | interface ISpinnerProps { 5 | color?: string; 6 | radius?: number; 7 | size?: number; 8 | text?: string; 9 | width?: number; 10 | } 11 | 12 | /** 13 | * A spinner indicating a loading state. 14 | */ 15 | export const Spinner = ({ 16 | text, 17 | color = "black", 18 | size = 100, 19 | radius = 0.4, 20 | width = 0.075, 21 | }: ISpinnerProps) => { 22 | const boxSize = 100; 23 | const center = boxSize / 2; 24 | 25 | return ( 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 49 | 59 | 60 | 61 | 62 | {text} 63 | 64 | 65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /frontend/src/components/base/suspense-fallback.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { FlexColumnContainer } from "./flex"; 3 | import { Spinner } from "./spinner"; 4 | 5 | /** 6 | * A loading spinner to use as a Suspense fallback. 7 | */ 8 | export const SuspenseFallback = () => ( 9 | 10 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /frontend/src/components/base/text-input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useCallback } from "react"; 3 | import { useFocus } from "../../hooks/use-focus"; 4 | import { FormField } from "./form-field"; 5 | import { TextField } from "@mui/material"; 6 | 7 | /** 8 | * The type of the text input. 9 | */ 10 | export enum TextInputType { 11 | Text = "text", 12 | Password = "password", 13 | Area = "area", 14 | Number = "number", 15 | Email = "email", 16 | } 17 | 18 | interface ITextInputProps { 19 | placeholder?: string; 20 | autoFocus?: boolean; 21 | title: string; 22 | mandatory?: boolean; 23 | type?: TextInputType; 24 | value: TValue; 25 | onChange: (value: TValue) => unknown; 26 | min?: number; 27 | max?: number; 28 | maxLength?: number; 29 | allowDecimals?: boolean; 30 | isDisabled?: boolean; 31 | name?: string; 32 | autoCompleteField?: string; 33 | rows?: number; 34 | description?: string; 35 | } 36 | 37 | /** 38 | * An input, that can also be a textarea, depending on its `type`. 39 | */ 40 | export const TextInput = ({ 41 | value, 42 | onChange, 43 | title, 44 | placeholder, 45 | type = TextInputType.Text, 46 | autoFocus = false, 47 | mandatory, 48 | maxLength = 1024, 49 | isDisabled = false, 50 | name, 51 | description, 52 | }: ITextInputProps) => { 53 | const [isFocused, onFocus, onBlur] = useFocus(autoFocus); 54 | const fieldType = type || TextInputType.Text; 55 | const fieldProps = { 56 | name, 57 | autoFocus, 58 | disabled: isDisabled, 59 | onBlur, 60 | onFocus, 61 | placeholder, 62 | value, 63 | 64 | onChange: useCallback( 65 | (event: React.ChangeEvent) => { 66 | const changedValue = event.target.value.substr(0, maxLength); 67 | 68 | if (type === TextInputType.Number) { 69 | const parsedValue = Number(changedValue); 70 | onChange(parsedValue); 71 | return; 72 | } 73 | 74 | onChange(changedValue); 75 | }, 76 | [onChange, type], 77 | ), 78 | }; 79 | 80 | const field = ( 81 |
82 | 92 |
93 | ); 94 | 95 | return ( 96 | 97 | {field} 98 | 99 | ); 100 | }; 101 | -------------------------------------------------------------------------------- /frontend/src/components/base/text.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | 4 | const P = styled.p` 5 | margin: 0; 6 | padding: 0.5rem 0; 7 | `; 8 | 9 | interface ITextProps { 10 | children: React.ReactNode; 11 | className?: string; 12 | } 13 | 14 | /** 15 | * Renders text. 16 | */ 17 | export const Text = ({ children, className }: ITextProps) => ( 18 |

{children}

19 | ); 20 | -------------------------------------------------------------------------------- /frontend/src/components/base/time-chart.tsx: -------------------------------------------------------------------------------- 1 | import type { ChartData, ChartOptions } from "chart.js"; 2 | import * as React from "react"; 3 | import { useMemo } from "react"; 4 | import { Line } from "react-chartjs-2"; 5 | import { chartColors, transparentChartColors } from "../../config"; 6 | 7 | const options = { 8 | scales: { 9 | xAxes: [ 10 | { 11 | time: { 12 | unit: "month", 13 | }, 14 | type: "time", 15 | }, 16 | ], 17 | yAxes: [ 18 | { 19 | ticks: { 20 | beginAtZero: true, 21 | stepSize: 10, 22 | }, 23 | }, 24 | ], 25 | }, 26 | } as ChartOptions; 27 | 28 | interface IValue { 29 | x: Date; 30 | y: number; 31 | } 32 | 33 | interface ITimeChartProps { 34 | title: string; 35 | values: readonly IValue[]; 36 | } 37 | 38 | /** 39 | * A time-based line chart. 40 | */ 41 | export const TimeChart = ({ title, values }: ITimeChartProps) => { 42 | const data = useMemo(() => { 43 | return { 44 | datasets: [ 45 | { 46 | backgroundColor: transparentChartColors[1], 47 | borderColor: chartColors[0], 48 | data: values as IValue[], 49 | label: title, 50 | }, 51 | ], 52 | } as ChartData; 53 | }, [title, values]); 54 | 55 | return ; 56 | }; 57 | -------------------------------------------------------------------------------- /frontend/src/components/base/titled-number.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | import { 4 | CenteredContainer, 5 | FlexColumnContainer, 6 | StyleableFlexContainer, 7 | } from "./flex"; 8 | 9 | const TitledNumberContainer = styled(StyleableFlexContainer)` 10 | padding: 1rem 0; 11 | `; 12 | 13 | const Big = styled(StyleableFlexContainer)` 14 | font-size: 3rem; 15 | padding: 0.5rem 0; 16 | `; 17 | 18 | const Title = styled(StyleableFlexContainer)` 19 | font-size: 1rem; 20 | `; 21 | 22 | interface ITitledNumberProps { 23 | title: string; 24 | value: number | string; 25 | } 26 | 27 | /** 28 | * A number with a title. 29 | */ 30 | export const TitledNumber = ({ title, value }: ITitledNumberProps) => ( 31 | 32 | 33 | 34 | {value} 35 | {title} 36 | 37 | 38 | 39 | ); 40 | -------------------------------------------------------------------------------- /frontend/src/components/base/worldmap.tsx: -------------------------------------------------------------------------------- 1 | import countries from "country-json/src/country-by-abbreviation.json"; 2 | import * as React from "react"; 3 | import { useMemo } from "react"; 4 | import { WorldMap as ReactSVGWorldMap } from "react-svg-worldmap"; 5 | import { chartColors } from "../../config"; 6 | import { Elevated } from "./elevated"; 7 | 8 | interface ICountryAbbreviationByName { 9 | [name: string]: string; 10 | } 11 | 12 | const countryAbbreviationsByName = countries.reduce( 13 | (accumulatedCountries, { abbreviation, country }) => ({ 14 | ...accumulatedCountries, 15 | [country]: abbreviation.toLowerCase(), 16 | }), 17 | {}, 18 | ); 19 | 20 | interface ICounts { 21 | [country: string]: number; 22 | } 23 | 24 | interface IWorldMapProps { 25 | counts: ICounts; 26 | } 27 | 28 | /** 29 | * A world map colored by the count of each country's value. 30 | */ 31 | export const WorldMap = ({ counts }: IWorldMapProps) => { 32 | const data = useMemo(() => { 33 | return [...Object.entries(counts)].map(([country, value]) => ({ 34 | country: countryAbbreviationsByName[country], 35 | value, 36 | })); 37 | }, [counts]); 38 | 39 | return ( 40 | 41 | 42 | 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /frontend/src/components/forms/choices-question-editor.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useCallback, useMemo } from "react"; 3 | import { 4 | ChoicesQuestionConfigurationDTO, 5 | QuestionDTO, 6 | } from "../../api/types/dto"; 7 | import { Checkboxes } from "../base/checkbox"; 8 | import { Spacer } from "../base/flex"; 9 | import { FlexRowColumnContainer, FlexRowContainer } from "../base/flex"; 10 | import { TextInput, TextInputType } from "../base/text-input"; 11 | 12 | const separator = "\n"; 13 | const checkboxOptionValue = "Use checkboxes"; 14 | const radioOptionValue = "Use radio buttons"; 15 | const displayAsDropdownOptionValue = "Use a dropdown"; 16 | const appearanceOptions = [ 17 | checkboxOptionValue, 18 | radioOptionValue, 19 | displayAsDropdownOptionValue, 20 | ]; 21 | 22 | interface IChoicesQuestionEditorProps { 23 | question: QuestionDTO; 24 | onQuestionChange: (question: QuestionDTO) => any; 25 | } 26 | 27 | /** 28 | * An editor for choices questions. 29 | * @see ChoicesQuestion 30 | */ 31 | export const ChoicesQuestionEditor = ({ 32 | question, 33 | onQuestionChange, 34 | }: IChoicesQuestionEditorProps) => { 35 | const selectedAppearanceOptions = useMemo(() => { 36 | if (question.configuration.allowMultiple) { 37 | return [checkboxOptionValue]; 38 | } else if (question.configuration.displayAsDropdown) { 39 | return [displayAsDropdownOptionValue]; 40 | } 41 | 42 | return [radioOptionValue]; 43 | }, [ 44 | question.configuration.allowMultiple, 45 | question.configuration.displayAsDropdown, 46 | ]); 47 | 48 | const handleConfigurationChange = useCallback( 49 | (changes: Partial) => { 50 | if (!onQuestionChange) { 51 | return; 52 | } 53 | 54 | onQuestionChange({ 55 | ...question, 56 | configuration: { 57 | ...question.configuration, 58 | ...changes, 59 | }, 60 | }); 61 | }, 62 | [onQuestionChange, question], 63 | ); 64 | 65 | const handleAppearanceChange = useCallback( 66 | (selectedAppearance: string[]) => { 67 | handleConfigurationChange({ 68 | allowMultiple: selectedAppearance.includes(checkboxOptionValue), 69 | displayAsDropdown: selectedAppearance.includes( 70 | displayAsDropdownOptionValue, 71 | ), 72 | }); 73 | }, 74 | [handleConfigurationChange], 75 | ); 76 | 77 | const handleChoicesUpdate = useCallback( 78 | (text: string) => { 79 | const choices = text 80 | .split(separator) 81 | .filter((option) => option.trim().length > 0); 82 | 83 | const choicesWithTrailingComma = text.endsWith(separator) 84 | ? [...choices, ""] 85 | : choices; 86 | 87 | handleConfigurationChange({ 88 | choices: choicesWithTrailingComma, 89 | }); 90 | }, 91 | [handleConfigurationChange], 92 | ); 93 | 94 | const choicesText = question.configuration.choices.join(separator); 95 | 96 | return ( 97 | 98 | 99 | 106 | 107 | 108 | 109 | 116 | 117 | 118 | ); 119 | }; 120 | -------------------------------------------------------------------------------- /frontend/src/components/forms/choices-question.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import type { 3 | ChoicesQuestionConfigurationDTO, 4 | QuestionDTO, 5 | } from "../../api/types/dto"; 6 | import { Checkboxes } from "../base/checkbox"; 7 | import { SelectWrapper } from "../base/select"; 8 | 9 | interface IChoicesQuestionProps { 10 | question: QuestionDTO; 11 | selected: string[]; 12 | onSelectedChanged: (selected: string[]) => any; 13 | isDisabled?: boolean; 14 | } 15 | 16 | /** 17 | * A question to select from multiple options, either via dropdown, checkboxes or radio boxes. 18 | */ 19 | export const ChoicesQuestion = ({ 20 | question, 21 | selected, 22 | onSelectedChanged, 23 | isDisabled, 24 | }: IChoicesQuestionProps) => { 25 | if (question.configuration.displayAsDropdown) { 26 | return ( 27 | onSelectedChanged([value])} 30 | title={question.title} 31 | value={selected[0]} 32 | values={question.configuration.choices} 33 | isDisabled={isDisabled} 34 | description={question.description} 35 | /> 36 | ); 37 | } 38 | 39 | return ( 40 | 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /frontend/src/components/forms/country-question-editor.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import countries from "country-json/src/country-by-abbreviation.json"; 3 | 4 | const countryNames = countries.map((country) => country.country); 5 | 6 | /** 7 | * A pseud-editor for country questions. 8 | * @see CountryQuestion 9 | */ 10 | export const CountryQuestionEditor = () => ( 11 |

12 | Tilt currently knows about{" "} 13 | 14 | {countryNames.length} countries 15 | 16 | . If you're missing a country or you started a Mars colony and we should add 17 | it to these options, please file an issue on the{" "} 18 | 19 | tilt repository 20 | 21 | . 22 |

23 | ); 24 | -------------------------------------------------------------------------------- /frontend/src/components/forms/country-question.tsx: -------------------------------------------------------------------------------- 1 | import countries from "country-json/src/country-by-abbreviation.json"; 2 | import * as React from "react"; 3 | import type { 4 | CountryQuestionConfigurationDTO, 5 | QuestionDTO, 6 | } from "../../api/types/dto"; 7 | import { Autocomplete, Box, TextField } from "@mui/material"; 8 | import { FormField } from "../base/form-field"; 9 | 10 | /** 11 | * A list of countries known to tilt. 12 | */ 13 | 14 | interface ICountryQuestionProps { 15 | question: QuestionDTO; 16 | valueInput: string; 17 | onChange: (value: string) => any; 18 | isDisabled?: boolean; 19 | } 20 | 21 | /** 22 | * A question to select the country a user is from. 23 | * Basically a choices question, but separating it allows better visualization. 24 | */ 25 | export const CountryQuestion = ({ 26 | valueInput, 27 | onChange, 28 | question, 29 | isDisabled, 30 | }: ICountryQuestionProps) => { 31 | return ( 32 |
33 | 34 | option.country} 38 | fullWidth 39 | autoHighlight 40 | onChange={(_e, v) => onChange(v?.country ?? "")} 41 | renderInput={(params) => ( 42 | 43 | )} 44 | value={countries.find((c) => c.country === valueInput) ?? null} 45 | disabled={isDisabled} 46 | renderOption={(props, option) => ( 47 | img": { mr: 2, flexShrink: 0 } }} 50 | {...props} 51 | > 52 | 59 | {option.country} 60 | 61 | )} 62 | /> 63 | 64 |
65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /frontend/src/components/forms/number-question-editor.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useCallback } from "react"; 3 | import { 4 | NumberQuestionConfigurationDTO, 5 | QuestionDTO, 6 | } from "../../api/types/dto"; 7 | import { Spacer } from "../base/flex"; 8 | import { FlexRowColumnContainer, FlexRowContainer } from "../base/flex"; 9 | import { TextInput, TextInputType } from "../base/text-input"; 10 | 11 | interface INumberQuestionEditorProps { 12 | question: QuestionDTO; 13 | onQuestionChange: (question: QuestionDTO) => any; 14 | } 15 | 16 | /** 17 | * An editor for number questions. 18 | * @see NumberQuestion 19 | */ 20 | export const NumberQuestionEditor = ({ 21 | question, 22 | onQuestionChange, 23 | }: INumberQuestionEditorProps) => { 24 | const handleConfigurationFieldChange = useCallback( 25 | (changes: Partial) => { 26 | if (!onQuestionChange) { 27 | return; 28 | } 29 | 30 | onQuestionChange({ 31 | ...question, 32 | configuration: { 33 | ...question.configuration, 34 | ...changes, 35 | }, 36 | }); 37 | }, 38 | [onQuestionChange, question], 39 | ); 40 | 41 | const handlePlaceholderChange = useCallback( 42 | (value: string) => handleConfigurationFieldChange({ placeholder: value }), 43 | [handleConfigurationFieldChange], 44 | ); 45 | 46 | const handleMinValueChange = useCallback( 47 | (value: number) => 48 | handleConfigurationFieldChange({ 49 | minValue: isNaN(value) ? undefined : value, 50 | }), 51 | [handleConfigurationFieldChange], 52 | ); 53 | 54 | const handleMaxValueChange = useCallback( 55 | (value: number) => 56 | handleConfigurationFieldChange({ 57 | maxValue: isNaN(value) ? undefined : value, 58 | }), 59 | [handleConfigurationFieldChange], 60 | ); 61 | 62 | return ( 63 | <> 64 | 70 | 71 | 72 | 73 | 80 | 81 | 82 | 83 | 90 | 91 | 92 | 93 | ); 94 | }; 95 | -------------------------------------------------------------------------------- /frontend/src/components/forms/number-question.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import type { 3 | NumberQuestionConfigurationDTO, 4 | QuestionDTO, 5 | } from "../../api/types/dto"; 6 | import { TextInput, TextInputType } from "../base/text-input"; 7 | 8 | interface INumberQuestionProps { 9 | question: QuestionDTO; 10 | value: number; 11 | onChange: (value: number) => any; 12 | isDisabled?: boolean; 13 | } 14 | 15 | /** 16 | * A question to ask users for a number, e.g. their age. 17 | */ 18 | export const NumberQuestion = ({ 19 | question, 20 | value, 21 | onChange, 22 | isDisabled, 23 | }: INumberQuestionProps) => ( 24 | 35 | ); 36 | -------------------------------------------------------------------------------- /frontend/src/components/forms/sorting-buttons.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | import { FlexColumnContainer } from "../base/flex"; 4 | 5 | const Button = styled.button` 6 | cursor: pointer; 7 | background: transparent; 8 | border: 0px; 9 | `; 10 | 11 | const Icon = styled.span` 12 | border: solid black; 13 | border-width: 0 2px 2px 0; 14 | display: inline-block; 15 | padding: 3px; 16 | `; 17 | 18 | interface IArrowProps { 19 | isUpwardsFacing: boolean; 20 | } 21 | 22 | const Arrow = ({ isUpwardsFacing }: IArrowProps) => ( 23 | 26 | ); 27 | 28 | interface ISortingButtonProps { 29 | onClickMoveUp: () => void; 30 | onClickMoveDown: () => void; 31 | } 32 | 33 | /** 34 | * Arrows up and down to change ordering of questions. 35 | */ 36 | export const SortingButtons = ({ 37 | onClickMoveDown, 38 | onClickMoveUp, 39 | }: ISortingButtonProps) => ( 40 | 41 | 44 | 47 | 48 | ); 49 | -------------------------------------------------------------------------------- /frontend/src/components/forms/stringified-unified-question.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useCallback } from "react"; 3 | import { QuestionDTO } from "../../api/types/dto"; 4 | import { QuestionType } from "../../api/types/enums"; 5 | import { UnifiedQuestion } from "./unified-question"; 6 | 7 | const deriveValue = (value: string, type: QuestionType): any => { 8 | switch (type) { 9 | case QuestionType.Choices: 10 | return value.split(","); 11 | 12 | default: 13 | return value; 14 | } 15 | }; 16 | 17 | const deriveResult = (value: any, type: QuestionType): string => { 18 | switch (type) { 19 | case QuestionType.Choices: 20 | return (value as string[]).join(","); 21 | 22 | default: 23 | return String(value); 24 | } 25 | }; 26 | 27 | interface IStringifiedUnifiedQuestionProps { 28 | question: QuestionDTO; 29 | value: string; 30 | onChange: (value: string) => any; 31 | isDisabled?: boolean; 32 | } 33 | 34 | /** 35 | * The API expects stringified values. Modifying the values in the API client is 36 | * not viable, since we only have enough information, whether we need to convert 37 | * values here through the question's configuration. 38 | */ 39 | export const StringifiedUnifiedQuestion = ({ 40 | question, 41 | value, 42 | onChange, 43 | isDisabled, 44 | }: IStringifiedUnifiedQuestionProps) => { 45 | const handleChange = useCallback( 46 | (v: any) => onChange(deriveResult(v, question.configuration.type)), 47 | [onChange, question], 48 | ); 49 | 50 | return ( 51 | 57 | ); 58 | }; 59 | -------------------------------------------------------------------------------- /frontend/src/components/forms/text-question-editor.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useCallback } from "react"; 3 | import { QuestionDTO, TextQuestionConfigurationDTO } from "../../api/types/dto"; 4 | import { Checkboxes } from "../base/checkbox"; 5 | import { Spacer } from "../base/flex"; 6 | import { FlexRowColumnContainer, FlexRowContainer } from "../base/flex"; 7 | import { TextInput } from "../base/text-input"; 8 | 9 | const multilineOptionValue = "Multiline"; 10 | const convertToUrlOptionValue = "Convert answer to URL"; 11 | 12 | interface ITextQuestionEditorProps { 13 | question: QuestionDTO; 14 | onQuestionChange: (updatedQuestion: QuestionDTO) => any; 15 | } 16 | 17 | /** 18 | * An editor for text questions. 19 | * @see TextQuestion 20 | */ 21 | export const TextQuestionEditor = ({ 22 | question, 23 | onQuestionChange, 24 | }: ITextQuestionEditorProps) => { 25 | const handleConfigurationFieldChange = useCallback( 26 | (changes: Partial) => { 27 | if (!onQuestionChange) { 28 | return; 29 | } 30 | 31 | onQuestionChange({ 32 | ...question, 33 | configuration: { 34 | ...question.configuration, 35 | ...changes, 36 | }, 37 | }); 38 | }, 39 | [onQuestionChange, question], 40 | ); 41 | 42 | const handleAppearanceChange = useCallback( 43 | (selected: string[]) => { 44 | handleConfigurationFieldChange({ 45 | convertAnswerToUrl: selected.includes(convertToUrlOptionValue), 46 | multiline: selected.includes(multilineOptionValue), 47 | }); 48 | }, 49 | [handleConfigurationFieldChange, question], 50 | ); 51 | 52 | const handlePlaceholderChange = useCallback( 53 | (placeholder: string) => handleConfigurationFieldChange({ placeholder }), 54 | [handleConfigurationFieldChange], 55 | ); 56 | 57 | const appearanceOptions = [multilineOptionValue, convertToUrlOptionValue]; 58 | 59 | const selectedAppearanceOptions = [ 60 | ...(question.configuration.multiline ? [multilineOptionValue] : []), 61 | ...(question.configuration.convertAnswerToUrl 62 | ? [convertToUrlOptionValue] 63 | : []), 64 | ]; 65 | 66 | return ( 67 | 68 | 69 | 75 | 76 | 77 | 78 | 84 | 85 | 86 | ); 87 | }; 88 | -------------------------------------------------------------------------------- /frontend/src/components/forms/text-question.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import type { 3 | QuestionDTO, 4 | TextQuestionConfigurationDTO, 5 | } from "../../api/types/dto"; 6 | import { TextInput, TextInputType } from "../base/text-input"; 7 | 8 | interface ITextQuestionProps { 9 | question: QuestionDTO; 10 | value: string; 11 | onChange: (value: string) => any; 12 | isDisabled?: boolean; 13 | } 14 | 15 | /** 16 | * An editable text question. 17 | */ 18 | export const TextQuestion = ({ 19 | question, 20 | value, 21 | onChange, 22 | isDisabled, 23 | }: ITextQuestionProps) => { 24 | return ( 25 | 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /frontend/src/components/forms/unified-question.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { enforceExhaustiveSwitch } from "../../../../backend/src/utils/switch"; 3 | import type { 4 | ChoicesQuestionConfigurationDTO, 5 | CountryQuestionConfigurationDTO, 6 | NumberQuestionConfigurationDTO, 7 | QuestionDTO, 8 | TextQuestionConfigurationDTO, 9 | } from "../../api/types/dto"; 10 | import { QuestionType } from "../../api/types/enums"; 11 | import { FlexColumnContainer } from "../base/flex"; 12 | import { ChoicesQuestion } from "./choices-question"; 13 | import { CountryQuestion } from "./country-question"; 14 | import { NumberQuestion } from "./number-question"; 15 | import { TextQuestion } from "./text-question"; 16 | 17 | interface IQuestionProps { 18 | question: QuestionDTO; 19 | value: any; 20 | onChange: (value: any) => any; 21 | isDisabled?: boolean; 22 | } 23 | 24 | const Question = ({ 25 | question, 26 | value, 27 | onChange, 28 | isDisabled, 29 | }: IQuestionProps) => { 30 | const type = question.configuration.type; 31 | 32 | switch (type) { 33 | case QuestionType.Text: 34 | return ( 35 | } 37 | onChange={onChange} 38 | value={value} 39 | isDisabled={isDisabled} 40 | /> 41 | ); 42 | 43 | case QuestionType.Number: 44 | return ( 45 | } 47 | onChange={onChange} 48 | value={value} 49 | isDisabled={isDisabled} 50 | /> 51 | ); 52 | 53 | case QuestionType.Choices: 54 | return ( 55 | } 57 | onSelectedChanged={onChange} 58 | selected={value} 59 | isDisabled={isDisabled} 60 | /> 61 | ); 62 | 63 | case QuestionType.Country: 64 | return ( 65 | } 67 | onChange={onChange} 68 | valueInput={value} 69 | isDisabled={isDisabled} 70 | /> 71 | ); 72 | 73 | default: 74 | enforceExhaustiveSwitch(type); 75 | throw new Error(`unknown question type ${type}`); 76 | } 77 | }; 78 | 79 | /** 80 | * A question component, displaying the respective question depending on the question's type. 81 | */ 82 | export const UnifiedQuestion = ({ 83 | question, 84 | value, 85 | onChange, 86 | isDisabled, 87 | }: IQuestionProps) => ( 88 | 89 | 95 | 96 | ); 97 | -------------------------------------------------------------------------------- /frontend/src/components/pages/challenges.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | import { Divider } from "../base/divider"; 4 | import { StyleableFlexContainer } from "../base/flex"; 5 | import { Heading } from "../base/headings"; 6 | import { Page } from "./page"; 7 | 8 | const HeaderContainer = styled(StyleableFlexContainer)` 9 | justify-content: space-between; 10 | flex-direction: row; 11 | `; 12 | 13 | /** 14 | * A settings dashboard to configure all parts of tilt. 15 | */ 16 | export const Challenges = () => ( 17 | 18 | 19 | 20 | 21 | 22 |
Will come soon ...
23 |
24 | ); 25 | -------------------------------------------------------------------------------- /frontend/src/components/pages/confirmation-form.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Form, FormType } from "../forms/form"; 3 | 4 | /** 5 | * The form to confirm a spot. 6 | */ 7 | export const ConfirmationForm = () => { 8 | return
; 9 | }; 10 | -------------------------------------------------------------------------------- /frontend/src/components/pages/forgot-password.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | import { useCallback, useState } from "react"; 4 | import { useApi } from "../../hooks/use-api"; 5 | import { Button } from "../base/button"; 6 | import { 7 | FlexColumnContainer, 8 | Spacer, 9 | StyleableFlexContainer, 10 | } from "../base/flex"; 11 | import { Heading } from "../base/headings"; 12 | import { Message } from "../base/message"; 13 | import { TextInput, TextInputType } from "../base/text-input"; 14 | import { InternalLink } from "../base/link"; 15 | import { Routes } from "../../routes"; 16 | 17 | const ButtonContainer = styled(StyleableFlexContainer)` 18 | padding-top: 1rem; 19 | `; 20 | 21 | /** 22 | * A form to create an account. 23 | */ 24 | export const ForgotPassword = () => { 25 | const [email, setEmail] = useState(""); 26 | const [message, setMessage] = useState(""); 27 | 28 | const { 29 | isFetching: loginInProgress, 30 | error: loginError, 31 | forcePerformRequest: forgotPasswordRequest, 32 | } = useApi( 33 | async (api, wasTriggeredManually) => { 34 | if (wasTriggeredManually) { 35 | const response = await api.forgotPassword(email); 36 | setMessage(() => response); 37 | } 38 | }, 39 | [email, message], 40 | ); 41 | 42 | const formInProgress = loginInProgress; 43 | 44 | const handleSubmit = useCallback((event: React.SyntheticEvent) => { 45 | event.preventDefault(); 46 | }, []); 47 | 48 | return ( 49 | 50 | 51 | 52 | {loginError && ( 53 | 54 | Reset password error: {loginError.message} 55 | 56 | )} 57 | 58 | 59 | setEmail(value)} 64 | type={TextInputType.Email} 65 | name="email" 66 | autoFocus 67 | autoCompleteField="email" 68 | /> 69 | 70 | « Back to login 71 | 72 | 73 | {message && ( 74 | 75 | {message} 76 | 77 | )} 78 | 79 | 80 | 88 | 89 | 90 |
91 | ); 92 | }; 93 | -------------------------------------------------------------------------------- /frontend/src/components/pages/lazy-admission.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { SuspenseFallback } from "../base/suspense-fallback"; 3 | 4 | const LazyLoadedAdmissionCenter = React.lazy(async () => { 5 | const { Admission } = await import("./admission"); 6 | return { 7 | default: Admission, 8 | }; 9 | }); 10 | 11 | /** 12 | * Lazy loaded admission center, since only moderators need this. 13 | */ 14 | export const LazyAdmission = () => ( 15 | }> 16 | 17 | 18 | ); 19 | -------------------------------------------------------------------------------- /frontend/src/components/pages/lazy-settings.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { SuspenseFallback } from "../base/suspense-fallback"; 3 | 4 | const LazyLoadedSettings = React.lazy(async () => { 5 | const { Settings } = await import("../pages/settings"); 6 | return { 7 | default: Settings, 8 | }; 9 | }); 10 | 11 | /** 12 | * Lazy loaded settings, since only moderators need this. 13 | */ 14 | export const LazySettings = () => ( 15 | }> 16 | 17 | 18 | ); 19 | -------------------------------------------------------------------------------- /frontend/src/components/pages/lazy-statistics.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | const LazyLoadedStatistics = React.lazy(async () => { 4 | const { Statistics } = await import("./statistics"); 5 | return { 6 | default: Statistics, 7 | }; 8 | }); 9 | 10 | /** 11 | * Lazy-loaded statistics, since only moderators need to see this. 12 | */ 13 | export const LazyStatistics = () => ; 14 | -------------------------------------------------------------------------------- /frontend/src/components/pages/lazy-system.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { SuspenseFallback } from "../base/suspense-fallback"; 3 | 4 | const LazyLoadedSystem = React.lazy(async () => { 5 | const { System } = await import("./system"); 6 | return { 7 | default: System, 8 | }; 9 | }); 10 | 11 | /** 12 | * Lazy loaded system, only root needs this. 13 | */ 14 | export const LazySystem = () => ( 15 | }> 16 | 17 | 18 | ); 19 | -------------------------------------------------------------------------------- /frontend/src/components/pages/login-form.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | import { useCallback, useState } from "react"; 4 | import { useLoginContext } from "../../contexts/login-context"; 5 | import { useApi } from "../../hooks/use-api"; 6 | import { Button } from "../base/button"; 7 | import { FlexColumnContainer, StyleableFlexContainer } from "../base/flex"; 8 | import { Heading } from "../base/headings"; 9 | import { Message } from "../base/message"; 10 | import { TextInput, TextInputType } from "../base/text-input"; 11 | import { InternalLink } from "../base/link"; 12 | import { Routes } from "../../routes"; 13 | 14 | const ButtonContainer = styled(StyleableFlexContainer)` 15 | padding-top: 1rem; 16 | `; 17 | 18 | /** 19 | * A form to create an account. 20 | */ 21 | export const LoginForm = () => { 22 | const { updateUser } = useLoginContext(); 23 | const [email, setEmail] = useState(""); 24 | const [password, setPassword] = useState(""); 25 | 26 | const { 27 | isFetching: loginInProgress, 28 | error: loginError, 29 | forcePerformRequest: sendLoginRequest, 30 | } = useApi( 31 | async (api, wasTriggeredManually) => { 32 | if (wasTriggeredManually) { 33 | const user = await api.login(email, password); 34 | updateUser(() => user); 35 | } 36 | }, 37 | [email, password, updateUser], 38 | ); 39 | 40 | const formInProgress = loginInProgress; 41 | 42 | const handleSubmit = useCallback((event: React.SyntheticEvent) => { 43 | event.preventDefault(); 44 | }, []); 45 | 46 | return ( 47 | 48 | 49 | 50 | {loginError && ( 51 | 52 | Login error: {loginError.message} 53 | 54 | )} 55 | 56 |
57 | setEmail(value)} 62 | type={TextInputType.Email} 63 | name="username" 64 | autoFocus 65 | autoCompleteField="username" 66 | /> 67 | setPassword(value)} 72 | type={TextInputType.Password} 73 | name="password" 74 | autoCompleteField="current-password" 75 | /> 76 | 77 |
78 | 79 | Forgot Password? 80 | 81 |
82 | 83 | 84 | 92 |
98 | New user?{" "} 99 | Register 100 |
101 |
102 | 103 |
104 | ); 105 | }; 106 | -------------------------------------------------------------------------------- /frontend/src/components/pages/map.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | import { Divider } from "../base/divider"; 4 | import { StyleableFlexContainer } from "../base/flex"; 5 | import { Heading } from "../base/headings"; 6 | import { Page } from "./page"; 7 | 8 | const HeaderContainer = styled(StyleableFlexContainer)` 9 | justify-content: space-between; 10 | flex-direction: row; 11 | `; 12 | 13 | /** 14 | * A settings dashboard to configure all parts of tilt. 15 | */ 16 | export const Map = () => ( 17 | 18 | 19 | 20 | 21 | 22 |
Will come soon ...
23 |
24 | ); 25 | -------------------------------------------------------------------------------- /frontend/src/components/pages/page-not-found.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | import { CenteredContainer, StyleableFlexContainer } from "../base/flex"; 4 | import { Heading } from "../base/headings"; 5 | import { Text } from "../base/text"; 6 | import { Page } from "../pages/page"; 7 | 8 | const Container = styled(StyleableFlexContainer)` 9 | color: #555; 10 | `; 11 | 12 | /** 13 | * 404. 14 | */ 15 | export const PageNotFound = () => ( 16 | 17 | 18 | 19 | 20 | 21 | Try selecting a page from the sidebar 22 | 23 | 24 | 25 | 26 | ); 27 | -------------------------------------------------------------------------------- /frontend/src/components/pages/page.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | import { StyleableFlexContainer } from "../base/flex"; 4 | 5 | const PaddedContainer = styled(StyleableFlexContainer)` 6 | padding: 2rem; 7 | `; 8 | 9 | interface IPageContainerProps { 10 | children: React.ReactNode; 11 | } 12 | 13 | /** 14 | * A container to wrap pages. 15 | */ 16 | export const Page = ({ children }: IPageContainerProps) => ( 17 | {children} 18 | ); 19 | -------------------------------------------------------------------------------- /frontend/src/components/pages/profile-form.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Form, FormType } from "../forms/form"; 3 | 4 | /** 5 | * The profile form, d'uuh. 6 | */ 7 | export const ProfileForm = () => { 8 | return
; 9 | }; 10 | -------------------------------------------------------------------------------- /frontend/src/components/pages/register-form.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | import { useCallback, useState } from "react"; 4 | import { Redirect } from "react-router"; 5 | import { useApi } from "../../hooks/use-api"; 6 | import { Routes } from "../../routes"; 7 | import { Button } from "../base/button"; 8 | import { FlexColumnContainer, StyleableFlexContainer } from "../base/flex"; 9 | import { Heading } from "../base/headings"; 10 | import { Message } from "../base/message"; 11 | import { TextInput, TextInputType } from "../base/text-input"; 12 | import { InternalLink } from "../base/link"; 13 | 14 | const ButtonContainer = styled(StyleableFlexContainer)` 15 | padding-top: 1rem; 16 | `; 17 | 18 | /** 19 | * A form to create an account. 20 | */ 21 | export const RegisterForm = () => { 22 | const [firstName, setFirstName] = useState(""); 23 | const [lastName, setLastName] = useState(""); 24 | const [email, setEmail] = useState(""); 25 | const [password, setPassword] = useState(""); 26 | 27 | const { 28 | value: didSignup, 29 | isFetching: signupInProgress, 30 | error: signupError, 31 | forcePerformRequest: sendSignupRequest, 32 | } = useApi( 33 | async (api, wasTriggeredManually) => { 34 | if (wasTriggeredManually) { 35 | await api.signup(firstName, lastName, email, password); 36 | return true; 37 | } 38 | 39 | return false; 40 | }, 41 | [email, password], 42 | ); 43 | 44 | const formInProgress = signupInProgress; 45 | const signupDone = Boolean(didSignup) && !signupInProgress && !signupError; 46 | 47 | const handleSubmit = useCallback((event: React.SyntheticEvent) => { 48 | event.preventDefault(); 49 | }, []); 50 | 51 | if (signupDone) { 52 | return ; 53 | } 54 | 55 | return ( 56 | 57 | 58 | 59 | {signupError && ( 60 | 61 | Signup error: {signupError.message} 62 | 63 | )} 64 | 65 | 66 | setFirstName(value)} 71 | type={TextInputType.Text} 72 | name="firstName" 73 | autoFocus 74 | autoCompleteField="firstName" 75 | /> 76 | setLastName(value)} 81 | type={TextInputType.Email} 82 | name="lastName" 83 | autoFocus 84 | autoCompleteField="lastName" 85 | /> 86 | setEmail(value)} 91 | type={TextInputType.Email} 92 | name="username" 93 | autoFocus 94 | autoCompleteField="username" 95 | /> 96 | setPassword(value)} 101 | type={TextInputType.Password} 102 | name="password" 103 | autoCompleteField="current-password" 104 | /> 105 | 106 | « Back to login 107 | 108 | 109 | 117 | 118 | 119 | 120 | ); 121 | }; 122 | -------------------------------------------------------------------------------- /frontend/src/components/pages/reset-password.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | import { useCallback, useState } from "react"; 4 | import { useApi } from "../../hooks/use-api"; 5 | import { Button } from "../base/button"; 6 | import { FlexColumnContainer, StyleableFlexContainer } from "../base/flex"; 7 | import { Heading } from "../base/headings"; 8 | import { Message } from "../base/message"; 9 | import { TextInput, TextInputType } from "../base/text-input"; 10 | import { Redirect, useLocation } from "react-router-dom"; 11 | import { InternalLink } from "../base/link"; 12 | import { Routes } from "../../routes"; 13 | 14 | const ButtonContainer = styled(StyleableFlexContainer)` 15 | padding-top: 1rem; 16 | `; 17 | 18 | /** 19 | * A form to create an account. 20 | */ 21 | export const ResetPassword = () => { 22 | const [password, setPassword] = useState(""); 23 | 24 | const location = useLocation(); 25 | const token = new URLSearchParams(location.search).get("token"); 26 | 27 | const { 28 | value: resetDone, 29 | isFetching: loginInProgress, 30 | error: loginError, 31 | forcePerformRequest: forgotPasswordRequest, 32 | } = useApi( 33 | async (api, wasTriggeredManually) => { 34 | if (wasTriggeredManually) { 35 | await api.resetPassword(password, token!); 36 | return true; 37 | } 38 | return false; 39 | }, 40 | [password], 41 | ); 42 | 43 | const formInProgress = loginInProgress; 44 | 45 | const handleSubmit = useCallback((event: React.SyntheticEvent) => { 46 | event.preventDefault(); 47 | }, []); 48 | 49 | if (resetDone) { 50 | return ; 51 | } 52 | 53 | return ( 54 | 55 | 56 | 57 | {loginError && ( 58 | 59 | Reset password error: {loginError.message} 60 | 61 | )} 62 | 63 |
64 | setPassword(value)} 69 | type={TextInputType.Password} 70 | name="password" 71 | autoCompleteField="current-password" 72 | /> 73 | 74 | « Back to login 75 | 76 | 84 | 85 | 86 |
87 | ); 88 | }; 89 | -------------------------------------------------------------------------------- /frontend/src/components/pages/settings.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | import { StyleableFlexContainer } from "../base/flex"; 4 | import { Heading } from "../base/headings"; 5 | import { ApplicationSettings } from "../settings/application-settings"; 6 | import { EmailSettings } from "../settings/email-settings"; 7 | import { FrontendSettings } from "../settings/frontend-settings"; 8 | import { SettingsSaveButton } from "../settings/save-button"; 9 | import { Page } from "./page"; 10 | import { SimpleCard } from "../base/simple-card"; 11 | 12 | const HeaderContainer = styled(StyleableFlexContainer)` 13 | justify-content: space-between; 14 | flex-direction: row; 15 | `; 16 | 17 | const ButtonContainer = styled(StyleableFlexContainer)` 18 | flex-basis: 0; 19 | `; 20 | 21 | /** 22 | * A settings dashboard to configure all parts of tilt. 23 | */ 24 | export const Settings = () => ( 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | ); 43 | -------------------------------------------------------------------------------- /frontend/src/components/pages/signup-done.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { FlexColumnContainer } from "../base/flex"; 3 | import { Heading } from "../base/headings"; 4 | import { Text } from "../base/text"; 5 | import { InternalLink } from "../base/link"; 6 | import { Routes } from "../../routes"; 7 | 8 | /** 9 | * A "you're signed up wait for the email" message. 10 | */ 11 | export const SignupDone = () => ( 12 | 13 | 14 | We've sent you an email with a button to verify yourself. 15 | 16 | It might take a minute or two to arrive, and to be safe, please also check 17 | your junk mail. 18 | 19 |
20 | « Back to login 21 |
22 |
23 | ); 24 | -------------------------------------------------------------------------------- /frontend/src/components/pages/verify-email.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { RouteComponentProps, withRouter } from "react-router"; 3 | import { useApi } from "../../hooks/use-api"; 4 | import { Routes } from "../../routes"; 5 | import { FlexColumnContainer } from "../base/flex"; 6 | import { Heading } from "../base/headings"; 7 | import { InternalLink } from "../base/link"; 8 | import { Message } from "../base/message"; 9 | import { Text } from "../base/text"; 10 | 11 | interface IVerifyEmailProps extends RouteComponentProps {} 12 | 13 | /** 14 | * A dialog to verify the user's email address via the given token. 15 | */ 16 | export const VerifyEmail = ({ location: { hash } }: IVerifyEmailProps) => { 17 | const token = hash.startsWith("#") ? hash.substring(1) : hash; 18 | const { isFetching: verificationInProgress, error } = useApi( 19 | async (api) => api.verifyEmail(token), 20 | [token], 21 | ); 22 | 23 | return ( 24 | 25 | 26 | 27 | {error ? ( 28 | 29 | Error: {error.message} 30 | 31 | ) : verificationInProgress ? ( 32 | This will only take a second... 33 | ) : ( 34 | <> 35 | Successfully verified your email! 36 | Back to login... 37 | 38 | )} 39 | 40 | ); 41 | }; 42 | 43 | /** 44 | * The email verification component, but connected to react-router. 45 | */ 46 | export const RoutedVerifyEmail = withRouter(VerifyEmail); 47 | -------------------------------------------------------------------------------- /frontend/src/components/routers/lazy-authenticated-router.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { PageSizedContainer } from "../base/flex"; 3 | import { SuspenseFallback } from "../base/suspense-fallback"; 4 | 5 | const LazyLoadedAuthenticatedRouter = React.lazy(async () => { 6 | const { AuthenticatedRouter } = await import("./authenticated-router"); 7 | 8 | return { 9 | default: AuthenticatedRouter, 10 | }; 11 | }); 12 | 13 | /** 14 | * Lazy loaded authenticated router. 15 | */ 16 | export const LazyAuthenticatedRouter = () => ( 17 | 20 | 21 | 22 | } 23 | > 24 | 25 | 26 | ); 27 | -------------------------------------------------------------------------------- /frontend/src/components/routers/sidebar/sidebar-menu.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | import { NavLink } from "react-router-dom"; 4 | import { transitionDuration } from "../../../config"; 5 | 6 | const UL = styled.ul` 7 | display: block; 8 | margin: 0; 9 | padding: 0; 10 | list-style: none; 11 | padding-left: 0.5rem; 12 | `; 13 | 14 | interface ISidebarMenuProps { 15 | children: React.ReactNode; 16 | } 17 | 18 | /** 19 | * The sidebar menu. 20 | */ 21 | export const SidebarMenu = ({ children }: ISidebarMenuProps) => ( 22 |
    {children}
23 | ); 24 | 25 | const LI = styled.li` 26 | display: block; 27 | `; 28 | 29 | const Link = styled(NavLink)` 30 | display: block; 31 | padding: 1rem 1.5rem; 32 | font-size: 1.2rem; 33 | 34 | transition-property: background-color; 35 | transition-duration: ${transitionDuration}; 36 | 37 | color: #929292; 38 | text-decoration: none; 39 | 40 | &.active { 41 | color: white; 42 | } 43 | 44 | &:hover { 45 | color: white; 46 | } 47 | `; 48 | 49 | interface ISidebarMenuItemProps { 50 | to?: string; 51 | onClick?: () => any; 52 | children: any; 53 | } 54 | 55 | /** 56 | * An item in the sidebar. 57 | */ 58 | export const SidebarMenuItem = ({ 59 | to, 60 | onClick, 61 | children, 62 | }: ISidebarMenuItemProps) => ( 63 |
  • 64 | 65 | {children} 66 | 67 |
  • 68 | ); 69 | -------------------------------------------------------------------------------- /frontend/src/components/routers/sidebar/sidebar-toggle.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | import { borderRadius } from "../../../config"; 4 | import { variables } from "../../../theme"; 5 | import { Elevated } from "../../base/elevated"; 6 | import { StyleableFlexContainer } from "../../base/flex"; 7 | 8 | const Bar = styled(StyleableFlexContainer)` 9 | height: 3px; 10 | border-radius: ${borderRadius}; 11 | background-color: ${variables.colorGradientEnd}; 12 | `; 13 | 14 | const Spacer = styled(StyleableFlexContainer)` 15 | height: 0.25rem; 16 | `; 17 | 18 | const Button = styled.button` 19 | display: block; 20 | width: 3rem; 21 | padding: 0.5rem; 22 | 23 | background-color: transparent; 24 | border: none; 25 | cursor: pointer; 26 | `; 27 | 28 | interface ISidebarBurgerProps { 29 | onClick: () => any; 30 | } 31 | 32 | /** 33 | * A button that kinda looks like a burger, because it consists of 3 bars. 34 | */ 35 | export const SidebarToggle = ({ onClick }: ISidebarBurgerProps) => ( 36 | 37 | 44 | 45 | ); 46 | -------------------------------------------------------------------------------- /frontend/src/components/routers/unauthenticated-router.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | import { Route, Switch } from "react-router"; 4 | import { useSettingsContext } from "../../contexts/settings-context"; 5 | import { Routes } from "../../routes"; 6 | import { Elevated } from "../base/elevated"; 7 | import { 8 | CenteredContainer, 9 | PageSizedContainer, 10 | StyleableFlexContainer, 11 | } from "../base/flex"; 12 | import { LoginForm } from "../pages/login-form"; 13 | import { RegisterForm } from "../pages/register-form"; 14 | import { ForgotPassword } from "../pages/forgot-password"; 15 | import { ResetPassword } from "../pages/reset-password"; 16 | import { SignupDone } from "../pages/signup-done"; 17 | import { RoutedVerifyEmail } from "../pages/verify-email"; 18 | import { variables } from "../../theme"; 19 | 20 | const BackgroundContainer = styled(StyleableFlexContainer)` 21 | overflow-y: auto; 22 | background-repeat: repeat-x repeat-y; 23 | width: 100%; 24 | height: 100%; 25 | `; 26 | 27 | const RouterContainer = styled(Elevated)` 28 | padding: 1rem; 29 | width: min(390px, 100vw); 30 | border-top: 4px solid; 31 | border-color: ${variables.colorLink}; 32 | }`; 33 | 34 | /** 35 | * A router for unauthenticated users. 36 | */ 37 | export const UnauthenticatedRouter = () => { 38 | const { settings } = useSettingsContext(); 39 | const imageURL = settings.frontend.loginSignupImage; 40 | const backgroundImage = !imageURL ? undefined : `url(${imageURL})`; 41 | 42 | return ( 43 | 44 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /frontend/src/components/settings/email-template-editor.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | import { useCallback } from "react"; 4 | import type { EmailTemplateDTO } from "../../api/types/dto"; 5 | import { Elevated } from "../base/elevated"; 6 | import { Spacer } from "../base/flex"; 7 | import { FlexRowColumnContainer, FlexRowContainer } from "../base/flex"; 8 | import { Subsubheading } from "../base/headings"; 9 | import { TextInput, TextInputType } from "../base/text-input"; 10 | 11 | const EmailTemplateLength = 65_536; 12 | 13 | const EmailTemplateEditorContainer = styled(Elevated)` 14 | padding: 1rem; 15 | `; 16 | 17 | interface IEmailTemplateEditor { 18 | title: string; 19 | template: EmailTemplateDTO; 20 | onTemplateChange: (template: EmailTemplateDTO) => any; 21 | } 22 | 23 | /** 24 | * An editor to modify email templates. 25 | */ 26 | export const EmailTemplateEditor = ({ 27 | title, 28 | template, 29 | onTemplateChange, 30 | }: IEmailTemplateEditor) => { 31 | const handleEmailTemplateChange = useCallback( 32 | (changes: Partial) => { 33 | onTemplateChange({ 34 | ...template, 35 | ...changes, 36 | }); 37 | }, 38 | [onTemplateChange, template], 39 | ); 40 | 41 | const handleSubjectChange = useCallback( 42 | (value: string) => handleEmailTemplateChange({ subject: value }), 43 | [handleEmailTemplateChange], 44 | ); 45 | 46 | const handleHtmlTemplateChange = useCallback( 47 | (value: string) => handleEmailTemplateChange({ htmlTemplate: value }), 48 | [handleEmailTemplateChange], 49 | ); 50 | 51 | const handleTextTemplateChange = useCallback( 52 | (value: string) => handleEmailTemplateChange({ textTemplate: value }), 53 | [handleEmailTemplateChange], 54 | ); 55 | 56 | return ( 57 | 58 | 59 | 60 | 66 | 67 | 68 | 69 | 78 | 79 | 80 | 81 | 90 | 91 | 92 | 93 | ); 94 | }; 95 | -------------------------------------------------------------------------------- /frontend/src/components/settings/question-editor.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | import { useCallback } from "react"; 4 | import type { QuestionDTO } from "../../api/types/dto"; 5 | import { transitionDuration } from "../../config"; 6 | import { useFortune } from "../../hooks/use-fortune"; 7 | import { useToggle } from "../../hooks/use-toggle"; 8 | import { variables } from "../../theme"; 9 | import { 10 | FlexColumnContainer, 11 | FlexRowContainer, 12 | Spacer, 13 | StyleableFlexContainer, 14 | } from "../base/flex"; 15 | import { SortingButtons } from "../forms/sorting-buttons"; 16 | import { UnifiedQuestion } from "../forms/unified-question"; 17 | import { UnifiedQuestionEditor } from "../forms/unified-question-editor"; 18 | import { SimpleCard } from "../base/simple-card"; 19 | 20 | const ButtonContainer = styled(StyleableFlexContainer)` 21 | align-self: flex-end; 22 | `; 23 | 24 | const MetaButton = styled.button` 25 | border: none; 26 | background: transparent; 27 | cursor: pointer; 28 | text-transform: uppercase; 29 | color: currentColor; 30 | font-weight: bold; 31 | `; 32 | 33 | const RemoveButton = styled(MetaButton)` 34 | color: red; 35 | transition-property: opacity; 36 | transition-duration: ${transitionDuration}; 37 | opacity: 0.3; 38 | 39 | &:hover { 40 | opacity: 1; 41 | } 42 | `; 43 | 44 | const FinishButton = styled(MetaButton)` 45 | color: ${variables.colorGradientStart}; 46 | `; 47 | 48 | const ignoreChange = () => 0; 49 | 50 | interface IQuestionEditorProps { 51 | question: QuestionDTO; 52 | onQuestionChange: (question: QuestionDTO) => any; 53 | onDeleteQuestion: (question: QuestionDTO) => any; 54 | allQuestions: readonly QuestionDTO[]; 55 | onMoveUp: () => void; 56 | onMoveDown: () => void; 57 | } 58 | 59 | /** 60 | * A question with an "Edit" and "Delete" button, and mock content. 61 | */ 62 | export const QuestionEditor = ({ 63 | question, 64 | onQuestionChange, 65 | onDeleteQuestion, 66 | allQuestions, 67 | onMoveDown, 68 | onMoveUp, 69 | }: IQuestionEditorProps) => { 70 | const [isEditing, toggleEditing] = useToggle(false); 71 | const fortune = useFortune(); 72 | 73 | const handleDelete = useCallback(() => { 74 | onDeleteQuestion(question); 75 | }, [onDeleteQuestion, question]); 76 | 77 | return ( 78 | 79 | 80 | 81 | 82 | 86 | 87 | 88 | 89 | {isEditing ? ( 90 | <> 91 | 92 | Delete question 93 | 94 | 95 | Finish editing 96 | 97 | 98 | ) : ( 99 | Edit 100 | )} 101 | 102 | 103 | 104 | {isEditing ? ( 105 | 110 | ) : ( 111 | 116 | )} 117 | 118 | 119 | ); 120 | }; 121 | -------------------------------------------------------------------------------- /frontend/src/components/settings/save-button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useSettingsContext } from "../../contexts/settings-context"; 3 | import { Button } from "../base/button"; 4 | 5 | /** 6 | * A button to save settings 7 | */ 8 | export const SettingsSaveButton = () => { 9 | const { save } = useSettingsContext(); 10 | return ( 11 | 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /frontend/src/components/settings/settings-section.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import * as React from "react"; 3 | import { StyleableFlexContainer } from "../base/flex"; 4 | import { Subheading } from "../base/headings"; 5 | 6 | const Section = styled(StyleableFlexContainer)` 7 | padding: 1rem 0; 8 | `; 9 | 10 | interface ISettingsSection { 11 | title: string; 12 | children: React.ReactNode; 13 | } 14 | 15 | /** 16 | * A section on the settings page. 17 | */ 18 | export const SettingsSection = ({ title, children }: ISettingsSection) => ( 19 |
    20 | 21 | {children} 22 |
    23 | ); 24 | -------------------------------------------------------------------------------- /frontend/src/config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Indicates whether the frontend is currently built in production. 3 | */ 4 | export const isProductionEnabled = process.env.NODE_ENV === "production"; 5 | 6 | const environmentBaseURL = process.env.API_BASE_URL ?? ""; 7 | 8 | /** 9 | * The document's base url, as defined by ``. 10 | */ 11 | export const documentBaseURL = 12 | document.querySelector("base")?.getAttribute("href")?.replace(/\/+$/, "") ?? 13 | ""; 14 | 15 | /** 16 | * The base url to a tilt backend. 17 | */ 18 | export const apiBaseUrl = environmentBaseURL.startsWith("http:") 19 | ? environmentBaseURL 20 | : `${documentBaseURL}${environmentBaseURL}`; 21 | 22 | /** 23 | * The default transition duration. 24 | */ 25 | export const transitionDuration = "0.2s"; 26 | 27 | /** 28 | * The default border radius. 29 | */ 30 | export const borderRadius = "5px"; 31 | 32 | /** 33 | * The sidebar width. 34 | */ 35 | export const sidebarWidth = "min(300px, 70vw)"; 36 | 37 | /** 38 | * The duration to show notifications. 39 | */ 40 | export const notificationDuration = 3000; 41 | 42 | /** 43 | * The duration to use for debouncing events. 44 | */ 45 | export const debounceDuration = 1000; 46 | 47 | /** 48 | * The default theme color, used while the theme is still loading. 49 | */ 50 | export const defaultThemeColor = "#333"; 51 | 52 | /** 53 | * The background color of the loading placeholder shimmer. 54 | */ 55 | export const shimmerBackgroundColor = "#f7f7f7"; 56 | 57 | /** 58 | * The color of the loading placeholder shimmer. 59 | */ 60 | export const shimmerColor = "#fefefe"; 61 | 62 | /** 63 | * The CSS breakpoints to determine whether a device is a tablet or a phone. 64 | */ 65 | export const mediaBreakpoints = { 66 | phone: "767px", 67 | tablet: "1024px", 68 | }; 69 | 70 | const chartColorMap = { 71 | blue: "rgb(54, 162, 235)", 72 | green: "rgb(75, 192, 192)", 73 | grey: "rgb(201, 203, 207)", 74 | orange: "rgb(255, 159, 64)", 75 | purple: "rgb(153, 102, 255)", 76 | black: "rgb(0, 0, 0)", 77 | greenSpecial: "rgb(86, 209, 117)", 78 | }; 79 | 80 | /** 81 | * Colors for charts, taken from chartjs.org 82 | */ 83 | export const chartColors = [ 84 | chartColorMap.black, 85 | chartColorMap.greenSpecial, 86 | chartColorMap.blue, 87 | chartColorMap.orange, 88 | chartColorMap.purple, 89 | chartColorMap.green, 90 | chartColorMap.grey, 91 | ]; 92 | 93 | /** 94 | * `chartColors` made slightly transparent. 95 | */ 96 | export const transparentChartColors = chartColors.map((color) => 97 | color.replace(")", ", 0.25)"), 98 | ); 99 | 100 | /** 101 | * The size of a horizontal or vertical spacer. 102 | */ 103 | export const spacerSize = "1rem"; 104 | -------------------------------------------------------------------------------- /frontend/src/contexts/login-context.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useCallback, useMemo, useState } from "react"; 3 | import type { UserDTO } from "../api/types/dto"; 4 | import { clearLoginToken, isLoginTokenSet } from "../authentication"; 5 | import { useApi } from "../hooks/use-api"; 6 | import { useContextOrThrow } from "../hooks/use-context-or-throw"; 7 | import { Nullable } from "../util"; 8 | 9 | interface ILoginBaseContextValue { 10 | updateUser: (reducer: (user: Nullable) => Nullable) => void; 11 | logout: () => void; 12 | user: Nullable; 13 | } 14 | 15 | interface ILoggedOutContextValue extends ILoginBaseContextValue { 16 | isLoggedIn: false; 17 | } 18 | 19 | interface ILoggedInContextValue extends ILoginBaseContextValue { 20 | isLoggedIn: true; 21 | } 22 | 23 | type ILoginContextValue = ILoggedOutContextValue | ILoggedInContextValue; 24 | 25 | const Context = React.createContext>(null); 26 | Context.displayName = "LoginContext"; 27 | 28 | interface ILoginContextProviderProps { 29 | children: React.ReactNode; 30 | } 31 | 32 | /** 33 | * A context storing and providing login functionality. 34 | * @param props The props to pass to this context 35 | */ 36 | export const LoginContextProvider = ({ 37 | children, 38 | }: ILoginContextProviderProps) => { 39 | const isAlreadyLoggedIn = isLoginTokenSet(); 40 | const [user, setUser] = useState>(null); 41 | 42 | useApi( 43 | async (api) => { 44 | if (isAlreadyLoggedIn) { 45 | try { 46 | const apiUser = await api.refreshLoginToken(); 47 | setUser(apiUser); 48 | } catch { 49 | // if we can't refresh our login token it either expired or its contents 50 | // changed and we need to request a new one by logging in again 51 | clearLoginToken(); 52 | setUser(null); 53 | } 54 | } 55 | }, 56 | [ 57 | // don't ever rerun this hook, since we only want this to run 58 | // once during app boot 59 | ], 60 | ); 61 | 62 | const logout = useCallback(() => { 63 | clearLoginToken(); 64 | setUser(null); 65 | }, []); 66 | 67 | const value = useMemo( 68 | () => ({ 69 | isLoggedIn: user != null || isAlreadyLoggedIn, 70 | logout, 71 | updateUser: setUser, 72 | user, 73 | }), 74 | [user, isAlreadyLoggedIn, logout], 75 | ); 76 | 77 | return {children}; 78 | }; 79 | 80 | /** 81 | * Gets the login context's value. 82 | */ 83 | export const useLoginContext = (): ILoginContextValue => 84 | useContextOrThrow(Context); 85 | -------------------------------------------------------------------------------- /frontend/src/contexts/notification-context.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useCallback, useMemo, useState } from "react"; 3 | import { Notification } from "../components/base/notification"; 4 | import { notificationDuration } from "../config"; 5 | import { useContextOrThrow } from "../hooks/use-context-or-throw"; 6 | import { Nullable, sleep } from "../util"; 7 | 8 | interface INotificationContextValue { 9 | showNotification: (message: string) => void; 10 | } 11 | 12 | const Context = React.createContext>(null); 13 | Context.displayName = "NotificationContext"; 14 | 15 | interface INotificationContextProps { 16 | children: React.ReactNode; 17 | } 18 | 19 | /** 20 | * A context to display notifications. 21 | * @param props The props to pass to this context 22 | */ 23 | export const NotificationContextProvider = ({ 24 | children, 25 | }: INotificationContextProps) => { 26 | const [message, setMessage] = useState(""); 27 | const [isShown, setIsShown] = useState(false); 28 | const showNotification = useCallback(async (messageToShow: string) => { 29 | setMessage(messageToShow); 30 | setIsShown(true); 31 | await sleep(notificationDuration); 32 | setIsShown(false); 33 | }, []); 34 | 35 | const value = useMemo( 36 | () => ({ 37 | showNotification, 38 | }), 39 | [showNotification], 40 | ); 41 | 42 | return ( 43 | 44 | {children} 45 | 46 | 47 | ); 48 | }; 49 | 50 | /** 51 | * Gets the notification context's value. 52 | */ 53 | export const useNotificationContext = (): INotificationContextValue => 54 | useContextOrThrow(Context); 55 | -------------------------------------------------------------------------------- /frontend/src/contexts/settings-context.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useCallback, useEffect, useMemo, useState } from "react"; 3 | import type { SettingsDTO } from "../api/types/dto"; 4 | import { PageSizedContainer } from "../components/base/flex"; 5 | import { SuspenseFallback } from "../components/base/suspense-fallback"; 6 | import { useApi } from "../hooks/use-api"; 7 | import { useContextOrThrow } from "../hooks/use-context-or-throw"; 8 | import { Nullable } from "../util"; 9 | import { useNotificationContext } from "./notification-context"; 10 | 11 | interface ISettingsContextValue { 12 | settings: SettingsDTO; 13 | updateSettings: (settings: SettingsDTO) => void; 14 | save: () => void; 15 | } 16 | 17 | const Context = React.createContext>(null); 18 | Context.displayName = "SettingsContext"; 19 | 20 | interface ISettingsContextProviderProps { 21 | children: React.ReactNode; 22 | } 23 | 24 | /** 25 | * A context storing and loading the application settings 26 | * @param props The props to pass to this context 27 | */ 28 | export const SettingsContextProvider = ({ 29 | children, 30 | }: ISettingsContextProviderProps) => { 31 | const { showNotification } = useNotificationContext(); 32 | const [localSettings, setLocalSettings] = 33 | useState>(null); 34 | 35 | const [isLocallyUpdated, setIsLocallyUpdated] = useState(false); 36 | 37 | useEffect(() => { 38 | if (!isLocallyUpdated) { 39 | return; 40 | } 41 | 42 | const handleUnload = (event: Event) => { 43 | const wantsToLeave = window.confirm( 44 | "Unsaved changes, are you sure you want to leave?", 45 | ); 46 | 47 | if (wantsToLeave) { 48 | return; 49 | } 50 | 51 | event.preventDefault(); 52 | return false; 53 | }; 54 | 55 | window.addEventListener("beforeunload", handleUnload); 56 | return () => window.removeEventListener("beforeunload", handleUnload); 57 | }, [isLocallyUpdated]); 58 | 59 | const { isFetching: isFetchingSettings, error: fetchError } = useApi( 60 | async (api) => { 61 | const settings = await api.getSettings(); 62 | setLocalSettings(settings); 63 | setIsLocallyUpdated(false); 64 | }, 65 | [], 66 | ); 67 | 68 | if (fetchError) { 69 | throw fetchError; 70 | } 71 | 72 | if (!isFetchingSettings && localSettings == null) { 73 | throw new Error("No settings received from the server"); 74 | } 75 | 76 | const updateSettings = useCallback((settings: SettingsDTO) => { 77 | if (settings != null) { 78 | setLocalSettings(settings); 79 | setIsLocallyUpdated(true); 80 | } 81 | }, []); 82 | 83 | const { forcePerformRequest: save } = useApi( 84 | async (api, wasForced) => { 85 | if (!wasForced || localSettings == null) { 86 | return; 87 | } 88 | 89 | const updatedSettings = await api.updateSettings(localSettings); 90 | 91 | setLocalSettings(updatedSettings); 92 | showNotification("Changes saved"); 93 | }, 94 | [localSettings], 95 | ); 96 | 97 | const value = useMemo( 98 | () => ({ 99 | settings: localSettings as SettingsDTO, 100 | updateSettings, 101 | save, 102 | }), 103 | [localSettings, updateSettings, save], 104 | ); 105 | 106 | if (isFetchingSettings || localSettings == null) { 107 | return ( 108 | 109 | 110 | 111 | ); 112 | } 113 | 114 | return {children}; 115 | }; 116 | 117 | /** 118 | * Gets the settings context's value. 119 | */ 120 | export const useSettingsContext = () => useContextOrThrow(Context); 121 | -------------------------------------------------------------------------------- /frontend/src/fortunes.ts: -------------------------------------------------------------------------------- 1 | const fortunes = [ 2 | "get some sleep", 3 | "it's late, why don't you do this tomorrow", 4 | "I don't want to do this now either", 5 | "your free trial has expired", 6 | "undefined", 7 | "TypeError: undefined is not a function", 8 | "^C^C^C^C", 9 | ":wq", 10 | ]; 11 | 12 | /** 13 | * Returns a random fortune, used to display as mock text. 14 | */ 15 | export const randomFortune = () => { 16 | const index = Math.floor(fortunes.length * Math.random()); 17 | return fortunes[index]; 18 | }; 19 | -------------------------------------------------------------------------------- /frontend/src/heuristics.ts: -------------------------------------------------------------------------------- 1 | import { QuestionDTO } from "./api/types/dto"; 2 | import { QuestionType } from "./api/types/enums"; 3 | 4 | /** 5 | * A heuristic to determine whether a question is used to query for a user's name. 6 | * @param question The question to check 7 | */ 8 | export const isNameQuestion = (question: QuestionDTO): boolean => { 9 | return ( 10 | question.title.toLowerCase().includes("name") && 11 | question.configuration.type === QuestionType.Text && 12 | question.mandatory && 13 | question.parentID == null 14 | ); 15 | }; 16 | 17 | /** 18 | * A heuristic to determine whether a question is used to query for the team a 19 | * user wants to be on. 20 | * @param question The question to check 21 | */ 22 | export const isTeamQuestion = (question: QuestionDTO): boolean => { 23 | return ( 24 | question.title.toLowerCase().includes("team") && 25 | question.configuration.type === QuestionType.Text 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /frontend/src/hooks/use-api.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react"; 2 | import { ApiClient } from "../api"; 3 | import { apiBaseUrl } from "../config"; 4 | import { Nullable } from "../util"; 5 | 6 | const api = new ApiClient(apiBaseUrl); 7 | 8 | interface IApiResult { 9 | value: Nullable; 10 | isFetching: boolean; 11 | error: Nullable; 12 | forcePerformRequest: () => void; 13 | } 14 | 15 | /** 16 | * Gets a result from the api. 17 | * @param callback A consumer returning values from the api 18 | * @param deps Dependencies inside the callback requiring the call to run again 19 | */ 20 | export const useApi = ( 21 | callback: (api: ApiClient, wasForced: boolean) => Promise, 22 | deps: readonly any[] = [], 23 | ): IApiResult => { 24 | const [isFetching, setIsFetching] = useState(true); 25 | const [value, setValue] = useState>(null); 26 | const [error, setError] = useState>(null); 27 | 28 | const performRequest = useCallback(async (wasForced: boolean) => { 29 | setIsFetching(true); 30 | setError(null); 31 | setValue(null); 32 | 33 | try { 34 | const result = await callback(api, wasForced); 35 | setValue(result); 36 | } catch (error) { 37 | if (error instanceof Error) { 38 | setError(error); 39 | } else { 40 | setError(new Error(String(error))); 41 | } 42 | } 43 | 44 | setIsFetching(false); 45 | }, deps); 46 | 47 | useEffect(() => { 48 | performRequest(false); 49 | }, deps); 50 | 51 | const forcePerformRequest = useCallback( 52 | () => performRequest(true), 53 | [performRequest], 54 | ); 55 | 56 | return { 57 | error, 58 | forcePerformRequest, 59 | isFetching, 60 | value, 61 | }; 62 | }; 63 | 64 | /** 65 | * Gets a result from the api without a hook. 66 | * @param callback A consumer returning values from the api 67 | */ 68 | export const performApiRequest = ( 69 | callback: (api: ApiClient) => Promise, 70 | ): Promise => callback(api); 71 | -------------------------------------------------------------------------------- /frontend/src/hooks/use-context-or-throw.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { Nullable } from "../util"; 3 | 4 | /** 5 | * Safely accesses a potentially empty context, throwing an error if there's no value. 6 | * @param context The context to access 7 | */ 8 | export const useContextOrThrow = ( 9 | context: React.Context>, 10 | ): T => { 11 | const value = useContext(context); 12 | 13 | if (value == null) { 14 | throw new Error(`${context.displayName ?? "Context"}.Provider is empty.`); 15 | } 16 | 17 | return value; 18 | }; 19 | -------------------------------------------------------------------------------- /frontend/src/hooks/use-derived-state.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react"; 2 | 3 | /** 4 | * Derives mutable state from the given function. This can be used to, e.g., map 5 | * props to state in a safer manner than just passing it to `useState`. Changed 6 | * props, in this example, will take precedence over the locally mutated state. 7 | * @param deriveFn A function to derive state 8 | * @param deps Dependencies for the derive function 9 | */ 10 | export const useDerivedState = ( 11 | deriveFn: () => T, 12 | deps?: readonly any[], 13 | ): [T, Dispatch>] => { 14 | const derivedState = useMemo(deriveFn, deps); 15 | const [state, setState] = useState(derivedState); 16 | 17 | useEffect(() => { 18 | setState(derivedState); 19 | }, [derivedState]); 20 | 21 | return [state, setState]; 22 | }; 23 | -------------------------------------------------------------------------------- /frontend/src/hooks/use-focus.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from "react"; 2 | 3 | type ISetFocus = () => void; 4 | type IResetFocus = () => void; 5 | 6 | /** 7 | * A hook to manage focus state via a focus and blur function. 8 | * @param initialFocusState The initial focus state 9 | */ 10 | export const useFocus = ( 11 | initialFocusState: boolean, 12 | ): [boolean, ISetFocus, IResetFocus] => { 13 | const [isFocused, setIsFocused] = useState(initialFocusState); 14 | const setFocus = useCallback(() => setIsFocused(true), []); 15 | const resetFocus = useCallback(() => setIsFocused(false), []); 16 | return [isFocused, setFocus, resetFocus]; 17 | }; 18 | -------------------------------------------------------------------------------- /frontend/src/hooks/use-fortune.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from "react"; 2 | import { randomFortune } from "../fortunes"; 3 | 4 | /** 5 | * Gets a random, but consistent fortune. 6 | */ 7 | export const useFortune = () => { 8 | const { current: fortune } = useRef(randomFortune()); 9 | return fortune; 10 | }; 11 | -------------------------------------------------------------------------------- /frontend/src/hooks/use-is-responsive.ts: -------------------------------------------------------------------------------- 1 | import { mediaBreakpoints } from "../config"; 2 | import { useMediaQuery } from "./use-media-query"; 3 | 4 | const query = `(max-width: ${mediaBreakpoints.tablet})`; 5 | 6 | /** 7 | * Returns whether we're currently displaying responsive layout. 8 | */ 9 | export const useIsResponsive = (): boolean => { 10 | return useMediaQuery(query); 11 | }; 12 | -------------------------------------------------------------------------------- /frontend/src/hooks/use-media-query.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from "react"; 2 | 3 | /** 4 | * Matches the given media query. 5 | * @param query A media query to match 6 | */ 7 | export const useMediaQuery = (query: string): boolean => { 8 | const media = useMemo(() => window.matchMedia(query), [query]); 9 | const [isMatch, setIsMatch] = useState(() => media.matches); 10 | 11 | useEffect(() => { 12 | const handler = () => { 13 | setIsMatch(media.matches); 14 | }; 15 | 16 | media.addListener(handler); 17 | 18 | return () => media.removeListener(handler); 19 | }, [media]); 20 | 21 | return isMatch; 22 | }; 23 | -------------------------------------------------------------------------------- /frontend/src/hooks/use-toggle.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from "react"; 2 | 3 | /** 4 | * A toggleable state. 5 | * @param initialValue The initial toggle value 6 | */ 7 | export const useToggle = (initialValue: boolean): [boolean, () => void] => { 8 | const [state, setState] = useState(initialValue); 9 | const toggle = useCallback(() => { 10 | setState((value) => !value); 11 | }, []); 12 | 13 | return [state, toggle]; 14 | }; 15 | -------------------------------------------------------------------------------- /frontend/src/hooks/use-uniqe-id.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from "react"; 2 | import { Nullable } from "../util"; 3 | 4 | let counter = 0; 5 | 6 | // it's safe to assume this won't overflow 7 | const incrementAndGetCounter = () => ++counter; 8 | 9 | const generateUniqueID = () => `unique-id-${incrementAndGetCounter()}`; 10 | 11 | /** 12 | * Generates a unique id. 13 | */ 14 | export const useUniqueID = (): string => { 15 | const ref = useRef>(null); 16 | 17 | if (ref.current == null) { 18 | ref.current = generateUniqueID(); 19 | } 20 | 21 | return ref.current as string; 22 | }; 23 | 24 | /** 25 | * Generates an array of unique ids. 26 | * @param count The amount of ids to generate 27 | */ 28 | export const useUniqueIDs = (count: number): readonly string[] => { 29 | const ref = useRef>(null); 30 | 31 | if (ref.current == null) { 32 | ref.current = new Array(count).fill(0).map(() => generateUniqueID()); 33 | } 34 | 35 | return ref.current as readonly string[]; 36 | }; 37 | -------------------------------------------------------------------------------- /frontend/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | tilt 6 | 7 | 8 | 23 | 24 | 25 |
    26 | 27 | 28 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ReactDOM from "react-dom"; 3 | import { BrowserRouter } from "react-router-dom"; 4 | import { RoutedApp } from "./components/app"; 5 | import { documentBaseURL } from "./config"; 6 | import { LoginContextProvider } from "./contexts/login-context"; 7 | import { NotificationContextProvider } from "./contexts/notification-context"; 8 | import { SettingsContextProvider } from "./contexts/settings-context"; 9 | 10 | const baseURL = documentBaseURL.replace(/^https?:\/\/[^\/]*/, ""); 11 | const container = document.getElementById("app"); 12 | const app = ( 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | 26 | ReactDOM.render(app, container); 27 | -------------------------------------------------------------------------------- /frontend/src/routes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The routes in the frontend. 3 | */ 4 | export enum Routes { 5 | Admission = "/admission", 6 | ConfirmationForm = "/confirm", 7 | ConfirmationFormApply = "/apply/confirm", 8 | Login = "/login", 9 | ForgotPassword = "/forgot-password", 10 | ResetPassword = "/reset-password", 11 | RegisterForm = "/register-form", 12 | Logout = "/logout", 13 | ProfileForm = "/profile", 14 | ProfileFormApply = "/apply/profile", 15 | Settings = "/settings", 16 | SignupDone = "/signup-done", 17 | Statistics = "/statistics", 18 | Status = "/dashboard", 19 | System = "/system", 20 | VerifyEmail = "/verify", 21 | Map = "/map", 22 | Challenges = "/challenges", 23 | } 24 | 25 | /** 26 | * The default initial route for authenticated users. 27 | */ 28 | export const defaultAuthenticatedRoute = Routes.Status; 29 | 30 | /** 31 | * Routes to exclude when redirecting to the default authenticated route. 32 | */ 33 | export const authenticatedRoutes = [ 34 | Routes.Admission, 35 | Routes.ConfirmationForm, 36 | Routes.Logout, 37 | Routes.ProfileForm, 38 | Routes.Settings, 39 | Routes.Statistics, 40 | Routes.System, 41 | Routes.Map, 42 | Routes.Challenges, 43 | ]; 44 | -------------------------------------------------------------------------------- /frontend/src/theme.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | const variableNames = { 4 | colorGradientEnd: "--color-gradient-end", 5 | colorGradientStart: "--color-gradient-start", 6 | colorLink: "--color-link", 7 | colorLinkHover: "--color-link-hover", 8 | }; 9 | 10 | type Variables = typeof variableNames; 11 | 12 | /** 13 | * CSS variables for our theme. 14 | */ 15 | export const variables = [...Object.entries(variableNames)].reduce( 16 | (accumulator, [name, variable]) => ({ 17 | ...accumulator, 18 | [name]: `var(${variable})`, 19 | }), 20 | {}, 21 | ) as Variables; 22 | 23 | interface IThemeProviderProps { 24 | children: any; 25 | values: Variables; 26 | } 27 | 28 | /** 29 | * A theme provider based on CSS variables. 30 | */ 31 | export const ThemeProvider = ({ children, values }: IThemeProviderProps) => { 32 | const css = [...Object.entries(values)] 33 | .map(([key, value]) => { 34 | const variable = variableNames[key as keyof Variables]; 35 | 36 | if (!variable) { 37 | return ""; 38 | } 39 | 40 | return `${variable}: ${value};`; 41 | }) 42 | .join(""); 43 | 44 | return ( 45 | <> 46 | 47 | {children} 48 | 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /frontend/test/__mocks__/api.ts: -------------------------------------------------------------------------------- 1 | import { ApiClient } from "../../src/api"; 2 | import { PublicFields } from "../../src/util"; 3 | 4 | type IMockedApi = { 5 | [K in keyof PublicFields]: jest.Mock; 6 | }; 7 | 8 | /** 9 | * A mocked api client. 10 | */ 11 | export const api: IMockedApi = { 12 | admit: jest.fn(), 13 | checkIn: jest.fn(), 14 | declineSpot: jest.fn(), 15 | deleteUser: jest.fn(), 16 | getAllApplications: jest.fn(), 17 | getConfirmationForm: jest.fn(), 18 | getProfileForm: jest.fn(), 19 | getSettings: jest.fn(), 20 | login: jest.fn(), 21 | pruneSystem: jest.fn(), 22 | refreshLoginToken: jest.fn(), 23 | signup: jest.fn(), 24 | storeConfirmationFormAnswers: jest.fn(), 25 | storeProfileFormAnswers: jest.fn(), 26 | updateSettings: jest.fn(), 27 | verifyEmail: jest.fn(), 28 | forgotPassword: jest.fn(), 29 | resetPassword: jest.fn(), 30 | }; 31 | -------------------------------------------------------------------------------- /frontend/test/components/button.spec.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from "enzyme"; 2 | import * as React from "react"; 3 | import { Button } from "../../src/components/base/button"; 4 | 5 | describe("Button", () => { 6 | it("renders the given text inside the button", () => { 7 | const text = "test"; 8 | const element = shallow(); 9 | 10 | expect(element).toHaveText(text); 11 | }); 12 | 13 | it("handles click events", () => { 14 | const callback = jest.fn(); 15 | const element = shallow(); 16 | 17 | element.simulate("click"); 18 | expect(callback).toBeCalled(); 19 | }); 20 | 21 | it("respects the disable prop", () => { 22 | const callback = jest.fn(); 23 | const element = shallow( 24 | , 27 | ); 28 | 29 | element.simulate("click"); 30 | expect(callback).not.toBeCalled(); 31 | }); 32 | 33 | it("respects the loading prop", () => { 34 | const callback = jest.fn(); 35 | const element = shallow( 36 | , 39 | ); 40 | 41 | element.simulate("click"); 42 | expect(callback).not.toBeCalled(); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /frontend/test/setup.ts: -------------------------------------------------------------------------------- 1 | import * as Adapter from "@wojtekmaj/enzyme-adapter-react-17"; 2 | import * as Enzyme from "enzyme"; 3 | import "jest-date-mock"; 4 | import "jest-enzyme"; 5 | 6 | Enzyme.configure({ 7 | adapter: new Adapter(), 8 | }); 9 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "experimentalDecorators": true, 5 | "importHelpers": true, 6 | "jsx": "react", 7 | "module": "esnext", 8 | "moduleResolution": "node", 9 | "noUnusedLocals": true, 10 | "noUnusedParameters": true, 11 | "target": "es2017", 12 | "resolveJsonModule": true, 13 | "strict": true, 14 | "skipLibCheck": true 15 | }, 16 | "include": [ 17 | "src/**/*.ts", 18 | "src/**/*.tsx", 19 | "test/**/*.ts", 20 | "test/**/*.tsx" 21 | ], 22 | 23 | } 24 | -------------------------------------------------------------------------------- /frontend/webpack.config.js: -------------------------------------------------------------------------------- 1 | const ReactRefreshPlugin = require("@pmmmwh/react-refresh-webpack-plugin"); 2 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 3 | const TerserPlugin = require("terser-webpack-plugin"); 4 | const { EnvironmentPlugin } = require("webpack"); 5 | const { join, resolve } = require("path"); 6 | const { BundleAnalyzerPlugin } = require("webpack-bundle-analyzer"); 7 | 8 | const isProduction = process.env.NODE_ENV === "production"; 9 | 10 | module.exports = { 11 | entry: "./src/index.tsx", 12 | 13 | output: { 14 | filename: isProduction ? "[name].[contenthash].js" : "[name].[hash].js", 15 | chunkFilename: isProduction 16 | ? "[name].[contenthash].js" 17 | : "[name].[hash].js", 18 | path: __dirname + "/dist", 19 | }, 20 | 21 | resolve: { 22 | extensions: [".ts", ".tsx", ".js", ".json"], 23 | }, 24 | 25 | optimization: isProduction 26 | ? { 27 | minimizer: [ 28 | new TerserPlugin({ 29 | terserOptions: { 30 | output: { 31 | comments: false, 32 | }, 33 | }, 34 | }), 35 | ], 36 | runtimeChunk: "single", 37 | splitChunks: { 38 | chunks: "all", 39 | }, 40 | } 41 | : undefined, 42 | 43 | module: { 44 | rules: [ 45 | { 46 | test: /\.tsx?$/, 47 | use: [ 48 | !isProduction && { 49 | loader: "babel-loader", 50 | options: { 51 | plugins: ["react-refresh/babel"], 52 | }, 53 | }, 54 | { 55 | loader: "ts-loader", 56 | options: { 57 | // we use tsc to check for errors instead 58 | transpileOnly: true, 59 | }, 60 | }, 61 | ].filter(Boolean), 62 | }, 63 | { 64 | test: /\.(png|jpg|svg)$/, 65 | loader: "file-loader", 66 | options: { 67 | outputPath: "assets/", 68 | }, 69 | }, 70 | ], 71 | }, 72 | 73 | plugins: [ 74 | !isProduction && new ReactRefreshPlugin(), 75 | new EnvironmentPlugin({ 76 | API_BASE_URL: "/api", 77 | NODE_ENV: "development", 78 | }), 79 | new HtmlWebpackPlugin({ 80 | template: "./src/index.html", 81 | }), 82 | new BundleAnalyzerPlugin({ 83 | analyzerMode: "static", 84 | openAnalyzer: false, 85 | reportFilename: "../bundle/report.html", 86 | }), 87 | ].filter(Boolean), 88 | 89 | devServer: { 90 | historyApiFallback: true, 91 | }, 92 | }; 93 | -------------------------------------------------------------------------------- /proxy/nginx.conf: -------------------------------------------------------------------------------- 1 | user nginx; 2 | worker_processes auto; 3 | 4 | pid /var/run/nginx.pid; 5 | 6 | events { 7 | worker_connections 1024; 8 | } 9 | 10 | http { 11 | log_format main '[$time_iso8601] $request_method $uri - $status'; 12 | server_tokens off; 13 | 14 | access_log /var/log/nginx/access.log main; 15 | error_log /dev/null; 16 | 17 | server { 18 | server_name localhost; 19 | listen 80; 20 | 21 | location /apply/ { 22 | proxy_pass http://tilt:3000/; 23 | proxy_set_header Host $host; 24 | proxy_set_header X-Forwarded-For $remote_addr; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended", 5 | "tslint-react-hooks" 6 | ], 7 | "jsRules": {}, 8 | "rules": { 9 | "ordered-imports": false, 10 | "completed-docs": [ 11 | true, 12 | { 13 | "enums": true, 14 | "functions": { 15 | "visibilities": [ 16 | "exported" 17 | ] 18 | }, 19 | "interfaces": { 20 | "visibilities": [ 21 | "exported" 22 | ] 23 | }, 24 | "methods": { 25 | "tags": { 26 | "content": {}, 27 | "existence": [ 28 | "inheritdoc", 29 | "override" 30 | ] 31 | } 32 | }, 33 | "types": { 34 | "visibilities": [ 35 | "exported" 36 | ] 37 | }, 38 | "variables": { 39 | "visibilities": [ 40 | "exported" 41 | ] 42 | } 43 | } 44 | ], 45 | "max-classes-per-file": false, 46 | "max-line-length": false, 47 | "member-ordering": false, 48 | "variable-name": false 49 | }, 50 | "rulesDirectory": [] 51 | } 52 | --------------------------------------------------------------------------------