├── .devcontainer └── devcontainer.json ├── .dockerignore ├── .editorconfig ├── .env.ci ├── .eslintignore ├── .eslintrc.js ├── .gitattributes ├── .github └── workflows │ ├── cypress.yml │ ├── nodejs.yml │ ├── pgrita.yml │ ├── production-docker.yml │ ├── test-docker.js │ └── windows-nodejs.yml ├── .gitignore ├── .vscode ├── README.md ├── extensions.json ├── launch.json ├── settings.json └── spellright.dict ├── .yarn ├── plugins │ └── @yarnpkg │ │ ├── plugin-interactive-tools.cjs │ │ ├── plugin-typescript.cjs │ │ └── plugin-workspace-tools.cjs └── releases │ └── yarn-3.4.1.cjs ├── .yarnrc.yml ├── @app ├── README.md ├── __tests__ │ ├── README.md │ └── helpers.ts ├── client │ ├── .babelrc │ ├── README.md │ ├── babel.config.js │ ├── jest.config.js │ ├── package.json │ └── src │ │ ├── graphql │ │ ├── AcceptOrganizationInvite.graphql │ │ ├── AddEmail.graphql │ │ ├── ChangePassword.graphql │ │ ├── ConfirmAccountDeletion.graphql │ │ ├── CreateOrganization.graphql │ │ ├── CurrentUserAuthentications.graphql │ │ ├── CurrentUserUpdated.graphql │ │ ├── DeleteEmail.graphql │ │ ├── DeleteOrganization.graphql │ │ ├── EmailsForm_User.graphql │ │ ├── EmailsForm_UserEmail.graphql │ │ ├── ForgotPassword.graphql │ │ ├── InvitationDetail.graphql │ │ ├── InviteToOrganization.graphql │ │ ├── Login.graphql │ │ ├── Logout.graphql │ │ ├── MakeEmailPrimary.graphql │ │ ├── OrganizationBySlug.graphql │ │ ├── OrganizationMembers.graphql │ │ ├── OrganizationPage.graphql │ │ ├── OrganizationPage_Query.graphql │ │ ├── ProfileSettingsForm_User.graphql │ │ ├── Register.graphql │ │ ├── RemoveFromOrganization.graphql │ │ ├── RequestAccountDeletion.graphql │ │ ├── ResendEmailVerification.graphql │ │ ├── ResetPassword.graphql │ │ ├── SettingsEmails.graphql │ │ ├── SettingsPassword.graphql │ │ ├── SettingsProfile.graphql │ │ ├── Shared.graphql │ │ ├── SharedLayout_Query.graphql │ │ ├── TransferOrganizationBillingContact.graphql │ │ ├── TransferOrganizationOwnership.graphql │ │ ├── UnlinkUserAuthentication.graphql │ │ ├── UpdateOrganization.graphql │ │ ├── UpdateUser.graphql │ │ └── VerifyEmail.graphql │ │ ├── next-env.d.ts │ │ ├── next.config.js │ │ ├── pages │ │ ├── _app.tsx │ │ ├── _document.tsx │ │ ├── _error.tsx │ │ ├── create-organization │ │ │ └── index.tsx │ │ ├── forgot.tsx │ │ ├── index.tsx │ │ ├── invitations │ │ │ └── accept.tsx │ │ ├── login.tsx │ │ ├── o │ │ │ ├── [slug] │ │ │ │ ├── index.tsx │ │ │ │ └── settings │ │ │ │ │ ├── delete.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── members.tsx │ │ │ └── index.tsx │ │ ├── register.tsx │ │ ├── reset.tsx │ │ ├── settings │ │ │ ├── accounts.tsx │ │ │ ├── delete.tsx │ │ │ ├── emails.tsx │ │ │ ├── index.tsx │ │ │ └── security.tsx │ │ └── verify.tsx │ │ ├── styles.css │ │ └── tsconfig.json ├── components │ ├── README.md │ ├── babel.config.js │ ├── jest.config.js │ ├── package.json │ ├── src │ │ ├── ButtonLink.tsx │ │ ├── ErrorAlert.tsx │ │ ├── ErrorOccurred.tsx │ │ ├── FourOhFour.tsx │ │ ├── OrganizationSettingsLayout.tsx │ │ ├── PasswordStrength.tsx │ │ ├── Redirect.tsx │ │ ├── SettingsLayout.tsx │ │ ├── SharedLayout.tsx │ │ ├── SocialLoginOptions.tsx │ │ ├── SpinPadded.tsx │ │ ├── StandardWidth.tsx │ │ ├── Text.tsx │ │ ├── Warn.tsx │ │ ├── index.tsx │ │ └── organizationHooks.tsx │ └── tsconfig.json ├── config │ ├── README.md │ ├── babel.config.js │ ├── env.js │ ├── extra.js │ ├── jest.config.js │ ├── package.json │ ├── src │ │ └── index.ts │ └── tsconfig.json ├── db │ ├── .gmrc │ ├── CONVENTIONS.md │ ├── README.md │ ├── __tests__ │ │ ├── .jest.watch.hack.json │ │ ├── README.md │ │ ├── app_private │ │ │ └── functions │ │ │ │ ├── link_or_register_user.test.ts │ │ │ │ ├── login.test.ts │ │ │ │ └── really_create_user.test.ts │ │ ├── app_public │ │ │ ├── functions │ │ │ │ ├── change_password.test.ts │ │ │ │ ├── confirm_account_deletion.test.ts │ │ │ │ ├── forgot_password.test.ts │ │ │ │ ├── invite_to_organization.test.ts │ │ │ │ ├── logout.test.ts │ │ │ │ └── reset_password.test.ts │ │ │ └── tables │ │ │ │ └── user_emails.test.ts │ │ ├── helpers.ts │ │ └── jest.watch.hack.ts │ ├── babel.config.js │ ├── jest.config.js │ ├── migrations │ │ ├── README.md │ │ ├── afterReset.sql │ │ ├── committed │ │ │ └── 000001.sql │ │ └── current │ │ │ └── 1-current.sql │ ├── package.json │ └── scripts │ │ ├── dump-db.js │ │ ├── test-seed.js │ │ └── wipe-if-demo ├── e2e │ ├── README.md │ ├── babel.config.js │ ├── cypress.config.js │ ├── cypress │ │ ├── e2e │ │ │ ├── homepage.cy.ts │ │ │ ├── login.cy.ts │ │ │ ├── manage_emails.cy.ts │ │ │ ├── organization_create.cy.ts │ │ │ ├── organization_page.cy.ts │ │ │ ├── register_account.cy.ts │ │ │ ├── subscriptions.cy.ts │ │ │ └── verify_email.cy.ts │ │ ├── fixtures │ │ │ └── example.json │ │ ├── support │ │ │ ├── commands.ts │ │ │ └── e2e.js │ │ └── tsconfig.json │ ├── jest.config.js │ └── package.json ├── graphql │ ├── README.md │ ├── babel.config.js │ ├── codegen.yml │ ├── jest.config.js │ ├── package.json │ └── tsconfig.json ├── lib │ ├── README.md │ ├── babel.config.js │ ├── jest.config.js │ ├── package.json │ ├── src │ │ ├── GraphileApolloLink.client.ts │ │ ├── GraphileApolloLink.ts │ │ ├── errors.ts │ │ ├── forms.ts │ │ ├── index.tsx │ │ ├── passwords.ts │ │ └── withApollo.tsx │ └── tsconfig.json ├── server │ ├── README.md │ ├── __tests__ │ │ ├── helpers.ts │ │ ├── mutations │ │ │ ├── __snapshots__ │ │ │ │ └── register.test.ts.snap │ │ │ └── register.test.ts │ │ └── queries │ │ │ ├── __snapshots__ │ │ │ └── currentUser.test.ts.snap │ │ │ └── currentUser.test.ts │ ├── babel.config.js │ ├── error.html │ ├── jest.config.js │ ├── package.json │ ├── postgraphile.tags.jsonc │ ├── public │ │ └── favicon.ico │ ├── scripts │ │ └── schema-export.ts │ ├── src │ │ ├── .eslintrc.js │ │ ├── app.ts │ │ ├── cloudflare.ts │ │ ├── fs.ts │ │ ├── graphile.config.ts │ │ ├── index.ts │ │ ├── middleware │ │ │ ├── index.ts │ │ │ ├── installCSRFProtection.ts │ │ │ ├── installCypressServerCommand.ts │ │ │ ├── installDatabasePools.ts │ │ │ ├── installErrorHandler.ts │ │ │ ├── installForceSSL.ts │ │ │ ├── installHelmet.ts │ │ │ ├── installLogging.ts │ │ │ ├── installPassport.ts │ │ │ ├── installPassportStrategy.ts │ │ │ ├── installPostGraphile.ts │ │ │ ├── installSSR.ts │ │ │ ├── installSameOrigin.ts │ │ │ ├── installSession.ts │ │ │ ├── installSharedStatic.ts │ │ │ └── installWorkerUtils.ts │ │ ├── plugins │ │ │ ├── Orders.ts │ │ │ ├── PassportLoginPlugin.ts │ │ │ ├── PrimaryKeyMutationsOnlyPlugin.ts │ │ │ ├── RemoveQueryQueryPlugin.ts │ │ │ └── SubscriptionsPlugin.ts │ │ ├── shutdownActions.ts │ │ └── utils │ │ │ ├── handleErrors.ts │ │ │ └── index.ts │ └── tsconfig.json └── worker │ ├── README.md │ ├── babel.config.js │ ├── crontab │ ├── jest.config.js │ ├── package.json │ ├── src │ ├── fs.ts │ ├── tasks │ │ ├── organization_invitations__send_invite.ts │ │ ├── send_email.ts │ │ ├── user__audit.ts │ │ ├── user__forgot_password.ts │ │ ├── user__forgot_password_unregistered_email.ts │ │ ├── user__send_delete_account_email.ts │ │ └── user_emails__send_verification.ts │ └── transport.ts │ ├── templates │ ├── account_activity.mjml │ ├── delete_account.mjml │ ├── organization_invite.mjml │ ├── password_reset.mjml │ ├── password_reset_unregistered.mjml │ └── verify_email.mjml │ └── tsconfig.json ├── CONTRIBUTING.md ├── Dockerfile ├── GRAPHILE_STARTER_LICENSE.md ├── LICENSE.md ├── Procfile ├── README.md ├── SPONSORS.md ├── TECHNICAL_DECISIONS.md ├── apollo.config.js ├── babel.config.js ├── data ├── README.md ├── amazon-rds-ca-cert.pem ├── schema.graphql └── schema.sql ├── docker-compose.yml ├── docker ├── README.md ├── package.json ├── scripts │ ├── clean-volumes.js │ ├── copy-local-config-and-ssh-creds.sh │ ├── lsfix.sh │ └── yarn-setup.js └── setup.sh ├── dockerctl ├── docs ├── error_codes.md └── production_todo.md ├── heroku-setup.template ├── jest.config.base.js ├── jest.config.js ├── package.json ├── production.Dockerfile ├── scripts ├── _setup_utils.js ├── clean.js ├── delete-env-file.js ├── lib │ ├── dotenv.js │ ├── random.js │ └── run.js ├── run-docker-with-env.js ├── setup_db.js ├── setup_env.js ├── start.js └── test.js ├── tsconfig.json └── yarn.lock /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // If you want to run as a non-root user in the container, see ../docker-compose.yml. 2 | { 3 | "name": "Node.js & Postgres", 4 | "dockerComposeFile": "../docker-compose.yml", 5 | "service": "dev", // attaches to this service after docker-compose up `runServices` 6 | "workspaceFolder": "/work", 7 | 8 | // Use 'settings' to set *default* container specific settings.json values on container create. 9 | // You can edit these settings after create using File > Preferences > Settings > Remote. 10 | "settings": { 11 | "terminal.integrated.shell.linux": "/bin/bash" 12 | }, 13 | 14 | // Uncomment the next line if you want start specific services in your Docker Compose config. 15 | "runServices": ["dev"], // only run dev, not also server 16 | 17 | // Uncomment the line below if you want to keep your containers running after VS Code shuts down. 18 | // "shutdownAction": "none", 19 | 20 | // Uncomment next line if you want to copy your .ssh creds and other config files for easier use inside container 21 | //"postCreateCommand": "bash ./docker/scripts/copy-local-config-and-ssh-creds.sh", 22 | 23 | // Add the IDs of extensions you want installed when the container is created in the array below. 24 | "extensions": [ 25 | "dbaeumer.vscode-eslint", 26 | "esbenp.prettier-vscode", 27 | "msjsdiag.debugger-for-chrome", 28 | "apollographql.vscode-apollo", 29 | "mikestead.dotenv", 30 | "ms-azuretools.vscode-docker", 31 | "p1c2u.docker-compose", 32 | "dzannotti.vscode-babel-coloring", 33 | "aaron-bond.better-comments", 34 | "pranaygp.vscode-css-peek", 35 | "codemooseus.vscode-devtools-for-chrome", 36 | "wix.vscode-import-cost", 37 | "cancerberosgx.vscode-typescript-refactors", 38 | "steoates.autoimport" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .docker 2 | .env 3 | .git 4 | .github 5 | .vscode 6 | 7 | @app/e2e 8 | 9 | *Dockerfile* 10 | *docker-compose* 11 | 12 | **/.DS_Store 13 | **/.next 14 | **/node_modules 15 | **/dist 16 | **/__tests__ 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig works out of the box with many editors, plugins are available 2 | # for many others - see the website: 3 | # https://EditorConfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | charset = utf-8 11 | 12 | [*.{js,jsx,ts,tsx,graphql,sql,md,html,mjml,json,jsonc,json5,yml,yaml,template,sh,Dockerfile}] 13 | indent_style = space 14 | indent_size = 2 15 | trim_trailing_whitespace = true 16 | 17 | [*.{md,html,mjml}] 18 | trim_trailing_whitespace = false 19 | 20 | [Dockerfile] 21 | indent_style = space 22 | indent_size = 2 23 | -------------------------------------------------------------------------------- /.env.ci: -------------------------------------------------------------------------------- 1 | NODE_ENV=test 2 | ROOT_DATABASE_URL=postgres://postgres:postgres@localhost/template1 3 | DATABASE_HOST=localhost 4 | DATABASE_NAME=graphile_starter 5 | DATABASE_OWNER=graphile_starter 6 | DATABASE_OWNER_PASSWORD=cisecret1 7 | DATABASE_AUTHENTICATOR=graphile_starter_authenticator 8 | DATABASE_AUTHENTICATOR_PASSWORD=cisecret2 9 | DATABASE_VISITOR=graphile_starter_visitor 10 | SECRET=cisecret3 11 | JWT_SECRET=cisecret4 12 | PORT=5678 13 | ROOT_URL=http://localhost:5678 14 | ENABLE_CYPRESS_COMMANDS=1 15 | DOCKER_MODE=n 16 | 17 | # Don't use turbo; we need it to work with Node 10. 18 | GRAPHILE_TURBO= 19 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | **/dist 3 | /data/schema.graphql 4 | /data/schema.sql 5 | /@app/graphql/index.* 6 | /@app/client/.next 7 | .next 8 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.js text=auto eol=lf 2 | *.ts text=auto eol=lf 3 | *.json text=auto eol=lf 4 | *.md text=auto eol=lf 5 | *.sh text=auto eol=lf 6 | *.yml text=auto eol=lf 7 | *.sql text=auto eol=lf 8 | -------------------------------------------------------------------------------- /.github/workflows/cypress.yml: -------------------------------------------------------------------------------- 1 | name: End-to-end tests 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | cypress-run: 7 | runs-on: ubuntu-latest 8 | 9 | env: 10 | CYPRESS_ROOT_URL: http://localhost:5678 11 | CI: true 12 | CONFIRM_DROP: 1 13 | NODE_ENV: test 14 | ENABLE_CYPRESS_COMMANDS: 1 15 | 16 | strategy: 17 | matrix: 18 | node-version: [16.x] 19 | 20 | services: 21 | postgres: 22 | image: postgres:14 23 | env: 24 | POSTGRES_USER: postgres 25 | POSTGRES_PASSWORD: postgres 26 | POSTGRES_DB: postgres 27 | ports: 28 | - "0.0.0.0:5432:5432" 29 | # needed because the postgres container does not provide a healthcheck 30 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 31 | 32 | steps: 33 | - name: Checkout 34 | uses: actions/checkout@v3 35 | - name: Use Node.js ${{ matrix.node-version }} 36 | uses: actions/setup-node@v3 37 | with: 38 | node-version: ${{ matrix.node-version }} 39 | - name: Install pg_dump 40 | run: | 41 | sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' 42 | wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add - 43 | sudo apt-get update 44 | sudo apt-get -yqq install postgresql-client-14 45 | - name: Setup 46 | run: | 47 | cp .env.ci .env 48 | yarn --immutable 49 | yarn setup 50 | yarn build 51 | - name: Start server in background 52 | run: yarn server start & 53 | - name: Start worker in background 54 | run: yarn worker start & 55 | - name: Cypress run 56 | uses: cypress-io/github-action@v5 57 | with: 58 | wait-on: http://localhost:5678 59 | working-directory: "@app/e2e" 60 | - uses: actions/upload-artifact@v4 61 | if: failure() 62 | with: 63 | name: cypress-screenshots 64 | path: "@app/e2e/cypress/screenshots" 65 | - uses: actions/upload-artifact@v4 66 | if: failure() 67 | with: 68 | name: cypress-videos 69 | path: "@app/e2e/cypress/videos" 70 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | env: 10 | CI: true 11 | POSTGRES_USER: postgres 12 | POSTGRES_PASSWORD: postgres 13 | 14 | strategy: 15 | matrix: 16 | node-version: [16.x] 17 | 18 | services: 19 | postgres: 20 | image: postgres:14 21 | env: 22 | POSTGRES_USER: postgres 23 | POSTGRES_PASSWORD: postgres 24 | POSTGRES_DB: postgres 25 | ports: 26 | - "0.0.0.0:5432:5432" 27 | # needed because the postgres container does not provide a healthcheck 28 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 29 | 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v2 33 | - name: Use Node.js ${{ matrix.node-version }} 34 | uses: actions/setup-node@v1 35 | with: 36 | node-version: ${{ matrix.node-version }} 37 | - name: Install pg_dump 38 | run: | 39 | sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' 40 | wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add - 41 | sudo apt-get update 42 | sudo apt-get -yqq install postgresql-client-14 43 | - name: yarn, lint, build and test 44 | run: | 45 | cp .env.ci .env 46 | yarn --immutable 47 | CONFIRM_DROP=1 yarn setup 48 | yarn build 49 | yarn lint 50 | yarn test --ci 51 | yarn depcheck 52 | -------------------------------------------------------------------------------- /.github/workflows/pgrita.yml: -------------------------------------------------------------------------------- 1 | name: pgRITA 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | node-version: [16.x] 12 | 13 | services: 14 | postgres: 15 | image: postgres:14 16 | env: 17 | POSTGRES_USER: postgres 18 | POSTGRES_PASSWORD: postgres 19 | POSTGRES_DB: postgres 20 | ports: 21 | - "0.0.0.0:5432:5432" 22 | # needed because the postgres container does not provide a healthcheck 23 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v2 28 | - name: Use Node.js ${{ matrix.node-version }} 29 | uses: actions/setup-node@v1 30 | with: 31 | node-version: ${{ matrix.node-version }} 32 | - name: Setup DB 33 | run: | 34 | cp .env.ci .env 35 | yarn --immutable 36 | CONFIRM_DROP=1 yarn setup 37 | - name: "Run pgRITA checks" 38 | uses: pgrita/action@main 39 | env: 40 | DATABASE_URL: postgres://postgres:postgres@localhost/graphile_starter 41 | PGRITA_TOKEN: ${{ secrets.PGRITA_TOKEN }} 42 | with: 43 | project: graphile/graphile-starter 44 | pass-on-no-token: true 45 | -------------------------------------------------------------------------------- /.github/workflows/production-docker.yml: -------------------------------------------------------------------------------- 1 | name: production.Dockerfile CI 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | node-version: [16.x] 12 | 13 | services: 14 | postgres: 15 | image: postgres:14 16 | env: 17 | POSTGRES_USER: postgres 18 | POSTGRES_PASSWORD: postgres 19 | POSTGRES_DB: postgres 20 | ports: 21 | - "0.0.0.0:5432:5432" 22 | # needed because the postgres container does not provide a healthcheck 23 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v2 28 | # TODO: we should be able to get rid of Node here 29 | - name: Use Node.js ${{ matrix.node-version }} 30 | uses: actions/setup-node@v1 31 | with: 32 | node-version: ${{ matrix.node-version }} 33 | - name: Install pg_dump 34 | run: | 35 | sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' 36 | wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add - 37 | sudo apt-get update 38 | sudo apt-get -yqq install postgresql-client-14 39 | - name: setup database 40 | run: | 41 | cp .env.ci .env 42 | yarn --immutable 43 | CONFIRM_DROP=1 yarn setup 44 | env: 45 | CI: true 46 | - name: "Run docker server build" 47 | run: 48 | docker build --file production.Dockerfile --build-arg ROOT_URL="http://localhost:5678" --build-arg 49 | TARGET="server" --tag gs-server . 50 | - name: "Run docker worker build" 51 | run: 52 | docker build --file production.Dockerfile --build-arg ROOT_URL="http://localhost:5678" --build-arg 53 | TARGET="worker" --tag gs-worker . 54 | - name: "ifconfig -a" 55 | run: "ifconfig -a" 56 | - name: "Start docker server" 57 | run: 58 | docker run --rm -d --init -p 5678:5678 --env-file .env -e NODE_ENV=production -e DATABASE_HOST=172.17.0.1 59 | --name gs-server gs-server 60 | - name: "Start docker worker" 61 | run: 62 | docker run --rm -d --init --env-file .env -e NODE_ENV=production -e DATABASE_HOST=172.17.0.1 --name gs-worker 63 | gs-worker 64 | - name: "Test docker" 65 | run: node .github/workflows/test-docker.js 66 | - name: "Tear down docker" 67 | run: docker kill gs-server gs-worker 68 | -------------------------------------------------------------------------------- /.github/workflows/test-docker.js: -------------------------------------------------------------------------------- 1 | const AbortController = require("abort-controller"); 2 | const { execSync } = require("child_process"); 3 | 4 | const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 5 | 6 | async function main() { 7 | let attempts = 0; 8 | let response; 9 | while (true) { 10 | try { 11 | const controller = new AbortController(); 12 | const timeout = setTimeout(() => { 13 | controller.abort(); 14 | }, 3000); 15 | try { 16 | const { default: fetch } = await import("node-fetch"); 17 | response = await fetch("http://localhost:5678", { 18 | signal: controller.signal, 19 | }); 20 | } finally { 21 | clearTimeout(timeout); 22 | } 23 | if (!response.ok) { 24 | throw new Error("Try again"); 25 | } 26 | break; 27 | } catch (e) { 28 | attempts++; 29 | if (attempts <= 30) { 30 | console.log(`Server is not ready yet: ${e.message}`); 31 | execSync("docker logs gs-server", { stdio: "inherit" }); 32 | } else { 33 | console.log(`Server never came up, aborting :(`); 34 | process.exit(1); 35 | } 36 | await sleep(1000); 37 | } 38 | } 39 | const text = await response.text(); 40 | 41 | // Check for known text on homepage 42 | if (!text.includes("https://graphile.org/postgraphile")) { 43 | throw new Error("Failed to confirm server works."); 44 | } 45 | 46 | // TODO: make this test depend on the worker running 47 | 48 | console.log("Docker tests passed."); 49 | } 50 | 51 | main().catch((e) => { 52 | console.error(e); 53 | process.exit(1); 54 | }); 55 | -------------------------------------------------------------------------------- /.github/workflows/windows-nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Windows Node CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: windows-latest 8 | 9 | env: 10 | CI: true 11 | CONFIRM_DROP: 1 12 | POSTGRES_USER: postgres 13 | POSTGRES_PASSWORD: postgres 14 | 15 | steps: 16 | # Force LF line endings - necessary to have prettier not complain 17 | - name: Prepare git 18 | run: | 19 | git config --global core.autocrlf false 20 | git config --global core.eol lf 21 | - name: Checkout 22 | uses: actions/checkout@v2 23 | - name: Use Node.js 16 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: "16" 27 | - name: Start Postgres 14 28 | run: | 29 | sc config postgresql-x64-14 start=auto 30 | net start postgresql-x64-14 31 | - name: Setup environment 32 | # Windows postgres auth is 'postgres'/'root' - see 33 | # https://github.com/actions/runner-images/blob/main/images/win/Windows2022-Readme.md#postgresql 34 | run: | 35 | cp .env.ci .env 36 | echo "ROOT_DATABASE_URL=postgres://postgres:root@localhost/template1" >> .env 37 | 38 | # These all need to be separate steps on Windows because a failure of a 39 | # command doesn't fail the whole job step - see 40 | # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsshell 41 | 42 | - name: yarn 43 | run: yarn --immutable 44 | - name: setup 45 | run: yarn setup 46 | - name: build 47 | run: yarn build 48 | - name: lint 49 | run: yarn lint 50 | - name: test 51 | run: yarn test --ci 52 | - name: depcheck 53 | run: yarn depcheck 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .next 3 | node_modules 4 | dist 5 | /heroku-setup 6 | /yarn-error.log 7 | /@app/e2e/cypress/videos 8 | /@app/e2e/cypress/screenshots 9 | /@app/graphql/index.* 10 | .agignore 11 | *.tsbuildinfo 12 | .ethereal 13 | yarn-error.log 14 | 15 | .pnp.* 16 | .yarn/* 17 | !.yarn/patches 18 | !.yarn/plugins 19 | !.yarn/releases 20 | !.yarn/sdks 21 | !.yarn/versions 22 | -------------------------------------------------------------------------------- /.vscode/README.md: -------------------------------------------------------------------------------- 1 | # Visual Studio Code 2 | 3 | VSCode users, are you in for a treat?! 4 | 5 | This project comes pre-configured with debug profiles for the server, client, 6 | worker and tests in `launch.json`. We also preconfigured VSCode with some 7 | settings that work beautifully for this project, and even recommend some 8 | extensions you may want to use! 9 | 10 | To view the 11 | [workspace recommended extensions](https://code.visualstudio.com/docs/editor/extension-gallery#_workspace-recommended-extensions), 12 | select the `Extensions: Show Recommended Extensions` command from your command 13 | palette. 14 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "EditorConfig.EditorConfig", // http://editorconfig.org 6 | "dbaeumer.vscode-eslint", // ESLint 7 | "esbenp.prettier-vscode", // Prettier 8 | "apollographql.vscode-apollo", // GraphQL support 9 | "msjsdiag.debugger-for-chrome", // Chrome debugger integration 10 | "ms-vscode-remote.vscode-remote-extensionpack" // OPTIONAL: develop inside of Docker containers 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "attach", 7 | "name": "Attach to Node Server", 8 | "port": 9678, 9 | "restart": true, 10 | "outFiles": ["${workspaceFolder}/@app/server/dist/**/*.js"] 11 | }, 12 | { 13 | "type": "node", 14 | "request": "attach", 15 | "name": "Attach to Jest tests", 16 | "port": 9876, 17 | "restart": true 18 | }, 19 | { 20 | "type": "node", 21 | "request": "attach", 22 | "name": "Attach to Worker", 23 | "port": 9757, 24 | "restart": true, 25 | "outFiles": ["${workspaceFolder}/@app/worker/dist/**/*.js"] 26 | }, 27 | { 28 | "type": "chrome", 29 | "request": "launch", 30 | "name": "Launch localhost:5678", 31 | "url": "http://localhost:5678/", 32 | "webRoot": "${workspaceFolder}/@app/client/src", 33 | "pathMapping": { 34 | "/_next": "${workspaceFolder}/@app/client/.next" 35 | } 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2, 3 | "editor.insertSpaces": true, 4 | "editor.formatOnSave": true, 5 | "editor.defaultFormatter": "esbenp.prettier-vscode", 6 | "eslint.enable": true, 7 | "eslint.lintTask.enable": true, 8 | "eslint.lintTask.options": "--ext .js,.jsx,.ts,.tsx,.graphql .", 9 | "eslint.validate": [ 10 | "javascript", 11 | "javascriptreact", 12 | "typescript", 13 | "typescriptreact", 14 | "graphql" 15 | ], 16 | "editor.codeActionsOnSave": { 17 | "source.fixAll": true 18 | }, 19 | 20 | "files.insertFinalNewline": true, 21 | "files.trimTrailingWhitespace": true, 22 | 23 | "prettier.disableLanguages": [], 24 | 25 | "files.associations": { 26 | ".gmrc": "jsonc" 27 | }, 28 | 29 | "[graphql]": { 30 | "editor.codeActionsOnSave": { 31 | "source.fixAll.eslint": false 32 | } 33 | }, 34 | "[markdown]": { 35 | "files.trimTrailingWhitespace": false 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.vscode/spellright.dict: -------------------------------------------------------------------------------- 1 | PostGraphile 2 | Graphile 3 | Heroku 4 | Docker 5 | Redis 6 | Nodemailer 7 | graphile-migrate 8 | graphile-worker 9 | graphile 10 | stdout 11 | envvars 12 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | plugins: 4 | - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs 5 | spec: "@yarnpkg/plugin-interactive-tools" 6 | - path: .yarn/plugins/@yarnpkg/plugin-typescript.cjs 7 | spec: "@yarnpkg/plugin-typescript" 8 | - path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs 9 | spec: "@yarnpkg/plugin-workspace-tools" 10 | 11 | yarnPath: .yarn/releases/yarn-3.4.1.cjs 12 | -------------------------------------------------------------------------------- /@app/README.md: -------------------------------------------------------------------------------- 1 | # @app 2 | 3 | This folder contains the various components (aka "packages") of our project. We 4 | use [yarn workspaces](https://yarnpkg.com/lang/en/docs/workspaces/) to manage 5 | this monorepo, to help us to keep things separate without slowing development 6 | speed. All components of this project are named `@app/*` so that we can 7 | reference them from each other in a straightforward manner, e.g. 8 | 9 | ```ts 10 | import { useAppQuery } from "@app/graphql"; 11 | ``` 12 | 13 | ## Packages 14 | 15 | - [@app/config](./config/README.md) - shared configuration for the entire stack, 16 | powered by [dotenv](https://github.com/motdotla/dotenv) 17 | - [@app/client](./client/README.md) - the React frontend, powered by 18 | [Next.js](https://nextjs.org/) 19 | - [@app/graphql](./graphql/README.md) - the autogenerated GraphQL types and 20 | Apollo React hooks, powered by 21 | [graphql-code-generator](https://github.com/dotansimha/graphql-code-generator) 22 | - [@app/server](./server/README.md) - the Node.js backend and tests, powered by 23 | [Express](https://expressjs.com/), [Passport](http://www.passportjs.org/) and 24 | [PostGraphile](https://www.graphile.org/postgraphile/) (provides auth, 25 | GraphQL, SSR, etc) 26 | - [@app/worker](./worker/README.md) - job queue (e.g. for sending emails), 27 | powered by [graphile-worker](https://github.com/graphile/worker) 28 | - [@app/db](./db/README.md) - database migrations and tests, powered by 29 | [graphile-migrate](https://github.com/graphile/migrate) 30 | - [@app/e2e](./e2e/README.md) - end-to-end tests for the entire stack, powered 31 | by [Cypress](https://www.cypress.io/) 32 | - [@app/\_\_tests\_\_](./__tests__/README.md) - some test helpers 33 | -------------------------------------------------------------------------------- /@app/__tests__/README.md: -------------------------------------------------------------------------------- 1 | # @app/\_\_tests\_\_ 2 | 3 | This folder contains helpers that are used by tests in multiple packages. Each 4 | package should have it's own helpers file, which may opt to import this helpers 5 | file should it need to. 6 | -------------------------------------------------------------------------------- /@app/client/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["next/babel"], 3 | "plugins": [ 4 | ["import", { "libraryName": "antd" }, "antd"], 5 | [ 6 | "import", 7 | { 8 | "libraryName": "lodash", 9 | "libraryDirectory": "", 10 | "camel2DashComponentName": false 11 | }, 12 | "lodash" 13 | ] 14 | ], 15 | "env": { 16 | "development": { 17 | "presets": [ 18 | [ 19 | "next/babel", 20 | { 21 | "preset-env": { 22 | "targets": { 23 | "node": "current", 24 | "browsers": "last 2 chrome versions" 25 | } 26 | } 27 | } 28 | ] 29 | ] 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /@app/client/README.md: -------------------------------------------------------------------------------- 1 | # @app/client 2 | 3 | This is the React frontend of our application, built with 4 | [Next.js](https://nextjs.org/). Next.js uses the 5 | [file-system as the main API](https://nextjs.org/docs#manual-setup) ─ files 6 | inside the [src/pages](./src/pages) folder automatically turn into routes of the 7 | same name, for example the route `/login` is provided by 8 | [src/pages/login.tsx](src/pages/login.tsx). 9 | 10 | ## GraphQL files 11 | 12 | We've separated the GraphQL queries, mutations, subscriptions and fragments into 13 | `.graphql` files inside the `src/graphql` folder. These are scanned and code 14 | generated by [@app/graphql](../graphql/README.md), so that we can then import 15 | them by name, for example: 16 | 17 | ```ts 18 | import { useAddEmailMutation } from "@app/graphql"; 19 | ``` 20 | 21 | `graphql-code-generator`, used in `@app/graphql`, automatically builds hooks 22 | like `use*Query`, `use*Mutation` and `use*Query` so we do not include `Query`, 23 | `Mutation` or `Subscription` in our operation names. 24 | 25 | ### GraphQL naming conventions 26 | 27 | 1. Operations are named in PascalCase 28 | 1. Name the file after the operation or fragment (e.g. 29 | `fragment EmailsForm_User {...}` would be in a file called 30 | `EmailsForm_User.graphql`) 31 | 1. Do not add `Query`, `Mutation` or `Subscription` suffixes to operations 32 | 1. Do not add `Fragment` suffix to fragments 33 | 1. Operations (i.e. non-fragments) should never contain an underscore in their 34 | name - underscores are reserved for fragments. 35 | 1. Fragments should always contain exactly one underscore, see fragment naming 36 | below 37 | 38 | ### GraphQL fragment naming 39 | 40 | Fragments belong to components (or functions) to enable GraphQL composition. 41 | This is one of the most powerful and most important features about doing GraphQL 42 | right - it helps to ensure that you only ask the server for data you actually 43 | need (and allows composing these data requirements between multiple components 44 | for greater efficiency). 45 | 46 | Fragments are named according to the following pattern: 47 | 48 | ``` 49 | [ComponentName]_[Distinguisher?][Type] 50 | ``` 51 | 52 | 1. `ComponentName` - the name of the React component (or possibly function) that 53 | owns this fragment. 54 | 2. `_` - an underscore 55 | 3. `Distinguisher?` - an optional piece of text if this component includes 56 | multiple fragments that are valid on the same `Type` 57 | 4. `Type` - the GraphQL type name upon which this fragment is valid. 58 | 59 | For example: 60 | 61 | ```graphql 62 | fragment EmailsForm_User on User { 63 | ... 64 | } 65 | ``` 66 | -------------------------------------------------------------------------------- /@app/client/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require("../../babel.config"); 2 | -------------------------------------------------------------------------------- /@app/client/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require("../../jest.config.base")(__dirname); 2 | -------------------------------------------------------------------------------- /@app/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@app/client", 3 | "private": true, 4 | "version": "0.0.0", 5 | "scripts": { 6 | "build": "cd src && cross-env NODE_ENV=production NODE_OPTIONS=\"${NODE_OPTIONS:-} -r @app/config/env\" next build", 7 | "test": "cross-env NODE_ENV=test NODE_OPTIONS=\"${NODE_OPTIONS:-} -r @app/config/env\" jest" 8 | }, 9 | "dependencies": { 10 | "@ant-design/icons": "^5.0.1", 11 | "@ant-design/pro-layout": "^7.8.3", 12 | "@apollo/client": "3.4.17", 13 | "@app/components": "0.0.0", 14 | "@app/config": "0.0.0", 15 | "@app/graphql": "0.0.0", 16 | "@app/lib": "0.0.0", 17 | "@types/lodash": "^4.14.191", 18 | "@types/node": "^18.14.2", 19 | "@types/nprogress": "^0.2.0", 20 | "@types/react": "18.0.28", 21 | "antd": "5.2.3", 22 | "dayjs": "^1.11.7", 23 | "graphql": "^15.8.0", 24 | "lodash": "^4.17.21", 25 | "net": "^1.0.2", 26 | "next": "^13.2.3", 27 | "nprogress": "^0.2.0", 28 | "rc-field-form": "~1.27.4", 29 | "react": "^18.2.0", 30 | "react-dom": "^18.2.0", 31 | "slugify": "^1.6.5", 32 | "tls": "^0.0.1", 33 | "webpack": "^5.94.0" 34 | }, 35 | "devDependencies": { 36 | "cross-env": "^7.0.3", 37 | "jest": "^29.4.3" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /@app/client/src/graphql/AcceptOrganizationInvite.graphql: -------------------------------------------------------------------------------- 1 | mutation AcceptOrganizationInvite($id: UUID!, $code: String) { 2 | acceptInvitationToOrganization(input: { invitationId: $id, code: $code }) { 3 | clientMutationId 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /@app/client/src/graphql/AddEmail.graphql: -------------------------------------------------------------------------------- 1 | #import "./EmailsForm_UserEmail.graphql" 2 | 3 | mutation AddEmail($email: String!) { 4 | createUserEmail(input: { userEmail: { email: $email } }) { 5 | user { 6 | id 7 | userEmails(first: 50) { 8 | nodes { 9 | id 10 | ...EmailsForm_UserEmail 11 | } 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /@app/client/src/graphql/ChangePassword.graphql: -------------------------------------------------------------------------------- 1 | mutation ChangePassword($oldPassword: String!, $newPassword: String!) { 2 | changePassword( 3 | input: { oldPassword: $oldPassword, newPassword: $newPassword } 4 | ) { 5 | success 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /@app/client/src/graphql/ConfirmAccountDeletion.graphql: -------------------------------------------------------------------------------- 1 | mutation ConfirmAccountDeletion($token: String!) { 2 | confirmAccountDeletion(input: { token: $token }) { 3 | success 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /@app/client/src/graphql/CreateOrganization.graphql: -------------------------------------------------------------------------------- 1 | fragment CreatedOrganization on Organization { 2 | id 3 | name 4 | slug 5 | } 6 | 7 | mutation CreateOrganization($name: String!, $slug: String!) { 8 | createOrganization(input: { name: $name, slug: $slug }) { 9 | organization { 10 | id 11 | ...CreatedOrganization 12 | } 13 | query { 14 | organizationBySlug(slug: $slug) { 15 | id 16 | ...CreatedOrganization 17 | } 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /@app/client/src/graphql/CurrentUserAuthentications.graphql: -------------------------------------------------------------------------------- 1 | query CurrentUserAuthentications { 2 | currentUser { 3 | id 4 | authentications: userAuthenticationsList(first: 50) { 5 | id 6 | service 7 | identifier 8 | createdAt 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /@app/client/src/graphql/CurrentUserUpdated.graphql: -------------------------------------------------------------------------------- 1 | subscription CurrentUserUpdated { 2 | currentUserUpdated { 3 | event 4 | user { 5 | id 6 | username 7 | name 8 | avatarUrl 9 | isAdmin 10 | isVerified 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /@app/client/src/graphql/DeleteEmail.graphql: -------------------------------------------------------------------------------- 1 | #import "./EmailsForm_UserEmail.graphql" 2 | 3 | mutation DeleteEmail($emailId: UUID!) { 4 | deleteUserEmail(input: { id: $emailId }) { 5 | user { 6 | id 7 | userEmails(first: 50) { 8 | nodes { 9 | id 10 | ...EmailsForm_UserEmail 11 | } 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /@app/client/src/graphql/DeleteOrganization.graphql: -------------------------------------------------------------------------------- 1 | mutation DeleteOrganization($organizationId: UUID!) { 2 | deleteOrganization(input: { organizationId: $organizationId }) { 3 | clientMutationId 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /@app/client/src/graphql/EmailsForm_User.graphql: -------------------------------------------------------------------------------- 1 | #import "./EmailsForm_UserEmail.graphql" 2 | 3 | fragment EmailsForm_User on User { 4 | id 5 | userEmails(first: 50) { 6 | nodes { 7 | ...EmailsForm_UserEmail 8 | id 9 | email 10 | isVerified 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /@app/client/src/graphql/EmailsForm_UserEmail.graphql: -------------------------------------------------------------------------------- 1 | fragment EmailsForm_UserEmail on UserEmail { 2 | id 3 | email 4 | isVerified 5 | isPrimary 6 | createdAt 7 | } 8 | -------------------------------------------------------------------------------- /@app/client/src/graphql/ForgotPassword.graphql: -------------------------------------------------------------------------------- 1 | mutation ForgotPassword($email: String!) { 2 | forgotPassword(input: { email: $email }) { 3 | # This mutation does not return any meaningful result, 4 | # but we still need to request _something_... 5 | clientMutationId 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /@app/client/src/graphql/InvitationDetail.graphql: -------------------------------------------------------------------------------- 1 | query InvitationDetail($id: UUID!, $code: String) { 2 | ...SharedLayout_Query 3 | organizationForInvitation(invitationId: $id, code: $code) { 4 | id 5 | name 6 | slug 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /@app/client/src/graphql/InviteToOrganization.graphql: -------------------------------------------------------------------------------- 1 | mutation InviteToOrganization( 2 | $organizationId: UUID! 3 | $email: String 4 | $username: String 5 | ) { 6 | inviteToOrganization( 7 | input: { 8 | organizationId: $organizationId 9 | email: $email 10 | username: $username 11 | } 12 | ) { 13 | clientMutationId 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /@app/client/src/graphql/Login.graphql: -------------------------------------------------------------------------------- 1 | mutation Login($username: String!, $password: String!) { 2 | login(input: { username: $username, password: $password }) { 3 | user { 4 | id 5 | username 6 | name 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /@app/client/src/graphql/Logout.graphql: -------------------------------------------------------------------------------- 1 | mutation Logout { 2 | logout { 3 | success 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /@app/client/src/graphql/MakeEmailPrimary.graphql: -------------------------------------------------------------------------------- 1 | mutation MakeEmailPrimary($emailId: UUID!) { 2 | makeEmailPrimary(input: { emailId: $emailId }) { 3 | user { 4 | id 5 | userEmails(first: 50) { 6 | nodes { 7 | id 8 | isPrimary 9 | } 10 | } 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /@app/client/src/graphql/OrganizationBySlug.graphql: -------------------------------------------------------------------------------- 1 | query OrganizationBySlug($slug: String!) { 2 | organizationBySlug(slug: $slug) { 3 | id 4 | name 5 | slug 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /@app/client/src/graphql/OrganizationMembers.graphql: -------------------------------------------------------------------------------- 1 | fragment OrganizationMembers_Membership on OrganizationMembership { 2 | id 3 | createdAt 4 | isOwner 5 | isBillingContact 6 | user { 7 | id 8 | username 9 | name 10 | } 11 | } 12 | 13 | fragment OrganizationMembers_Organization on Organization { 14 | id 15 | ...OrganizationPage_Organization 16 | name 17 | slug 18 | organizationMemberships( 19 | first: 10 20 | offset: $offset 21 | orderBy: [MEMBER_NAME_ASC] 22 | ) { 23 | nodes { 24 | id 25 | ...OrganizationMembers_Membership 26 | } 27 | totalCount 28 | } 29 | } 30 | 31 | query OrganizationMembers($slug: String!, $offset: Int = 0) { 32 | ...OrganizationPage_Query 33 | organizationBySlug(slug: $slug) { 34 | id 35 | ...OrganizationMembers_Organization 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /@app/client/src/graphql/OrganizationPage.graphql: -------------------------------------------------------------------------------- 1 | query OrganizationPage($slug: String!) { 2 | ...OrganizationPage_Query 3 | } 4 | -------------------------------------------------------------------------------- /@app/client/src/graphql/OrganizationPage_Query.graphql: -------------------------------------------------------------------------------- 1 | fragment OrganizationPage_Organization on Organization { 2 | id 3 | name 4 | slug 5 | currentUserIsOwner 6 | currentUserIsBillingContact 7 | } 8 | 9 | fragment OrganizationPage_Query on Query { 10 | ...SharedLayout_Query 11 | organizationBySlug(slug: $slug) { 12 | id 13 | ...OrganizationPage_Organization 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /@app/client/src/graphql/ProfileSettingsForm_User.graphql: -------------------------------------------------------------------------------- 1 | fragment ProfileSettingsForm_User on User { 2 | id 3 | name 4 | username 5 | avatarUrl 6 | } 7 | -------------------------------------------------------------------------------- /@app/client/src/graphql/Register.graphql: -------------------------------------------------------------------------------- 1 | mutation Register( 2 | $username: String! 3 | $password: String! 4 | $email: String! 5 | $name: String 6 | ) { 7 | register( 8 | input: { 9 | username: $username 10 | password: $password 11 | email: $email 12 | name: $name 13 | } 14 | ) { 15 | user { 16 | id 17 | username 18 | name 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /@app/client/src/graphql/RemoveFromOrganization.graphql: -------------------------------------------------------------------------------- 1 | mutation RemoveFromOrganization($organizationId: UUID!, $userId: UUID!) { 2 | removeFromOrganization( 3 | input: { organizationId: $organizationId, userId: $userId } 4 | ) { 5 | clientMutationId 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /@app/client/src/graphql/RequestAccountDeletion.graphql: -------------------------------------------------------------------------------- 1 | mutation RequestAccountDeletion { 2 | requestAccountDeletion(input: {}) { 3 | success 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /@app/client/src/graphql/ResendEmailVerification.graphql: -------------------------------------------------------------------------------- 1 | mutation ResendEmailVerification($emailId: UUID!) { 2 | resendEmailVerificationCode(input: { emailId: $emailId }) { 3 | success 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /@app/client/src/graphql/ResetPassword.graphql: -------------------------------------------------------------------------------- 1 | mutation ResetPassword($userId: UUID!, $token: String!, $password: String!) { 2 | resetPassword( 3 | input: { userId: $userId, resetToken: $token, newPassword: $password } 4 | ) { 5 | success 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /@app/client/src/graphql/SettingsEmails.graphql: -------------------------------------------------------------------------------- 1 | #import "./EmailsForm_User.graphql" 2 | 3 | query SettingsEmails { 4 | ...SharedLayout_Query 5 | currentUser { 6 | id 7 | isVerified 8 | ...EmailsForm_User 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /@app/client/src/graphql/SettingsPassword.graphql: -------------------------------------------------------------------------------- 1 | query SettingsPassword { 2 | currentUser { 3 | id 4 | hasPassword 5 | userEmails(first: 1, condition: { isPrimary: true }) { 6 | nodes { 7 | id 8 | email 9 | } 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /@app/client/src/graphql/SettingsProfile.graphql: -------------------------------------------------------------------------------- 1 | #import "./ProfileSettingsForm_User.graphql" 2 | 3 | query SettingsProfile { 4 | ...SharedLayout_Query 5 | currentUser { 6 | id 7 | ...ProfileSettingsForm_User 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /@app/client/src/graphql/Shared.graphql: -------------------------------------------------------------------------------- 1 | query Shared { 2 | ...SharedLayout_Query 3 | } 4 | -------------------------------------------------------------------------------- /@app/client/src/graphql/SharedLayout_Query.graphql: -------------------------------------------------------------------------------- 1 | fragment SharedLayout_Query on Query { 2 | currentUser { 3 | id 4 | ...SharedLayout_User 5 | } 6 | } 7 | 8 | fragment SharedLayout_User on User { 9 | id 10 | name 11 | username 12 | avatarUrl 13 | isAdmin 14 | isVerified 15 | organizationMemberships(first: 20) { 16 | nodes { 17 | id 18 | isOwner 19 | isBillingContact 20 | organization { 21 | id 22 | name 23 | slug 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /@app/client/src/graphql/TransferOrganizationBillingContact.graphql: -------------------------------------------------------------------------------- 1 | mutation TransferOrganizationBillingContact( 2 | $organizationId: UUID! 3 | $userId: UUID! 4 | ) { 5 | transferOrganizationBillingContact( 6 | input: { organizationId: $organizationId, userId: $userId } 7 | ) { 8 | organization { 9 | id 10 | currentUserIsBillingContact 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /@app/client/src/graphql/TransferOrganizationOwnership.graphql: -------------------------------------------------------------------------------- 1 | mutation TransferOrganizationOwnership($organizationId: UUID!, $userId: UUID!) { 2 | transferOrganizationOwnership( 3 | input: { organizationId: $organizationId, userId: $userId } 4 | ) { 5 | organization { 6 | id 7 | currentUserIsOwner 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /@app/client/src/graphql/UnlinkUserAuthentication.graphql: -------------------------------------------------------------------------------- 1 | mutation UnlinkUserAuthentication($id: UUID!) { 2 | deleteUserAuthentication(input: { id: $id }) { 3 | user { 4 | id 5 | userAuthenticationsList(first: 50) { 6 | id 7 | identifier 8 | service 9 | createdAt 10 | } 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /@app/client/src/graphql/UpdateOrganization.graphql: -------------------------------------------------------------------------------- 1 | mutation UpdateOrganization($input: UpdateOrganizationInput!) { 2 | updateOrganization(input: $input) { 3 | organization { 4 | id 5 | slug 6 | name 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /@app/client/src/graphql/UpdateUser.graphql: -------------------------------------------------------------------------------- 1 | mutation UpdateUser($id: UUID!, $patch: UserPatch!) { 2 | updateUser(input: { id: $id, patch: $patch }) { 3 | clientMutationId 4 | user { 5 | id 6 | name 7 | username 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /@app/client/src/graphql/VerifyEmail.graphql: -------------------------------------------------------------------------------- 1 | mutation VerifyEmail($id: UUID!, $token: String!) { 2 | verifyEmail(input: { userEmailId: $id, token: $token }) { 3 | success 4 | query { 5 | currentUser { 6 | id 7 | isVerified 8 | } 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /@app/client/src/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /@app/client/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import "antd/dist/reset.css"; 2 | import "nprogress/nprogress.css"; 3 | import "../styles.css"; 4 | 5 | import { ApolloClient, ApolloProvider } from "@apollo/client"; 6 | import { withApollo } from "@app/lib"; 7 | import { ConfigProvider, notification } from "antd"; 8 | import App from "next/app"; 9 | import Router from "next/router"; 10 | import NProgress from "nprogress"; 11 | import * as React from "react"; 12 | 13 | declare global { 14 | interface Window { 15 | __GRAPHILE_APP__: { 16 | ROOT_URL?: string; 17 | T_AND_C_URL?: string; 18 | }; 19 | } 20 | } 21 | 22 | NProgress.configure({ 23 | showSpinner: false, 24 | }); 25 | 26 | if (typeof window !== "undefined") { 27 | const nextDataEl = document.getElementById("__NEXT_DATA__"); 28 | if (!nextDataEl || !nextDataEl.textContent) { 29 | throw new Error("Cannot read from __NEXT_DATA__ element"); 30 | } 31 | const data = JSON.parse(nextDataEl.textContent); 32 | window.__GRAPHILE_APP__ = { 33 | ROOT_URL: data.query.ROOT_URL, 34 | T_AND_C_URL: data.query.T_AND_C_URL, 35 | }; 36 | 37 | Router.events.on("routeChangeStart", () => { 38 | NProgress.start(); 39 | }); 40 | 41 | Router.events.on("routeChangeComplete", () => { 42 | NProgress.done(); 43 | }); 44 | Router.events.on("routeChangeError", (err: Error | string) => { 45 | NProgress.done(); 46 | if ((err as any)["cancelled"]) { 47 | // No worries; you deliberately cancelled it 48 | } else { 49 | notification.open({ 50 | message: "Page load failed", 51 | description: `This is very embarrassing! Please reload the page. Further error details: ${ 52 | typeof err === "string" ? err : err.message 53 | }`, 54 | duration: 0, 55 | }); 56 | } 57 | }); 58 | } 59 | 60 | class MyApp extends App<{ apollo: ApolloClient }> { 61 | static async getInitialProps({ Component, ctx }: any) { 62 | let pageProps = {}; 63 | 64 | if (Component.getInitialProps) { 65 | pageProps = await Component.getInitialProps(ctx); 66 | } 67 | 68 | return { pageProps }; 69 | } 70 | 71 | render() { 72 | const { Component, pageProps, apollo } = this.props; 73 | 74 | return ( 75 | 91 | 92 | 93 | 94 | 95 | ); 96 | } 97 | } 98 | 99 | export default withApollo(MyApp); 100 | -------------------------------------------------------------------------------- /@app/client/src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { 2 | DocumentContext, 3 | Head, 4 | Html, 5 | Main, 6 | NextScript, 7 | } from "next/document"; 8 | import React from "react"; 9 | 10 | // Fix for Ant Design server-side rendering: https://github.com/ant-design/ant-design/issues/30396 11 | React["useLayoutEffect"] = 12 | typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect; 13 | 14 | class MyDocument extends Document { 15 | static async getInitialProps(ctx: DocumentContext) { 16 | const initialProps = await Document.getInitialProps(ctx); 17 | return { ...initialProps }; 18 | } 19 | 20 | render() { 21 | return ( 22 | 23 | 24 | 25 | 26 | 27 |
28 | 29 | 30 | 31 | ); 32 | } 33 | } 34 | 35 | export default MyDocument; 36 | -------------------------------------------------------------------------------- /@app/client/src/pages/o/[slug]/index.tsx: -------------------------------------------------------------------------------- 1 | import { PageHeader } from "@ant-design/pro-layout"; 2 | import { 3 | ButtonLink, 4 | SharedLayout, 5 | useOrganizationLoading, 6 | useOrganizationSlug, 7 | } from "@app/components"; 8 | import { 9 | OrganizationPage_OrganizationFragment, 10 | useOrganizationPageQuery, 11 | } from "@app/graphql"; 12 | import { Col, Empty, Row } from "antd"; 13 | import { NextPage } from "next"; 14 | import React, { FC } from "react"; 15 | 16 | const OrganizationPage: NextPage = () => { 17 | const slug = useOrganizationSlug(); 18 | const query = useOrganizationPageQuery({ variables: { slug } }); 19 | const organizationLoadingElement = useOrganizationLoading(query); 20 | const organization = query?.data?.organizationBySlug; 21 | 22 | return ( 23 | 29 | {organizationLoadingElement || ( 30 | 31 | )} 32 | 33 | ); 34 | }; 35 | 36 | interface OrganizationPageInnerProps { 37 | organization: OrganizationPage_OrganizationFragment; 38 | } 39 | 40 | const OrganizationPageInner: FC = (props) => { 41 | const { organization } = props; 42 | 43 | return ( 44 | 45 | 46 |
47 | 60 | Settings 61 | , 62 | ] 63 | : null 64 | } 65 | /> 66 | 69 | Customize this page in 70 |
71 | @app/client/src/pages/o/[slug]/index.tsx 72 | 73 | } 74 | /> 75 |
76 | 77 |
78 | ); 79 | }; 80 | 81 | export default OrganizationPage; 82 | -------------------------------------------------------------------------------- /@app/client/src/pages/o/index.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from "next"; 2 | import Router from "next/router"; 3 | import React, { useEffect } from "react"; 4 | 5 | const O: NextPage = () => { 6 | useEffect(() => { 7 | Router.replace("/"); 8 | }, []); 9 | return
Redirecting...
; 10 | }; 11 | 12 | export default O; 13 | -------------------------------------------------------------------------------- /@app/client/src/pages/verify.tsx: -------------------------------------------------------------------------------- 1 | import { Col, Row, SharedLayout } from "@app/components"; 2 | import { useSharedQuery, useVerifyEmailMutation } from "@app/graphql"; 3 | import { Alert } from "antd"; 4 | import get from "lodash/get"; 5 | import { NextPage } from "next"; 6 | import React, { useEffect } from "react"; 7 | 8 | interface IProps { 9 | id: string | null; 10 | token: string | null; 11 | } 12 | 13 | const VerifyPage: NextPage = (props) => { 14 | const [[id, token], setIdAndToken] = React.useState<[string, string]>([ 15 | props.id || "", 16 | props.token || "", 17 | ]); 18 | const [state, setState] = React.useState< 19 | "PENDING" | "SUBMITTING" | "SUCCESS" 20 | >(props.id && props.token ? "SUBMITTING" : "PENDING"); 21 | const [error, setError] = React.useState(null); 22 | const [verifyEmail] = useVerifyEmailMutation(); 23 | useEffect(() => { 24 | if (state === "SUBMITTING") { 25 | setError(null); 26 | verifyEmail({ 27 | variables: { 28 | id, 29 | token, 30 | }, 31 | }) 32 | .then((result) => { 33 | if (get(result, "data.verifyEmail.success")) { 34 | setState("SUCCESS"); 35 | } else { 36 | setState("PENDING"); 37 | setError(new Error("Incorrect token, please check and try again")); 38 | } 39 | }) 40 | .catch((e: Error) => { 41 | setError(e); 42 | setState("PENDING"); 43 | }); 44 | } 45 | }, [id, token, state, props, verifyEmail]); 46 | function form() { 47 | return ( 48 |
setState("SUBMITTING")}> 49 |

Please enter your email verification code

50 | setIdAndToken([id, e.target.value])} 54 | /> 55 | {error ?

{error.message || String(error)}

: null} 56 | 57 |
58 | ); 59 | } 60 | const query = useSharedQuery(); 61 | return ( 62 | 63 | 64 | 65 | {state === "PENDING" ? ( 66 | form() 67 | ) : state === "SUBMITTING" ? ( 68 | "Submitting..." 69 | ) : state === "SUCCESS" ? ( 70 | 76 | ) : ( 77 | "Unknown state" 78 | )} 79 | 80 | 81 | 82 | ); 83 | }; 84 | 85 | VerifyPage.getInitialProps = async ({ query: { id, token } }) => ({ 86 | id: typeof id === "string" ? id : null, 87 | token: typeof token === "string" ? token : null, 88 | }); 89 | 90 | export default VerifyPage; 91 | -------------------------------------------------------------------------------- /@app/client/src/styles.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Work around a bug in antd where a 33 | 34 | 35 | } 36 | /> 37 | ); 38 | } 39 | return ( 40 | 45 | We're really sorry, but an unexpected error occurred. Please{" "} 46 | {/* eslint-disable-next-line @next/next/no-html-link-for-pages */} 47 | return to the homepage and try again. 48 | 49 | } 50 | > 51 | 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /@app/components/src/ErrorOccurred.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import React from "react"; 3 | 4 | import { H2, P } from "./Text"; 5 | 6 | export function ErrorOccurred() { 7 | return ( 8 |
9 |

Something Went Wrong

10 |

11 | We're not sure what happened there; how embarrassing! Please try 12 | again later, or if this keeps happening then let us know. 13 |

14 |

15 | Go to the homepage 16 |

17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /@app/components/src/FourOhFour.tsx: -------------------------------------------------------------------------------- 1 | import { User } from "@app/graphql"; 2 | import { Result } from "antd"; 3 | import React from "react"; 4 | 5 | import { ButtonLink } from "./ButtonLink"; 6 | 7 | interface FourOhFourProps { 8 | currentUser?: Pick | null; 9 | } 10 | export function FourOhFour(props: FourOhFourProps) { 11 | const { currentUser } = props; 12 | return ( 13 |
14 | 22 | Back Home 23 | 24 | } 25 | /> 26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /@app/components/src/OrganizationSettingsLayout.tsx: -------------------------------------------------------------------------------- 1 | import { OrganizationPage_OrganizationFragment } from "@app/graphql"; 2 | import { Layout, Menu, Typography } from "antd"; 3 | import { TextProps } from "antd/lib/typography/Text"; 4 | import Link from "next/link"; 5 | import React, { useMemo } from "react"; 6 | 7 | import { contentMinHeight } from "./SharedLayout"; 8 | import { StandardWidth } from "./StandardWidth"; 9 | 10 | const { Text } = Typography; 11 | const { Sider, Content } = Layout; 12 | 13 | interface PageSpec { 14 | title: string; 15 | cy: string; 16 | titleProps?: TextProps; 17 | } 18 | 19 | // TypeScript shenanigans (so we can still use `keyof typeof pages` later) 20 | function page(spec: PageSpec): PageSpec { 21 | return spec; 22 | } 23 | 24 | const makePages = (_org: OrganizationPage_OrganizationFragment) => ({ 25 | [`/o/[slug]/settings`]: page({ 26 | title: "Profile", 27 | cy: "orgsettingslayout-link-profile", 28 | }), 29 | [`/o/[slug]/settings/members`]: page({ 30 | title: "Members", 31 | cy: "orgsettingslayout-link-members", 32 | }), 33 | [`/o/[slug]/settings/delete`]: page({ 34 | title: "Delete Organization", 35 | titleProps: { 36 | type: "danger", 37 | }, 38 | cy: "orgsettingslayout-link-delete", 39 | }), 40 | }); 41 | 42 | export interface OrganizationSettingsLayoutProps { 43 | href: string; 44 | organization: OrganizationPage_OrganizationFragment; 45 | children: React.ReactNode; 46 | } 47 | 48 | export function OrganizationSettingsLayout({ 49 | href: inHref, 50 | organization, 51 | children, 52 | }: OrganizationSettingsLayoutProps) { 53 | const pages = useMemo(() => makePages(organization), [organization]); 54 | const href = pages[inHref as keyof typeof pages] 55 | ? inHref 56 | : Object.keys(pages)[0]; 57 | /* 58 | const page = pages[href]; 59 | // `useRouter()` sometimes returns null 60 | const router: NextRouter | null = useRouter(); 61 | const fullHref = 62 | href + (router && router.query ? `?${qs.stringify(router.query)}` : ""); 63 | */ 64 | return ( 65 | 66 | 67 | 68 | {(Object.keys(pages) as (keyof typeof pages)[]).map((pageHref) => ( 69 | 70 | 75 | 76 | {pages[pageHref].title} 77 | 78 | 79 | 80 | ))} 81 | 82 | 83 | 84 | {children} 85 | 86 | 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /@app/components/src/PasswordStrength.tsx: -------------------------------------------------------------------------------- 1 | import { InfoCircleOutlined } from "@ant-design/icons"; 2 | import { Col, Popover, Progress, Row } from "antd"; 3 | import React, { useEffect, useState } from "react"; 4 | 5 | export interface PasswordStrengthProps { 6 | passwordStrength: number; 7 | suggestions: string[]; 8 | isDirty: boolean; 9 | isFocussed: boolean; 10 | } 11 | 12 | function strengthToPercent(strength: number): number { 13 | // passwordStrength is a value 0-4 14 | return (strength + 1) * 2 * 10; 15 | } 16 | 17 | export function PasswordStrength({ 18 | passwordStrength, 19 | suggestions = [ 20 | "Use a few words, avoid common phrases", 21 | "No need for symbols, digits, or uppercase letters", 22 | ], 23 | isDirty = false, 24 | isFocussed = false, 25 | }: PasswordStrengthProps) { 26 | const [visible, setVisible] = useState(false); 27 | 28 | useEffect(() => { 29 | // Auto-display popup 30 | if (isFocussed && isDirty && suggestions.length > 0) { 31 | setVisible(true); 32 | } 33 | // Auto-hide when there's no suggestions 34 | if (suggestions.length === 0) { 35 | setVisible(false); 36 | } 37 | }, [isDirty, isFocussed, suggestions]); 38 | 39 | // Blur on password field focus loss 40 | useEffect(() => { 41 | if (!isFocussed) { 42 | setVisible(false); 43 | } 44 | }, [isFocussed]); 45 | 46 | if (!isDirty) return null; 47 | 48 | const handleVisibleChange = (visible: boolean) => { 49 | setVisible(visible); 50 | }; 51 | 52 | const content = ( 53 |
    54 | {suggestions.map((suggestion, key) => { 55 | return
  • {suggestion}
  • ; 56 | })} 57 |
58 | ); 59 | 60 | return ( 61 | 62 | 63 | 67 | 68 | 69 | 77 |
84 | 0 ? {} : { visibility: "hidden" }} 86 | /> 87 |
88 |
89 | 90 |
91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /@app/components/src/Redirect.tsx: -------------------------------------------------------------------------------- 1 | import { NetworkStatus, useApolloClient } from "@apollo/client"; 2 | import { Skeleton } from "antd"; 3 | import Router from "next/router"; 4 | import React, { useEffect } from "react"; 5 | 6 | import { SharedLayout } from "./SharedLayout"; 7 | import { StandardWidth } from "./StandardWidth"; 8 | import { H3 } from "./Text"; 9 | 10 | export interface RedirectProps { 11 | href: string; 12 | as?: string; 13 | layout?: boolean; 14 | } 15 | 16 | export function Redirect({ href, as, layout }: RedirectProps) { 17 | const client = useApolloClient(); 18 | useEffect(() => { 19 | Router.push(href, as); 20 | }, [as, href]); 21 | if (layout) { 22 | return ( 23 | { 32 | throw new Error("Redirecting..."); 33 | }) as any, 34 | }} 35 | > 36 | 37 | 38 | ); 39 | } else { 40 | return ( 41 | 42 |

Redirecting...

43 | 44 |
45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /@app/components/src/SocialLoginOptions.tsx: -------------------------------------------------------------------------------- 1 | import { GithubOutlined } from "@ant-design/icons"; 2 | import { Button } from "antd"; 3 | import React from "react"; 4 | 5 | export interface SocialLoginOptionsProps { 6 | next: string; 7 | buttonTextFromService?: (service: string) => string; 8 | } 9 | 10 | function defaultButtonTextFromService(service: string) { 11 | return `Sign in with ${service}`; 12 | } 13 | 14 | export function SocialLoginOptions({ 15 | next, 16 | buttonTextFromService = defaultButtonTextFromService, 17 | }: SocialLoginOptionsProps) { 18 | return ( 19 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /@app/components/src/SpinPadded.tsx: -------------------------------------------------------------------------------- 1 | import Spin, { SpinProps } from "antd/lib/spin"; 2 | import React, { FC } from "react"; 3 | 4 | export const SpinPadded: FC = (props) => ( 5 |
13 | 14 |
15 | ); 16 | -------------------------------------------------------------------------------- /@app/components/src/StandardWidth.tsx: -------------------------------------------------------------------------------- 1 | import { Col, Row } from "antd"; 2 | import React, { FC } from "react"; 3 | 4 | export interface StandardWidthProps { 5 | children: React.ReactNode; 6 | } 7 | 8 | export const StandardWidth: FC = ({ children }) => ( 9 | 10 | {children} 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /@app/components/src/Text.tsx: -------------------------------------------------------------------------------- 1 | import { Typography } from "antd"; 2 | import React, { FC } from "react"; 3 | 4 | // Extract the type of a function's first argument 5 | // Usage: `Arg1` 6 | type Arg1 = T extends (arg: infer U) => any ? U : never; 7 | 8 | type TitleProps = Arg1; 9 | 10 | export const H1: FC = (props) => ( 11 | 12 | ); 13 | export const H2: FC = (props) => ( 14 | 15 | ); 16 | export const H3: FC = (props) => ( 17 | 18 | ); 19 | export const H4: FC = (props) => ( 20 | 21 | ); 22 | 23 | const Paragraph: typeof Typography.Paragraph = Typography.Paragraph; 24 | export { Paragraph as P }; 25 | 26 | type TextProps = Arg1; 27 | export const Strong: FC = (props) => ( 28 | 29 | ); 30 | -------------------------------------------------------------------------------- /@app/components/src/Warn.tsx: -------------------------------------------------------------------------------- 1 | import { Badge } from "antd"; 2 | import React from "react"; 3 | 4 | export interface WarnProps extends React.ComponentProps { 5 | children: React.ReactNode; 6 | okay?: boolean; 7 | } 8 | 9 | export function Warn({ children, okay, ...props }: WarnProps) { 10 | return okay ? ( 11 | {children} 12 | ) : ( 13 | 14 | 15 | {children} 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /@app/components/src/index.tsx: -------------------------------------------------------------------------------- 1 | export * from "./ButtonLink"; 2 | export * from "./ErrorAlert"; 3 | export * from "./ErrorOccurred"; 4 | export * from "./FourOhFour"; 5 | export * from "./organizationHooks"; 6 | export * from "./OrganizationSettingsLayout"; 7 | export * from "./PasswordStrength"; 8 | export * from "./Redirect"; 9 | export * from "./SettingsLayout"; 10 | export * from "./SharedLayout"; 11 | export * from "./SocialLoginOptions"; 12 | export * from "./SpinPadded"; 13 | export * from "./StandardWidth"; 14 | export * from "./Text"; 15 | export * from "./Warn"; 16 | -------------------------------------------------------------------------------- /@app/components/src/organizationHooks.tsx: -------------------------------------------------------------------------------- 1 | import { QueryResult } from "@apollo/client"; 2 | import { OrganizationPage_QueryFragment } from "@app/graphql"; 3 | import { Col, Row } from "antd"; 4 | import { useRouter } from "next/router"; 5 | import React from "react"; 6 | 7 | import { ErrorAlert, FourOhFour } from "./"; 8 | import { SpinPadded } from "./SpinPadded"; 9 | 10 | export function useOrganizationSlug() { 11 | const router = useRouter(); 12 | const { slug: rawSlug } = router.query; 13 | return String(rawSlug); 14 | } 15 | 16 | export function useOrganizationLoading( 17 | query: Pick< 18 | QueryResult, 19 | "data" | "loading" | "error" | "networkStatus" | "client" | "refetch" 20 | > 21 | ) { 22 | const { data, loading, error } = query; 23 | 24 | let child: JSX.Element | null = null; 25 | const organization = data?.organizationBySlug; 26 | if (organization) { 27 | //child = ; 28 | } else if (loading) { 29 | child = ; 30 | } else if (error) { 31 | child = ; 32 | } else { 33 | // TODO: 404 34 | child = ; 35 | } 36 | 37 | return child ? ( 38 | 39 | {child} 40 | 41 | ) : null; 42 | } 43 | -------------------------------------------------------------------------------- /@app/components/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "rootDir": "src", 6 | "outDir": "dist", 7 | "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo", 8 | "declarationDir": "dist", 9 | "lib": ["dom", "dom.iterable", "esnext"], 10 | "target": "es5", 11 | "module": "commonjs", 12 | "jsx": "react" 13 | }, 14 | "include": ["src/**/*.ts", "src/**/*.tsx"], 15 | "references": [ 16 | { 17 | "path": "../graphql" 18 | }, 19 | { 20 | "path": "../lib" 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /@app/config/README.md: -------------------------------------------------------------------------------- 1 | # @app/config 2 | 3 | This package contains shared configuration between the entire project. 4 | 5 | ## Configuration settings 6 | 7 | In [src/index.ts](src/index.ts) you'll find some settings that are used in 8 | various places in the app, for example: 9 | 10 | - `fromEmail` - the email address to send emails from. 11 | - `awsRegion` - used for sending emails with Amazon SES. 12 | - `projectName` - sourced from `package.json`; the name of your project! 13 | - `companyName` - for copyright ownership. 14 | - `emailLegalText` - legal text to put at the bottom of emails. Since all emails 15 | in this project is transactional, an `unsubscribe` link is not needed, but you 16 | should definitely consider how you intend to handle complaints 17 | 18 | ## Environmental variables 19 | 20 | In order to support multiplatform and docker development in the same repository, 21 | we use `node -r @app/config/env path/to/code` to run various parts of the 22 | project. `node -r` requires a specific module before running the main script; in 23 | this case we're requiring [@app/config/env.js](./env.js) which sources the 24 | settings from `.env` in the root folder and then builds some derivative 25 | environmental variables from them. This is a fairly advanced technique. 26 | -------------------------------------------------------------------------------- /@app/config/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require("../../babel.config"); 2 | -------------------------------------------------------------------------------- /@app/config/env.js: -------------------------------------------------------------------------------- 1 | /* Use via `node -r @app/config/env path/to/file.js` */ 2 | require("dotenv").config({ path: `${__dirname}/../../.env` }); 3 | require("./extra"); 4 | -------------------------------------------------------------------------------- /@app/config/extra.js: -------------------------------------------------------------------------------- 1 | // These are the connection strings for the DB and the test DB. 2 | // NOTE: in production you probably want to add ?ssl=true to force SSL usage. 3 | // NOTE: these used to be in `.env` but now it is used by docker-compose we can't use expansions 4 | const { resolve } = require("path"); 5 | 6 | function fixFilePaths(connectionString) { 7 | // Connection string may contain '../../data/amazon-rds-ca-cert.pem' or 8 | // similar; but we might be running it from somewhere other than `@app/*/` 9 | // (e.g. maybe `@app/*/dist/`). To solve this, we make the file path concrete 10 | // here. 11 | return connectionString.replace( 12 | /\.\.\/\.\.\/data\//g, 13 | resolve(__dirname, "../../data") + "/" 14 | ); 15 | } 16 | 17 | process.env.DATABASE_URL = process.env.DATABASE_URL 18 | ? fixFilePaths(process.env.DATABASE_URL) 19 | : `postgres://${process.env.DATABASE_OWNER}:${process.env.DATABASE_OWNER_PASSWORD}@${process.env.DATABASE_HOST}/${process.env.DATABASE_NAME}`; 20 | process.env.AUTH_DATABASE_URL = process.env.AUTH_DATABASE_URL 21 | ? fixFilePaths(process.env.AUTH_DATABASE_URL) 22 | : `postgres://${process.env.DATABASE_AUTHENTICATOR}:${process.env.DATABASE_AUTHENTICATOR_PASSWORD}@${process.env.DATABASE_HOST}/${process.env.DATABASE_NAME}`; 23 | process.env.SHADOW_DATABASE_URL = process.env.SHADOW_DATABASE_URL 24 | ? fixFilePaths(process.env.SHADOW_DATABASE_URL) 25 | : `postgres://${process.env.DATABASE_OWNER}:${process.env.DATABASE_OWNER_PASSWORD}@${process.env.DATABASE_HOST}/${process.env.DATABASE_NAME}_shadow`; 26 | process.env.SHADOW_AUTH_DATABASE_URL = process.env.SHADOW_AUTH_DATABASE_URL 27 | ? fixFilePaths(process.env.SHADOW_AUTH_DATABASE_URL) 28 | : `postgres://${process.env.DATABASE_AUTHENTICATOR}:${process.env.DATABASE_AUTHENTICATOR_PASSWORD}@${process.env.DATABASE_HOST}/${process.env.DATABASE_NAME}_shadow`; 29 | 30 | // Always overwrite test database URL 31 | process.env.TEST_DATABASE_URL = `postgres://${process.env.DATABASE_OWNER}:${process.env.DATABASE_OWNER_PASSWORD}@${process.env.DATABASE_HOST}/${process.env.DATABASE_NAME}_test`; 32 | 33 | // https://docs.cypress.io/guides/guides/environment-variables.html#Option-3-CYPRESS 34 | process.env.CYPRESS_ROOT_URL = process.env.ROOT_URL; 35 | -------------------------------------------------------------------------------- /@app/config/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require("../../jest.config.base")(__dirname); 2 | -------------------------------------------------------------------------------- /@app/config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@app/config", 3 | "private": true, 4 | "version": "0.0.0", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "build": "tsc", 9 | "test": "cross-env NODE_ENV=test NODE_OPTIONS=\"${NODE_OPTIONS:-} -r @app/config/env\" jest" 10 | }, 11 | "dependencies": { 12 | "dotenv": "^16.0.3" 13 | }, 14 | "devDependencies": { 15 | "cross-env": "^7.0.3", 16 | "jest": "^29.4.3" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /@app/config/src/index.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | const packageJson = require("../../../package.json"); 3 | 4 | // TODO: customise this with your own settings! 5 | 6 | export const fromEmail = 7 | '"PostGraphile Starter" '; 8 | export const awsRegion = "us-east-1"; 9 | export const projectName = packageJson.projectName.replace(/[-_]/g, " "); 10 | export const companyName = projectName; // For copyright ownership 11 | export const emailLegalText = 12 | // Envvar here so we can override on the demo website 13 | process.env.LEGAL_TEXT || ""; 14 | -------------------------------------------------------------------------------- /@app/config/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "rootDir": "src", 6 | "outDir": "dist", 7 | "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo", 8 | "declarationDir": "dist", 9 | "lib": ["es2018", "esnext.asynciterable"], 10 | "target": "es2018", 11 | "module": "commonjs" 12 | }, 13 | "include": ["src"] 14 | } 15 | -------------------------------------------------------------------------------- /@app/db/__tests__/.jest.watch.hack.json: -------------------------------------------------------------------------------- 1 | {"ts": 0} 2 | -------------------------------------------------------------------------------- /@app/db/__tests__/README.md: -------------------------------------------------------------------------------- 1 | # @app/db/\_\_tests\_\_ 2 | 3 | Database tests are organised into 4 | `@app/db/__tests__/[schema]/[type]/[name].test.ts` files. If you do not name 5 | your test ending with `.test.ts` then it will not run. 6 | 7 | Tests are ran using `jest -i` inline mode so that only one test runs at a time, 8 | this is to prevent rare database deadlocks when running the test suite. In 9 | future we may build a way to run the tests against a swarm of databases which 10 | will remove this restriction, but for now it is necessary to ensure 11 | deterministic tests. 12 | 13 | ## jest.watch.hack.ts 14 | 15 | We're running `jest` in `--watch` mode, so it only tests files that are affected 16 | by changes since the last commit. Since jest cannot look into our database to 17 | see what tests are relevant to run, we make database tests dependent on this 18 | "hack" file, which we then update with a timestamp to force the database tests 19 | to run on database changes. When the tests run, we then reset this file back to 20 | its unmodified state to prevent the tests running again. 21 | 22 | Never commit changes to this file. 23 | -------------------------------------------------------------------------------- /@app/db/__tests__/app_public/functions/change_password.test.ts: -------------------------------------------------------------------------------- 1 | import { PoolClient } from "pg"; 2 | 3 | import { asRoot, becomeUser, withRootDb, withUserDb } from "../../helpers"; 4 | 5 | async function changePassword( 6 | client: PoolClient, 7 | oldPassword: string | null | void, 8 | newPassword: string | null 9 | ) { 10 | const { 11 | rows: [row], 12 | } = await client.query( 13 | ` 14 | select * from app_public.change_password( 15 | $1, 16 | $2 17 | ) 18 | `, 19 | [oldPassword, newPassword] 20 | ); 21 | return row; 22 | } 23 | 24 | it("can change password", () => 25 | withUserDb(async (client, user) => { 26 | const newPassword = "can change password test DO_NOT_COPY_THIS"; 27 | 28 | // Action 29 | await changePassword(client, user._password, newPassword); 30 | 31 | // Assertions 32 | const { rows: secrets } = await asRoot(client, () => 33 | client.query( 34 | "select * from app_private.user_secrets where user_id = $1 and password_hash = crypt($2, password_hash)", 35 | [user.id, newPassword] 36 | ) 37 | ); 38 | 39 | // Check it only changes one person's password 40 | expect(secrets).toHaveLength(1); 41 | expect(secrets[0].user_id).toEqual(user.id); 42 | })); 43 | 44 | it("cannot change password if password is wrong (CREDS)", () => 45 | withUserDb(async (client) => { 46 | const newPassword = "SECURE_PASSWORD_1!"; 47 | 48 | // Action 49 | const promise = changePassword(client, "WRONG PASSWORD", newPassword); 50 | 51 | // Assertions 52 | await expect(promise).rejects.toMatchInlineSnapshot( 53 | `[error: Incorrect password]` 54 | ); 55 | await expect(promise).rejects.toHaveProperty("code", "CREDS"); 56 | })); 57 | 58 | it("cannot set a 'weak' password (WEAKP)", () => 59 | // For a given value of 'weak' 60 | withUserDb(async (client, user) => { 61 | const newPassword = "WEAK"; 62 | 63 | // Action 64 | const promise = changePassword(client, user._password, newPassword); 65 | 66 | // Assertions 67 | await expect(promise).rejects.toMatchInlineSnapshot( 68 | `[error: Password is too weak]` 69 | ); 70 | await expect(promise).rejects.toHaveProperty("code", "WEAKP"); 71 | })); 72 | 73 | it("gives error if not logged in (LOGIN)", () => 74 | withRootDb(async (client) => { 75 | // Setup 76 | await becomeUser(client, null); 77 | const newPassword = "SECURE_PASSWORD_1!"; 78 | 79 | // Action 80 | const promise = changePassword(client, "irrelevant", newPassword); 81 | 82 | // Assertions 83 | await expect(promise).rejects.toMatchInlineSnapshot( 84 | `[error: You must log in to change your password]` 85 | ); 86 | await expect(promise).rejects.toHaveProperty("code", "LOGIN"); 87 | })); 88 | -------------------------------------------------------------------------------- /@app/db/__tests__/app_public/functions/forgot_password.test.ts: -------------------------------------------------------------------------------- 1 | import { PoolClient } from "pg"; 2 | 3 | import { clearJobs, createUsers, getJobs, withRootDb } from "../../helpers"; 4 | 5 | export async function forgotPassword( 6 | client: PoolClient, 7 | email: string 8 | ): Promise { 9 | const { 10 | rows: [row], 11 | } = await client.query('select app_public.forgot_password($1) as "bool"', [ 12 | email, 13 | ]); 14 | return row ? row.bool : null; 15 | } 16 | 17 | it("can trigger user password reset with email, receive email with token", () => 18 | withRootDb(async (client) => { 19 | const [user] = await createUsers(client, 1, true); 20 | await forgotPassword(client, user._email!.toLowerCase()); 21 | const jobs = await getJobs(client, "user__forgot_password"); 22 | expect(jobs).toHaveLength(1); 23 | expect(jobs[0].payload).toMatchObject({ 24 | id: user.id, 25 | email: user._email, 26 | }); 27 | expect(jobs[0].payload.token).toBeTruthy(); 28 | expect(typeof jobs[0].payload.token).toBe("string"); 29 | })); 30 | 31 | it("can trigger user password reset with EMAIL, receive email with token", () => 32 | withRootDb(async (client) => { 33 | const [user] = await createUsers(client, 1, true); 34 | await forgotPassword(client, user._email!.toUpperCase()); 35 | const jobs = await getJobs(client, "user__forgot_password"); 36 | expect(jobs).toHaveLength(1); 37 | expect(jobs[0].payload).toMatchObject({ 38 | id: user.id, 39 | email: user._email, 40 | }); 41 | expect(jobs[0].payload.token).toBeTruthy(); 42 | expect(typeof jobs[0].payload.token).toBe("string"); 43 | })); 44 | 45 | it("cannot spam re-send of password reset email", () => 46 | withRootDb(async (client) => { 47 | const [user] = await createUsers(client, 1, true); 48 | await forgotPassword(client, user._email!.toUpperCase()); 49 | await clearJobs(client); 50 | // Immediately re-send 51 | await forgotPassword(client, user._email!.toUpperCase()); 52 | const jobs = await getJobs(client, "user__forgot_password"); 53 | expect(jobs).toHaveLength(0); 54 | })); 55 | 56 | it("can trigger re-send of the password reset email", () => 57 | withRootDb(async (client) => { 58 | const [user] = await createUsers(client, 1, true); 59 | await forgotPassword(client, user._email!.toUpperCase()); 60 | await clearJobs(client); 61 | await client.query( 62 | ` 63 | update app_private.user_email_secrets 64 | set password_reset_email_sent_at = password_reset_email_sent_at - interval '3 minutes' 65 | where user_email_id = (select id from app_public.user_emails where email = $1) 66 | `, 67 | [user._email] 68 | ); 69 | await forgotPassword(client, user._email!.toUpperCase()); 70 | const jobs = await getJobs(client, "user__forgot_password"); 71 | expect(jobs).toHaveLength(1); 72 | expect(jobs[0].payload).toMatchObject({ 73 | id: user.id, 74 | email: user._email, 75 | }); 76 | expect(jobs[0].payload.token).toBeTruthy(); 77 | expect(typeof jobs[0].payload.token).toBe("string"); 78 | })); 79 | -------------------------------------------------------------------------------- /@app/db/__tests__/app_public/functions/logout.test.ts: -------------------------------------------------------------------------------- 1 | import { getSessions, withUserDb } from "../../helpers"; 2 | 3 | it("deletes session when user logs out", () => 4 | withUserDb(async (client, user) => { 5 | // Setup 6 | const originalSessions = await getSessions(client, user.id); 7 | expect(originalSessions).toHaveLength(1); 8 | 9 | // Action 10 | await client.query("select * from app_public.logout()"); 11 | 12 | // Assertions 13 | const finalSessions = await getSessions(client, user.id); 14 | expect(finalSessions).toHaveLength(0); 15 | })); 16 | 17 | it("doesn't throw an error if logged out user logs out (idempotent)", () => 18 | withUserDb(async (client, user) => { 19 | // Setup 20 | await client.query("select * from app_public.logout()"); 21 | 22 | // Action/assertion: second logout shouldn't error 23 | await expect( 24 | client.query("select * from app_public.logout()") 25 | ).resolves.toBeTruthy(); 26 | const finalSessions = await getSessions(client, user.id); 27 | expect(finalSessions).toHaveLength(0); 28 | })); 29 | -------------------------------------------------------------------------------- /@app/db/__tests__/jest.watch.hack.ts: -------------------------------------------------------------------------------- 1 | export const ts = null; 2 | -------------------------------------------------------------------------------- /@app/db/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require("../../babel.config"); 2 | -------------------------------------------------------------------------------- /@app/db/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require("../../jest.config.base")(__dirname), 3 | maxConcurrency: 1, 4 | }; 5 | -------------------------------------------------------------------------------- /@app/db/migrations/README.md: -------------------------------------------------------------------------------- 1 | # Migrations 2 | 3 | This folder contains the database migrations. We're using the `graphile-migrate` 4 | project to produce these; we highly recommend you 5 | [read the Graphile Migrate README](https://github.com/graphile/migrate/blob/main/README.md) 6 | before implementing your own migrations. 7 | 8 | The main files you'll be working with are those in the `current/` folder. 9 | 10 | ## afterReset.sql 11 | 12 | This file is ran once only, when you reset (or create) your database. It 13 | currently grants permissions to the relevant roles and creates the required 14 | extensions. It's expected that this is ran with database superuser privileges as 15 | normal users often don't have sufficient permissions to install extensions. 16 | 17 | ## current/\*.sql 18 | 19 | This is where your new database changes go. They need to be idempotent (for 20 | explanation 21 | [read the Graphile Migrate README](https://github.com/graphile/migrate/blob/main/README.md)). 22 | The `yarn start` command will automatically watch these files and re-run them 23 | whenever they change, updating your database in realtime. Each file needs a 24 | unique positive integer prefix, we've started you off with 25 | `current/1-current.sql` but you can add more if it helps you structure your 26 | migration more cleanly. 27 | 28 | **IMPORTANT**: because we use `ignoreRBAC: false` in PostGraphile's 29 | configuration, new tables _will not show up_ until you `GRANT` permissions on 30 | them. 31 | 32 | ```sql 33 | create table app_public.my_new_table ( 34 | id serial primary key, 35 | my_column text 36 | ); 37 | 38 | -- Doesn't appear until we add: 39 | 40 | grant 41 | select, 42 | insert (my_column), 43 | update (my_column), 44 | delete 45 | on app_public.my_new_table to :DATABASE_VISITOR; 46 | ``` 47 | 48 | ## committed/\*.sql 49 | 50 | When you're happy with the changes you have made, you can commit your migration 51 | with 52 | 53 | ``` 54 | yarn db commit 55 | ``` 56 | 57 | This will call `graphile-migrate commit` which involves merging the 58 | `current/*.sql` files together and then putting the result into the `committed` 59 | folder with a hash to prevent later modifications (which should instead be done 60 | with additional migrations). 61 | 62 | If you've not yet merged your changes (and no-one else has ran them) then you 63 | can run 64 | 65 | ``` 66 | yarn db uncommit 67 | ``` 68 | 69 | and it will perform the reverse of this process so that you may modify the 70 | migrations again. 71 | -------------------------------------------------------------------------------- /@app/db/migrations/afterReset.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | GRANT CONNECT ON DATABASE :DATABASE_NAME TO :DATABASE_OWNER; 3 | GRANT CONNECT ON DATABASE :DATABASE_NAME TO :DATABASE_AUTHENTICATOR; 4 | GRANT ALL ON DATABASE :DATABASE_NAME TO :DATABASE_OWNER; 5 | ALTER SCHEMA public OWNER TO :DATABASE_OWNER; 6 | 7 | -- Some extensions require superuser privileges, so we create them before migration time. 8 | CREATE EXTENSION IF NOT EXISTS plpgsql WITH SCHEMA pg_catalog; 9 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA public; 10 | CREATE EXTENSION IF NOT EXISTS citext WITH SCHEMA public; 11 | CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public; 12 | COMMIT; 13 | -------------------------------------------------------------------------------- /@app/db/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@app/db", 3 | "private": true, 4 | "version": "0.0.0", 5 | "scripts": { 6 | "gm": "cross-env NODE_OPTIONS=\"${NODE_OPTIONS:-} -r @app/config/env\" graphile-migrate", 7 | "migrate": "yarn gm migrate", 8 | "watch": "yarn gm watch", 9 | "commit": "yarn gm commit", 10 | "uncommit": "yarn gm uncommit", 11 | "reset": "yarn gm reset", 12 | "dump": "yarn gm migrate && yarn gm reset --shadow --erase && yarn gm migrate --shadow --forceActions", 13 | "wipe-if-demo": "./scripts/wipe-if-demo", 14 | "test": "cross-env NODE_ENV=test NODE_OPTIONS=\"${NODE_OPTIONS:-} -r @app/config/env\" jest" 15 | }, 16 | "dependencies": { 17 | "cross-env": "^7.0.3", 18 | "graphile-migrate": "^1.4.1" 19 | }, 20 | "devDependencies": { 21 | "@types/pg": "^8.6.6", 22 | "graphile-worker": "^0.13.0", 23 | "jest": "^29.4.3", 24 | "lodash": "^4.17.21", 25 | "pg": "^8.9.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /@app/db/scripts/dump-db.js: -------------------------------------------------------------------------------- 1 | const { spawn } = require("child_process"); 2 | 3 | if (process.env.IN_TESTS === "1") { 4 | process.exit(0); 5 | } 6 | 7 | const connectionString = process.env.GM_DBURL; 8 | if (!connectionString) { 9 | console.error( 10 | "This script should only be called from a graphile-migrate action." 11 | ); 12 | process.exit(1); 13 | } 14 | 15 | spawn( 16 | process.env.PG_DUMP || "pg_dump", 17 | [ 18 | "--no-sync", 19 | "--schema-only", 20 | "--no-owner", 21 | "--exclude-schema=graphile_migrate", 22 | "--exclude-schema=graphile_worker", 23 | "--file=../../data/schema.sql", 24 | connectionString, 25 | ], 26 | { 27 | stdio: "inherit", 28 | shell: true, 29 | } 30 | ); 31 | -------------------------------------------------------------------------------- /@app/db/scripts/test-seed.js: -------------------------------------------------------------------------------- 1 | const { writeFile } = require("fs").promises; 2 | const pg = require("pg"); 3 | 4 | if (process.env.IN_TESTS !== "1") { 5 | process.exit(0); 6 | } 7 | 8 | async function main() { 9 | const connectionString = process.env.GM_DBURL; 10 | if (!connectionString) { 11 | throw new Error("GM_DBURL not set!"); 12 | } 13 | const pgPool = new pg.Pool({ connectionString }); 14 | try { 15 | await pgPool.query("delete from graphile_worker.jobs;"); 16 | await writeFile( 17 | `${__dirname}/../__tests__/jest.watch.hack.ts`, 18 | `export const ts = ${Date.now()};\n` 19 | ); 20 | } finally { 21 | await pgPool.end(); 22 | } 23 | } 24 | 25 | main().catch((e) => { 26 | console.error(e); 27 | process.exit(1); 28 | }); 29 | -------------------------------------------------------------------------------- /@app/db/scripts/wipe-if-demo: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | # TODO: delete this script, the entry in `package.json`, and the subcommand in 6 | # `Procfile` - it's only used for the Graphile Starter demo. 7 | 8 | if [ "x$DEMO" != "xWIPE_DATABASE" ]; then 9 | echo "This script is only intended to be used when deploying the Graphile Starter demo to Heroku, since we reset the database every time we push. You should delete it and delete the references to it in package.json and Procfile." 10 | exit 0; 11 | fi; 12 | 13 | if [ "x$DATABASE_URL" = "x" ]; then 14 | echo "No database URL."; 15 | exit 2; 16 | fi; 17 | 18 | psql "$DATABASE_URL" < 2 | 3 | context("HomePage", () => { 4 | it("renders correctly", () => { 5 | // Setup 6 | cy.visit(Cypress.env("ROOT_URL")); 7 | 8 | // Action 9 | 10 | // Assertions 11 | cy.url().should("equal", Cypress.env("ROOT_URL") + "/"); 12 | cy.getCy("header-login-button").should("exist"); 13 | cy.getCy("homepage-header").should("exist"); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /@app/e2e/cypress/e2e/login.cy.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | const PASSWORD = "MyPassword1"; 4 | 5 | context("Login", () => { 6 | beforeEach(() => cy.serverCommand("clearTestUsers")); 7 | 8 | it("can log in", () => { 9 | // Setup 10 | cy.serverCommand("createUser", { 11 | username: "testuser", 12 | name: "Test User", 13 | verified: true, 14 | password: PASSWORD, 15 | }); 16 | cy.visit(Cypress.env("ROOT_URL") + "/login"); 17 | cy.getCy("loginpage-button-withusername").click(); 18 | cy.getCy("header-login-button").should("not.exist"); // No login button on login page 19 | 20 | // Action 21 | cy.getCy("loginpage-input-username").type("testuser"); 22 | cy.getCy("loginpage-input-password").type(PASSWORD); 23 | cy.getCy("loginpage-button-submit").click(); 24 | 25 | // Assertion 26 | cy.url().should("equal", Cypress.env("ROOT_URL") + "/"); // Should be on homepage 27 | cy.getCy("header-login-button").should("not.exist"); // Should be logged in 28 | cy.getCy("layout-dropdown-user").should("contain", "Test User"); // Should be logged in 29 | }); 30 | 31 | it("fails on bad password", () => { 32 | // Setup 33 | cy.serverCommand("createUser", { 34 | username: "testuser", 35 | name: "Test User", 36 | verified: true, 37 | password: PASSWORD, 38 | }); 39 | cy.visit(Cypress.env("ROOT_URL") + "/login"); 40 | cy.getCy("loginpage-button-withusername").click(); 41 | 42 | // Action 43 | cy.getCy("loginpage-input-username").type("testuser"); 44 | cy.getCy("loginpage-input-password").type(PASSWORD + "!"); 45 | cy.getCy("loginpage-button-submit").click(); 46 | 47 | // Assertion 48 | cy.contains("Incorrect username or passphrase").should("exist"); 49 | cy.url().should("equal", Cypress.env("ROOT_URL") + "/login"); // Should be on login page still 50 | cy.getCy("header-login-button").should("not.exist"); // No login button on login page 51 | cy.getCy("layout-dropdown-user").should("not.exist"); // Should not be logged in 52 | cy.getCy("layout-dropdown-user").should("not.exist"); // Should not be logged in 53 | 54 | // But can recover 55 | cy.getCy("loginpage-input-password").type("{backspace}"); // Delete the '!' that shouldn't be there 56 | cy.getCy("loginpage-button-submit").click(); 57 | cy.url().should("equal", Cypress.env("ROOT_URL") + "/"); // Should be on homepage 58 | cy.getCy("header-login-button").should("not.exist"); // Should be logged in 59 | cy.getCy("layout-dropdown-user").should("contain", "Test User"); // Should be logged in 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /@app/e2e/cypress/e2e/organization_create.cy.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context("Create organizations", () => { 4 | beforeEach(() => cy.serverCommand("clearTestUsers")); 5 | beforeEach(() => cy.serverCommand("clearTestOrganizations")); 6 | 7 | it("can create an organization", () => { 8 | // Setup 9 | cy.login({ next: "/", verified: true }); 10 | 11 | // Action 12 | cy.getCy("layout-dropdown-user").trigger("mouseover"); 13 | cy.getCy("layout-link-create-organization").click(); 14 | cy.url().should("equal", Cypress.env("ROOT_URL") + "/create-organization"); 15 | cy.getCy("createorganization-input-name").type("Test Organization"); 16 | cy.getCy("createorganization-slug-value").contains("test-organization"); 17 | cy.getCy("createorganization-button-create").click(); 18 | 19 | // Assertion 20 | cy.url().should("equal", Cypress.env("ROOT_URL") + "/o/test-organization"); 21 | cy.getCy("layout-header-titlelink").contains("Test Organization"); 22 | cy.getCy("layout-header-titlelink") 23 | .invoke("attr", "href") 24 | .should("equal", "/o/test-organization"); 25 | }); 26 | 27 | it("handles conflicting organization name", () => { 28 | // Setup 29 | cy.login({ 30 | next: "/", 31 | verified: true, 32 | orgs: [["Test Organization", "test-organization"]], 33 | }); 34 | 35 | // Action 36 | cy.getCy("layout-dropdown-user").trigger("mouseover"); 37 | cy.getCy("layout-link-create-organization").click(); 38 | cy.url().should("equal", Cypress.env("ROOT_URL") + "/create-organization"); 39 | cy.getCy("createorganization-input-name").type("Test Organization"); 40 | cy.getCy("createorganization-slug-value").contains("test-organization"); 41 | 42 | // Assertion 43 | cy.getCy("createorganization-hint-nameinuse").should("exist"); 44 | cy.getCy("createorganization-button-create").click(); 45 | cy.getCy("createorganization-alert-nuniq").should("exist"); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /@app/e2e/cypress/e2e/organization_page.cy.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context("Organization page", () => { 4 | beforeEach(() => cy.serverCommand("clearTestUsers")); 5 | beforeEach(() => cy.serverCommand("clearTestOrganizations")); 6 | 7 | it("renders for owner", () => { 8 | // Setup 9 | cy.login({ 10 | next: "/o/test-organization", 11 | verified: true, 12 | orgs: [["Test Organization", "test-organization"]], 13 | }); 14 | 15 | // Action 16 | 17 | // Assertions 18 | cy.url().should("equal", Cypress.env("ROOT_URL") + "/o/test-organization"); 19 | cy.getCy("layout-header-titlelink").contains("Test Organization"); 20 | cy.getCy("layout-header-titlelink") 21 | .invoke("attr", "href") 22 | .should("equal", "/o/test-organization"); 23 | cy.getCy("organizationpage-button-settings").should("exist"); 24 | }); 25 | 26 | it("renders 404 for logged out user", () => { 27 | // Setup 28 | cy.login({ 29 | next: "/o/test-organization", 30 | verified: true, 31 | orgs: [["Test Organization", "test-organization"]], 32 | }); 33 | cy.visit(Cypress.env("ROOT_URL") + "/logout"); 34 | cy.visit(Cypress.env("ROOT_URL") + "/o/test-organization"); 35 | 36 | // Action 37 | 38 | // Assertions 39 | cy.url().should("equal", Cypress.env("ROOT_URL") + "/o/test-organization"); 40 | cy.getCy("fourohfour-div").should("exist"); 41 | }); 42 | 43 | it("renders without settings link for non-owner member", () => { 44 | // Setup 45 | cy.login({ 46 | next: "/o/test-organization", 47 | verified: true, 48 | orgs: [["Test Organization", "test-organization", false]], 49 | }); 50 | 51 | // Action 52 | 53 | // Assertions 54 | cy.url().should("equal", Cypress.env("ROOT_URL") + "/o/test-organization"); 55 | cy.getCy("layout-header-titlelink").contains("Test Organization"); 56 | cy.getCy("layout-header-titlelink") 57 | .invoke("attr", "href") 58 | .should("equal", "/o/test-organization"); 59 | cy.getCy("organizationpage-button-settings").should("not.exist"); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /@app/e2e/cypress/e2e/register_account.cy.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context("RegisterAccount", () => { 4 | beforeEach(() => cy.serverCommand("clearTestUsers")); 5 | 6 | it("can navigate to registration page", () => { 7 | // Setup 8 | cy.visit(Cypress.env("ROOT_URL")); 9 | 10 | // Action 11 | cy.getCy("header-login-button").click(); 12 | cy.getCy("loginpage-button-register").click(); 13 | 14 | // Assertions 15 | cy.url().should("equal", Cypress.env("ROOT_URL") + "/register?next=%2F"); 16 | cy.getCy("registerpage-name-label").should("exist"); 17 | }); 18 | 19 | it("requires the form be filled", () => { 20 | // Setup 21 | cy.visit(Cypress.env("ROOT_URL") + "/register"); 22 | 23 | // Action 24 | cy.getCy("registerpage-submit-button").click(); 25 | 26 | // Assertions 27 | cy.getCy("registerpage-name-label").should("exist"); 28 | cy.contains("input your name"); 29 | cy.contains("input your passphrase"); 30 | }); 31 | 32 | context("Account creation", () => { 33 | beforeEach(() => cy.serverCommand("clearTestUsers")); 34 | 35 | it("enables account creation", () => { 36 | // Setup 37 | cy.visit(Cypress.env("ROOT_URL") + "/register"); 38 | cy.getCy("header-login-button").should("not.exist"); // No login button on register page 39 | 40 | // Action 41 | cy.getCy("registerpage-input-name").type("Test User"); 42 | cy.getCy("registerpage-input-username").type("testuser"); 43 | cy.getCy("registerpage-input-email").type("test.user@example.com"); 44 | cy.getCy("registerpage-input-password").type("Really Good Password"); 45 | cy.getCy("registerpage-input-password2").type("Really Good Password"); 46 | cy.getCy("registerpage-submit-button").click(); 47 | 48 | // Assertions 49 | cy.url().should("equal", Cypress.env("ROOT_URL") + "/"); // Should be on homepage 50 | cy.getCy("header-login-button").should("not.exist"); 51 | cy.getCy("layout-dropdown-user").should("contain", "Test User"); // Should be logged in 52 | }); 53 | 54 | it("prevents creation if username is in use", () => { 55 | // Setup 56 | cy.serverCommand("createUser", { username: "testuser" }); 57 | cy.visit(Cypress.env("ROOT_URL") + "/register"); 58 | 59 | // Action 60 | cy.getCy("registerpage-input-name").type("Test User"); 61 | cy.getCy("registerpage-input-username").type("testuser"); 62 | cy.getCy("registerpage-input-email").type("test.user@example.com"); 63 | cy.getCy("registerpage-input-password").type("Really Good Password"); 64 | cy.getCy("registerpage-input-password2").type("Really Good Password"); 65 | cy.getCy("registerpage-submit-button").click(); 66 | 67 | // Assertions 68 | cy.contains("account with this username").should("exist"); 69 | cy.getCy("header-login-button").should("not.exist"); // No login button on register page 70 | }); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /@app/e2e/cypress/e2e/subscriptions.cy.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | export {}; 4 | 5 | const PASSWORD = "MyPassword1"; 6 | 7 | context("Subscriptions", () => { 8 | beforeEach(() => cy.serverCommand("clearTestUsers")); 9 | 10 | it("can log in; current user subscription works", () => { 11 | // Setup 12 | cy.serverCommand("createUser", { 13 | username: "testuser", 14 | name: "Test User", 15 | verified: false, 16 | password: PASSWORD, 17 | }); 18 | cy.visit(Cypress.env("ROOT_URL") + "/login"); 19 | cy.getCy("loginpage-button-withusername").click(); 20 | cy.getCy("header-login-button").should("not.exist"); // No login button on login page 21 | 22 | // Action 23 | cy.getCy("loginpage-input-username").type("testuser"); 24 | cy.getCy("loginpage-input-password").type(PASSWORD); 25 | cy.getCy("loginpage-button-submit").click(); 26 | 27 | // Assertion 28 | cy.url().should("equal", Cypress.env("ROOT_URL") + "/"); // Should be on homepage 29 | cy.getCy("header-login-button").should("not.exist"); // Should be logged in 30 | cy.getCy("layout-dropdown-user").should("contain", "Test User"); // Should be logged in 31 | 32 | // Subscription 33 | cy.getCy("header-unverified-warning").should("exist"); 34 | cy.wait(1000); // allow the websocket to reconnect 35 | cy.serverCommand("verifyUser"); 36 | cy.getCy("header-unverified-warning").should("not.exist"); 37 | }); 38 | 39 | it("can start on an already logged-in session; current user subscription works", () => { 40 | // Setup 41 | cy.login({ next: "/", verified: false }); 42 | 43 | // Subscription 44 | cy.getCy("header-unverified-warning").should("exist"); 45 | cy.wait(1000); // allow the websocket to reconnect 46 | cy.serverCommand("verifyUser"); 47 | cy.getCy("header-unverified-warning").should("not.exist"); 48 | }); 49 | 50 | it("can register; current user subscription works", () => { 51 | // Setup 52 | cy.visit(Cypress.env("ROOT_URL") + "/register"); 53 | cy.getCy("header-login-button").should("not.exist"); // No login button on register page 54 | 55 | // Action 56 | cy.getCy("registerpage-input-name").type("Test User"); 57 | cy.getCy("registerpage-input-username").type("testuser"); 58 | cy.getCy("registerpage-input-email").type("test.user@example.com"); 59 | cy.getCy("registerpage-input-password").type("Really Good Password"); 60 | cy.getCy("registerpage-input-password2").type("Really Good Password"); 61 | cy.getCy("registerpage-submit-button").click(); 62 | 63 | // Assertions 64 | cy.url().should("equal", Cypress.env("ROOT_URL") + "/"); // Should be on homepage 65 | cy.getCy("header-login-button").should("not.exist"); 66 | cy.getCy("layout-dropdown-user").should("contain", "Test User"); // Should be logged in 67 | 68 | // Subscription 69 | cy.getCy("header-unverified-warning").should("exist"); 70 | cy.wait(1000); // allow the websocket to reconnect 71 | cy.serverCommand("verifyUser"); 72 | cy.getCy("header-unverified-warning").should("not.exist"); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /@app/e2e/cypress/e2e/verify_email.cy.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context("Verify email", () => { 4 | beforeEach(() => cy.serverCommand("clearTestUsers")); 5 | it("can open verification link", () => { 6 | // Setup 7 | cy.serverCommand("createUser", { 8 | username: "testuser", 9 | }).as("createUserResult"); 10 | 11 | // Action 12 | cy.get("@createUserResult").then( 13 | ({ userEmailId, verificationToken }: any) => { 14 | const url = `${Cypress.env("ROOT_URL")}/verify?id=${encodeURIComponent( 15 | String(userEmailId) 16 | )}&token=${encodeURIComponent(verificationToken)}`; 17 | cy.visit(url); 18 | } 19 | ); 20 | 21 | // Assertion 22 | cy.contains("Email Verified").should("exist"); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /@app/e2e/cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } -------------------------------------------------------------------------------- /@app/e2e/cypress/support/e2e.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import "./commands"; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | 22 | Cypress.on("uncaught:exception", (err) => { 23 | // This error can be ignored: https://stackoverflow.com/a/50387233/2067611 24 | // > This error means that ResizeObserver was not able to deliver 25 | // > all observations within a single animation frame. 26 | // > It is benign (your site will not break). 27 | if (err.message.includes("ResizeObserver loop limit exceeded")) { 28 | return false; 29 | } 30 | }); 31 | -------------------------------------------------------------------------------- /@app/e2e/cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "strict": true, 5 | "baseUrl": "node_modules", 6 | "target": "es5", 7 | "lib": ["es5", "dom"], 8 | "types": ["cypress"] 9 | }, 10 | "include": ["**/*.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /@app/e2e/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require("../../jest.config.base")(__dirname); 2 | -------------------------------------------------------------------------------- /@app/e2e/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@app/e2e", 3 | "private": true, 4 | "version": "0.0.0", 5 | "scripts": { 6 | "cy": "cross-env NODE_OPTIONS=\"${NODE_OPTIONS:-} -r @app/config/env\" cypress", 7 | "open": "yarn cy open", 8 | "run": "yarn cy run", 9 | "test": "cross-env NODE_ENV=test NODE_OPTIONS=\"${NODE_OPTIONS:-} -r @app/config/env\" jest --passWithNoTests" 10 | }, 11 | "devDependencies": { 12 | "cross-env": "^7.0.3", 13 | "cypress": "^12.7.0", 14 | "jest": "^29.4.3", 15 | "webpack": "^5.94.0" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /@app/graphql/README.md: -------------------------------------------------------------------------------- 1 | # @app/graphql 2 | 3 | This folder contains the auto-generated types and components produced by 4 | [`graphql-code-generator`](https://github.com/dotansimha/graphql-code-generator), 5 | from the GraphQL files found inside `@app/client`. You can import them like: 6 | 7 | ```js 8 | /* 9 | * e.g. if you have `mutation DoTheThing { ... }`, then you can import the 10 | * Apollo React Hook via: 11 | */ 12 | import { useDoTheThingMutation } from "@app/graphql"; 13 | ``` 14 | -------------------------------------------------------------------------------- /@app/graphql/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require("../../babel.config"); 2 | -------------------------------------------------------------------------------- /@app/graphql/codegen.yml: -------------------------------------------------------------------------------- 1 | overwrite: true 2 | schema: "../../data/schema.graphql" 3 | documents: "../client/src/**/*.graphql" 4 | config: 5 | avoidOptionals: 6 | field: true 7 | inputValue: false 8 | object: false 9 | scalars: 10 | Datetime: "string" 11 | JSON: "{ [key: string]: any }" 12 | noGraphQLTag: false 13 | withHOC: false 14 | withComponent: false 15 | withHooks: true 16 | reactApolloVersion: 3 17 | generates: 18 | index.tsx: 19 | plugins: 20 | - add: 21 | content: "/* DO NOT EDIT! This file is auto-generated by graphql-code-generator - see `codegen.yml` */" 22 | - "typescript" 23 | - "typescript-operations" 24 | - "typescript-react-apollo" 25 | -------------------------------------------------------------------------------- /@app/graphql/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require("../../jest.config.base")(__dirname); 2 | -------------------------------------------------------------------------------- /@app/graphql/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@app/graphql", 3 | "private": true, 4 | "version": "0.0.0", 5 | "main": "index.js", 6 | "types": "index.d.ts", 7 | "scripts": { 8 | "build": "yarn codegen && tsc -b", 9 | "watch": "yarn codegen --watch", 10 | "codegen": "graphql-codegen --config codegen.yml", 11 | "test": "cross-env NODE_ENV=test NODE_OPTIONS=\"${NODE_OPTIONS:-} -r @app/config/env\" jest" 12 | }, 13 | "dependencies": { 14 | "@apollo/client": "3.4.17", 15 | "react": "^18.2.0", 16 | "tslib": "^2.5.0" 17 | }, 18 | "devDependencies": { 19 | "@graphql-codegen/add": "^4.0.1", 20 | "@graphql-codegen/cli": "^3.2.1", 21 | "@graphql-codegen/typescript": "^3.0.1", 22 | "@graphql-codegen/typescript-operations": "^3.0.1", 23 | "@graphql-codegen/typescript-react-apollo": "3.3.7", 24 | "cross-env": "^7.0.3", 25 | "jest": "^29.4.3" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /@app/graphql/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "rootDir": ".", 6 | "outDir": ".", 7 | "tsBuildInfoFile": "tsconfig.tsbuildinfo", 8 | "declarationDir": ".", 9 | "lib": ["es2018", "esnext.asynciterable"], 10 | "target": "es2018", 11 | "module": "commonjs", 12 | "jsx": "react" 13 | }, 14 | "include": ["index.tsx"] 15 | } 16 | -------------------------------------------------------------------------------- /@app/lib/README.md: -------------------------------------------------------------------------------- 1 | # @app/lib 2 | 3 | Various utilities, mostly React hooks. 4 | 5 | ## Compilation 6 | 7 | Note that these components are compiled to on-disk JS in the same way as the 8 | other packages (except client) so that Next.js can require them as if they were 9 | regular NPM dependencies (thus Next does not need to know about monorepos, or 10 | how to transpile this code). 11 | -------------------------------------------------------------------------------- /@app/lib/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require("../../babel.config"); 2 | -------------------------------------------------------------------------------- /@app/lib/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require("../../jest.config.base")(__dirname); 2 | -------------------------------------------------------------------------------- /@app/lib/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@app/lib", 3 | "version": "0.0.0", 4 | "main": "dist/index.js", 5 | "types": "dist/index.d.ts", 6 | "scripts": { 7 | "build": "tsc -b", 8 | "test": "cross-env NODE_ENV=test NODE_OPTIONS=\"${NODE_OPTIONS:-} -r @app/config/env\" jest" 9 | }, 10 | "dependencies": { 11 | "@apollo/client": "3.4.17", 12 | "graphql": "^15.8.0", 13 | "graphql-ws": "^5.11.3", 14 | "next": "^13.2.3", 15 | "next-with-apollo": "^5.3.0", 16 | "rc-field-form": "^1.27.4", 17 | "react": "^18.2.0", 18 | "react-dom": "^18.2.0", 19 | "tslib": "^2.5.0", 20 | "zxcvbn": "^4.4.2" 21 | }, 22 | "devDependencies": { 23 | "@types/express": "^4.17.17", 24 | "@types/zxcvbn": "^4.4.1", 25 | "cross-env": "^7.0.3", 26 | "express": "^4.20.0", 27 | "jest": "^29.4.3", 28 | "postgraphile": "^4.13.0", 29 | "typescript": "^5.0.0-beta" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /@app/lib/src/GraphileApolloLink.client.ts: -------------------------------------------------------------------------------- 1 | import { ApolloLink } from "@apollo/client"; 2 | export class GraphileApolloLink extends ApolloLink { 3 | constructor() { 4 | super(); 5 | throw new Error(`This should not be called on the client`); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /@app/lib/src/GraphileApolloLink.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApolloLink, 3 | FetchResult, 4 | NextLink, 5 | Observable, 6 | Operation, 7 | } from "@apollo/client"; 8 | import { Request, Response } from "express"; 9 | import { execute, getOperationAST } from "graphql"; 10 | import { HttpRequestHandler } from "postgraphile"; 11 | 12 | export interface GraphileApolloLinkInterface { 13 | /** The request object. */ 14 | req: Request; 15 | 16 | /** The response object. */ 17 | res: Response; 18 | 19 | /** The instance of the express middleware returned by calling `postgraphile()` */ 20 | postgraphileMiddleware: HttpRequestHandler; 21 | 22 | /** An optional rootValue to use inside resolvers. */ 23 | rootValue?: any; 24 | } 25 | 26 | /** 27 | * A Graphile Apollo link for use during SSR. Allows Apollo Client to resolve 28 | * server-side requests without requiring an HTTP roundtrip. 29 | */ 30 | export class GraphileApolloLink extends ApolloLink { 31 | constructor(private options: GraphileApolloLinkInterface) { 32 | super(); 33 | } 34 | 35 | request( 36 | operation: Operation, 37 | _forward?: NextLink 38 | ): Observable | null { 39 | const { postgraphileMiddleware, req, res, rootValue } = this.options; 40 | return new Observable((observer) => { 41 | (async () => { 42 | try { 43 | const { 44 | operationName, 45 | variables: variableValues, 46 | query: document, 47 | } = operation; 48 | const op = getOperationAST(document, operationName); 49 | if (!op || op.operation !== "query") { 50 | if (!observer.closed) { 51 | /* Only do queries (not subscriptions) on server side */ 52 | observer.complete(); 53 | } 54 | return; 55 | } 56 | const schema = await postgraphileMiddleware.getGraphQLSchema(); 57 | const data = 58 | await postgraphileMiddleware.withPostGraphileContextFromReqRes( 59 | req, 60 | res, 61 | {}, 62 | (context) => 63 | execute( 64 | schema, 65 | document, 66 | rootValue || {}, 67 | context, 68 | variableValues, 69 | operationName 70 | ) 71 | ); 72 | if (!observer.closed) { 73 | observer.next(data); 74 | observer.complete(); 75 | } 76 | } catch (e: any) { 77 | if (!observer.closed) { 78 | observer.error(e); 79 | } else { 80 | console.error(e); 81 | } 82 | } 83 | })(); 84 | }); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /@app/lib/src/errors.ts: -------------------------------------------------------------------------------- 1 | import { ApolloError } from "@apollo/client"; 2 | import { GraphQLError } from "graphql"; 3 | 4 | export function extractError(error: null): null; 5 | export function extractError(error: Error): Error; 6 | export function extractError(error: ApolloError): GraphQLError; 7 | export function extractError(error: GraphQLError): GraphQLError; 8 | export function extractError( 9 | error: null | Error | ApolloError | GraphQLError 10 | ): null | Error | GraphQLError; 11 | export function extractError( 12 | error: null | Error | ApolloError | GraphQLError 13 | ): null | Error | GraphQLError { 14 | return ( 15 | (error && 16 | "graphQLErrors" in error && 17 | error.graphQLErrors && 18 | error.graphQLErrors.length && 19 | error.graphQLErrors[0]) || 20 | error 21 | ); 22 | } 23 | 24 | export function getExceptionFromError( 25 | error: null | Error | ApolloError | GraphQLError 26 | ): 27 | | (Error & { 28 | code?: string; 29 | fields?: string[]; 30 | extensions?: { code?: string; fields?: string[] }; 31 | }) 32 | | null { 33 | // @ts-ignore 34 | const graphqlError: GraphQLError = extractError(error); 35 | const exception = 36 | graphqlError && 37 | graphqlError.extensions && 38 | graphqlError.extensions.exception; 39 | return (exception || graphqlError || error) as Error | null; 40 | } 41 | 42 | export function getCodeFromError( 43 | error: null | Error | ApolloError | GraphQLError 44 | ): null | string { 45 | const err = getExceptionFromError(error); 46 | return err?.extensions?.code ?? err?.code ?? null; 47 | } 48 | -------------------------------------------------------------------------------- /@app/lib/src/forms.ts: -------------------------------------------------------------------------------- 1 | export const formItemLayout = { 2 | labelCol: { 3 | xs: { span: 24 }, 4 | sm: { span: 6 }, 5 | }, 6 | wrapperCol: { 7 | xs: { span: 24 }, 8 | sm: { span: 18 }, 9 | }, 10 | }; 11 | export const tailFormItemLayout = { 12 | wrapperCol: { 13 | xs: { 14 | span: 24, 15 | offset: 0, 16 | }, 17 | sm: { 18 | span: 18, 19 | offset: 6, 20 | }, 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /@app/lib/src/index.tsx: -------------------------------------------------------------------------------- 1 | export * from "./errors"; 2 | export * from "./forms"; 3 | export * from "./passwords"; 4 | export * from "./withApollo"; 5 | -------------------------------------------------------------------------------- /@app/lib/src/passwords.ts: -------------------------------------------------------------------------------- 1 | import { Store } from "rc-field-form/lib/interface"; 2 | import zxcvbn from "zxcvbn"; 3 | 4 | interface ExpectedProps { 5 | setPasswordStrength: (score: number) => void; 6 | setPasswordSuggestions: (message: string[]) => void; 7 | } 8 | 9 | export const setPasswordInfo = ( 10 | props: ExpectedProps, 11 | changedValues: Store, 12 | fieldName = "password" 13 | ): void => { 14 | // On field change check to see if password changed 15 | if (!(fieldName in changedValues)) { 16 | return; 17 | } 18 | 19 | const value = changedValues[fieldName]; 20 | const { score, feedback } = zxcvbn(value || ""); 21 | props.setPasswordStrength(score); 22 | 23 | const messages = [...feedback.suggestions]; 24 | if (feedback.warning !== "") { 25 | messages.push(feedback.warning); 26 | } 27 | props.setPasswordSuggestions(messages); 28 | }; 29 | -------------------------------------------------------------------------------- /@app/lib/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "rootDir": "src", 6 | "outDir": "dist", 7 | "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo", 8 | "declarationDir": "dist", 9 | "lib": ["dom", "dom.iterable", "esnext"], 10 | "target": "es5", 11 | "module": "commonjs", 12 | "jsx": "react" 13 | }, 14 | "include": ["src/**/*.ts", "src/**/*.tsx"] 15 | } 16 | -------------------------------------------------------------------------------- /@app/server/README.md: -------------------------------------------------------------------------------- 1 | # @app/server 2 | 3 | The server is responsible for: 4 | 5 | - authentication (via [Passport](http://www.passportjs.org/)) 6 | - serving the GraphQL endpoint (via 7 | [PostGraphile](https://graphile.org/postgraphile/), based on database in 8 | `@app/db`) 9 | - server-side rendering (SSR) of the `@app/client` thanks to 10 | [Next.js](https://nextjs.org/) 11 | 12 | The server does not perform background tasks such as sending emails, that is the 13 | responsibility of the job queue, which can be found in 14 | [@app/worker](../worker/README.md). 15 | 16 | ## Entry point 17 | 18 | The entry point to the server is [src/index.ts](src/index.ts). This file sets up 19 | an HTTP server and installs our express app (defined in 20 | [src/app.ts](src/app.ts)) into it. The express app installs the middleware it 21 | needs from the src/middleware/\*.ts files. 22 | 23 | ## Express getters 24 | 25 | Commonly-used values are stored into the Express app itself, using Express' 26 | `app.set(key, value)` API. Unfortunately this API does not allow for type-safe 27 | retrieval, so we encourage the use of custom typed getters to retrieve the 28 | relevant values, for example: 29 | `function getHttpServer(app: Express): Server | void { return app.get("httpServer"); }` 30 | 31 | ## PostGraphile smart tags 32 | 33 | `postgraphile.tags.jsonc` is a 34 | [smart tags file](https://www.graphile.org/postgraphile/smart-tags-file/) used 35 | to configure and shape our resulting GraphQL schema. The file is documented 36 | (hence JSONC); we'd like it to be JSON5 eventually (which should be as simple as 37 | renaming the file and having prettier reformat it for us) but VSCode does not 38 | have built-in support for JSON5 currently. 39 | 40 | ## PostGraphile plugins 41 | 42 | Our GraphQL schema uses a number of plugins for enhancements and customizations; 43 | these can be found in [src/plugins](./src/plugins). 44 | 45 | ## shutdownActions 46 | 47 | In development we restart the server quite frequently. We don't want the server 48 | to fail to start when the previous one was killed, so we maintain a list of 49 | `shutdownActions` to cleanly close servers, sockets, files and the like. These 50 | actions are called automatically when the process exits, or is interrupted with 51 | certain signals such as SIGINT from Ctrl-c. 52 | -------------------------------------------------------------------------------- /@app/server/__tests__/mutations/__snapshots__/register.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Register 2`] = ` 4 | { 5 | "data": { 6 | "register": { 7 | "user": { 8 | "avatarUrl": null, 9 | "createdAt": "[timestamp-1]", 10 | "id": "[id-1]", 11 | "isAdmin": false, 12 | "isVerified": false, 13 | "name": "Test User", 14 | "updatedAt": "[timestamp-1]", 15 | "username": "[username-1]", 16 | }, 17 | }, 18 | }, 19 | } 20 | `; 21 | -------------------------------------------------------------------------------- /@app/server/__tests__/mutations/register.test.ts: -------------------------------------------------------------------------------- 1 | import { asRoot } from "../../../__tests__/helpers"; 2 | import { 3 | deleteTestUsers, 4 | runGraphQLQuery, 5 | sanitize, 6 | setup, 7 | teardown, 8 | } from "../helpers"; 9 | 10 | beforeEach(deleteTestUsers); 11 | beforeAll(setup); 12 | afterAll(teardown); 13 | 14 | test("Register", async () => { 15 | await runGraphQLQuery( 16 | // GraphQL query goes here: 17 | `mutation Register($username: String!, $password: String!, $name: String!, $email: String!) { 18 | register( 19 | input: { 20 | username: $username 21 | password: $password 22 | name: $name 23 | email: $email 24 | } 25 | ) { 26 | user { 27 | id 28 | name 29 | avatarUrl 30 | createdAt 31 | isAdmin 32 | isVerified 33 | updatedAt 34 | username 35 | } 36 | } 37 | } 38 | `, 39 | 40 | // GraphQL variables: 41 | { 42 | username: "testuser", 43 | password: "SECURE_PASSWORD", 44 | name: "Test User", 45 | email: "test.user@example.org", 46 | }, 47 | 48 | // Additional props to add to `req` (e.g. `user: {session_id: '...'}`) 49 | { 50 | login: jest.fn((_user, cb) => process.nextTick(cb)), 51 | }, 52 | 53 | // This function runs all your test assertions: 54 | async (json, { pgClient }) => { 55 | expect(json.errors).toBeFalsy(); 56 | expect(json.data).toBeTruthy(); 57 | expect(json.data!.register).toBeTruthy(); 58 | expect(json.data!.register.user).toBeTruthy(); 59 | expect(sanitize(json.data!.register.user)).toMatchInlineSnapshot(` 60 | { 61 | "avatarUrl": null, 62 | "createdAt": "[timestamp-1]", 63 | "id": "[id-1]", 64 | "isAdmin": false, 65 | "isVerified": false, 66 | "name": "Test User", 67 | "updatedAt": "[timestamp-1]", 68 | "username": "[username-1]", 69 | } 70 | `); 71 | const id = json.data!.register.user.id; 72 | 73 | // If you need to, you can query the DB within the context of this 74 | // function - e.g. to check that your mutation made the changes you'd 75 | // expect. 76 | const { rows } = await asRoot(pgClient, () => 77 | pgClient.query(`SELECT * FROM app_public.users WHERE id = $1`, [id]) 78 | ); 79 | if (rows.length !== 1) { 80 | throw new Error("User not found!"); 81 | } 82 | expect(rows[0].username).toEqual(json.data!.register.user.username); 83 | } 84 | ); 85 | }); 86 | -------------------------------------------------------------------------------- /@app/server/__tests__/queries/__snapshots__/currentUser.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`currentUser when logged in 1`] = ` 4 | { 5 | "data": { 6 | "currentUser": { 7 | "id": "[id-1]", 8 | }, 9 | }, 10 | } 11 | `; 12 | 13 | exports[`currentUser when logged out 1`] = ` 14 | { 15 | "data": { 16 | "currentUser": null, 17 | }, 18 | } 19 | `; 20 | -------------------------------------------------------------------------------- /@app/server/__tests__/queries/currentUser.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createUserAndLogIn, 3 | deleteTestUsers, 4 | runGraphQLQuery, 5 | setup, 6 | teardown, 7 | } from "../helpers"; 8 | 9 | beforeEach(deleteTestUsers); 10 | beforeAll(setup); 11 | afterAll(teardown); 12 | 13 | test("currentUser when logged out", async () => { 14 | await runGraphQLQuery( 15 | // GraphQL query goes here: 16 | `{currentUser{id}}`, 17 | 18 | // GraphQL variables: 19 | {}, 20 | 21 | // Additional props to add to `req` (e.g. `user: {session_id: '...'}`) 22 | { 23 | user: null, 24 | }, 25 | 26 | // This function runs all your test assertions: 27 | async (json) => { 28 | expect(json.errors).toBeFalsy(); 29 | expect(json.data).toBeTruthy(); 30 | expect(json.data!.currentUser).toBe(null); 31 | } 32 | ); 33 | }); 34 | 35 | test("currentUser when logged in", async () => { 36 | const { user, session } = await createUserAndLogIn(); 37 | await runGraphQLQuery( 38 | // GraphQL query goes here: 39 | `{currentUser{id}}`, 40 | 41 | // GraphQL variables: 42 | {}, 43 | 44 | // Additional props to add to `req` (e.g. `user: {session_id: '...'}`) 45 | { 46 | user: { session_id: session.uuid }, 47 | }, 48 | 49 | // This function runs all your test assertions: 50 | async (json) => { 51 | expect(json.errors).toBeFalsy(); 52 | expect(json.data).toBeTruthy(); 53 | expect(json.data!.currentUser).toMatchObject({ 54 | id: user.id, 55 | }); 56 | } 57 | ); 58 | }); 59 | -------------------------------------------------------------------------------- /@app/server/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require("../../babel.config"); 2 | -------------------------------------------------------------------------------- /@app/server/error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Error! 8 | 59 | 60 | 61 |
62 |

An error occurred

63 |

64 | <%= message %> 65 |

66 |

Visit our home page or get in touch with our team.

67 |
68 | 69 | 70 | -------------------------------------------------------------------------------- /@app/server/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require("../../jest.config.base")(__dirname); 2 | -------------------------------------------------------------------------------- /@app/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@app/server", 3 | "private": true, 4 | "version": "0.0.0", 5 | "scripts": { 6 | "build": "tsc -b", 7 | "start": "node -r @app/config/env dist/index.js", 8 | "dev": "nodemon --signal SIGINT --watch 'dist/**/*.js' -x \"node --inspect=9678 -r @app/config/env -r source-map-support/register\" dist/index.js", 9 | "schema:export": "ts-node -r @app/config/env scripts/schema-export.ts", 10 | "cloudflare:import": "(echo \"export const cloudflareIps: string[] = [\"; (curl -Ls https://www.cloudflare.com/ips-v4 | sort | sed -e \"s/^/ \\\"/\" -e \"s/$/\\\",/\"); echo \"];\") > src/cloudflare.ts", 11 | "test": "cross-env NODE_ENV=test NODE_OPTIONS=\"${NODE_OPTIONS:-} -r @app/config/env\" jest" 12 | }, 13 | "dependencies": { 14 | "@app/client": "0.0.0", 15 | "@app/config": "0.0.0", 16 | "@graphile-contrib/pg-simplify-inflector": "^6.1.0", 17 | "@graphile/pg-pubsub": "^4.13.0", 18 | "@graphile/pro": "^1.0.4", 19 | "@types/connect-pg-simple": "^7.0.0", 20 | "@types/csurf": "^1.11.2", 21 | "@types/express-session": "^1.17.6", 22 | "@types/helmet": "^4.0.0", 23 | "@types/morgan": "^1.9.4", 24 | "@types/passport": "^1.0.12", 25 | "@types/passport-github2": "^1.2.5", 26 | "@types/pg": "^8.6.6", 27 | "@types/redis": "^4.0.11", 28 | "body-parser": "^1.20.3", 29 | "chalk": "^5.2.0", 30 | "connect-pg-simple": "^8.0.0", 31 | "connect-redis": "^7.0.0", 32 | "csurf": "^1.11.0", 33 | "express": "^4.20.0", 34 | "express-session": "^1.17.3", 35 | "graphile-build": "^4.13.0", 36 | "graphile-build-pg": "^4.13.0", 37 | "graphile-utils": "^4.13.0", 38 | "graphile-worker": "^0.13.0", 39 | "helmet": "^6.0.1", 40 | "lodash": "^4.17.21", 41 | "morgan": "^1.10.0", 42 | "next": "^13.2.3", 43 | "passport": "^0.6.0", 44 | "passport-github2": "^0.1.12", 45 | "pg": "^8.9.0", 46 | "postgraphile": "^4.13.0", 47 | "redis": "^4.6.5", 48 | "source-map-support": "^0.5.21", 49 | "tslib": "^2.5.0" 50 | }, 51 | "devDependencies": { 52 | "@types/node": "^18.14.2", 53 | "graphql": "^15.8.0", 54 | "jest": "^29.4.3", 55 | "mock-req": "^0.2.0", 56 | "ts-node": "^10.9.1" 57 | }, 58 | "optionalDependencies": { 59 | "bufferutil": "^4.0.7", 60 | "utf-8-validate": "^6.0.3" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /@app/server/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graphile/starter/0b68eabc8ed414029594f40ca7bdcd8b46e17cb6/@app/server/public/favicon.ico -------------------------------------------------------------------------------- /@app/server/scripts/schema-export.ts: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from "fs"; 2 | import { lexicographicSortSchema, printSchema } from "graphql"; 3 | import { Pool } from "pg"; 4 | import { createPostGraphileSchema } from "postgraphile"; 5 | 6 | import { getPostGraphileOptions } from "../src/graphile.config"; 7 | 8 | async function main() { 9 | const rootPgPool = new Pool({ 10 | connectionString: process.env.DATABASE_URL!, 11 | }); 12 | try { 13 | const schema = await createPostGraphileSchema( 14 | process.env.AUTH_DATABASE_URL!, 15 | "app_public", 16 | getPostGraphileOptions({ rootPgPool }) 17 | ); 18 | const sorted = lexicographicSortSchema(schema); 19 | writeFileSync( 20 | `${__dirname}/../../../data/schema.graphql`, 21 | printSchema(sorted) 22 | ); 23 | console.log("GraphQL schema exported"); 24 | } finally { 25 | rootPgPool.end(); 26 | } 27 | } 28 | 29 | main().catch((e) => { 30 | console.error(e); 31 | process.exit(1); 32 | }); 33 | -------------------------------------------------------------------------------- /@app/server/src/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: `${__dirname}/../../../.eslintrc.js`, 3 | rules: { 4 | /* 5 | * Server side we won't be validating against the schema (because we're 6 | * defining it!) 7 | */ 8 | "graphql/template-strings": 0, 9 | "graphql/named-operations": 0, 10 | "graphql/required-fields": 0, 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /@app/server/src/cloudflare.ts: -------------------------------------------------------------------------------- 1 | export const cloudflareIps: string[] = [ 2 | "103.21.244.0/22", 3 | "103.22.200.0/22", 4 | "103.31.4.0/22", 5 | "104.16.0.0/13", 6 | "104.24.0.0/14", 7 | "108.162.192.0/18", 8 | "131.0.72.0/22", 9 | "141.101.64.0/18", 10 | "162.158.0.0/15", 11 | "172.64.0.0/13", 12 | "173.245.48.0/20", 13 | "188.114.96.0/20", 14 | "190.93.240.0/20", 15 | "197.234.240.0/22", 16 | "198.41.128.0/17", 17 | ]; 18 | -------------------------------------------------------------------------------- /@app/server/src/fs.ts: -------------------------------------------------------------------------------- 1 | import { promises as fsp } from "fs"; 2 | 3 | const { utimes, open } = fsp; 4 | 5 | export const touch = async (filepath: string): Promise => { 6 | try { 7 | const time = new Date(); 8 | await utimes(filepath, time, time); 9 | } catch (err) { 10 | const filehandle = await open(filepath, "w"); 11 | await filehandle.close(); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /@app/server/src/middleware/index.ts: -------------------------------------------------------------------------------- 1 | import installCSRFProtection from "./installCSRFProtection"; 2 | import installCypressServerCommand from "./installCypressServerCommand"; 3 | import installDatabasePools from "./installDatabasePools"; 4 | import installErrorHandler from "./installErrorHandler"; 5 | import installForceSSL from "./installForceSSL"; 6 | import installHelmet from "./installHelmet"; 7 | import installLogging from "./installLogging"; 8 | import installPassport from "./installPassport"; 9 | import installPostGraphile from "./installPostGraphile"; 10 | import installSameOrigin from "./installSameOrigin"; 11 | import installSession from "./installSession"; 12 | import installSharedStatic from "./installSharedStatic"; 13 | import installSSR from "./installSSR"; 14 | import installWorkerUtils from "./installWorkerUtils"; 15 | 16 | export { 17 | installCSRFProtection, 18 | installCypressServerCommand, 19 | installDatabasePools, 20 | installErrorHandler, 21 | installForceSSL, 22 | installHelmet, 23 | installLogging, 24 | installPassport, 25 | installPostGraphile, 26 | installSameOrigin, 27 | installSession, 28 | installSharedStatic, 29 | installSSR, 30 | installWorkerUtils, 31 | }; 32 | -------------------------------------------------------------------------------- /@app/server/src/middleware/installCSRFProtection.ts: -------------------------------------------------------------------------------- 1 | import csrf from "csurf"; 2 | import { Express } from "express"; 3 | 4 | export default (app: Express) => { 5 | const csrfProtection = csrf({ 6 | // Store to the session rather than a Cookie 7 | cookie: false, 8 | 9 | // Extract the CSRF Token from the `CSRF-Token` header. 10 | value(req) { 11 | const csrfToken = req.headers["csrf-token"]; 12 | return typeof csrfToken === "string" ? csrfToken : ""; 13 | }, 14 | }); 15 | 16 | app.use((req, res, next) => { 17 | if ( 18 | req.method === "POST" && 19 | req.path === "/graphql" && 20 | (req.headers.referer === `${process.env.ROOT_URL}/graphiql` || 21 | req.headers.origin === process.env.ROOT_URL) 22 | ) { 23 | // Bypass CSRF for GraphiQL 24 | next(); 25 | } else { 26 | csrfProtection(req, res, next); 27 | } 28 | }); 29 | }; 30 | -------------------------------------------------------------------------------- /@app/server/src/middleware/installDatabasePools.ts: -------------------------------------------------------------------------------- 1 | import { Express } from "express"; 2 | import { Pool } from "pg"; 3 | 4 | import { getShutdownActions } from "../app"; 5 | 6 | export function getRootPgPool(app: Express): Pool { 7 | return app.get("rootPgPool"); 8 | } 9 | export function getAuthPgPool(app: Express): Pool { 10 | return app.get("authPgPool"); 11 | } 12 | 13 | /** 14 | * When a PoolClient omits an 'error' event that cannot be caught by a promise 15 | * chain (e.g. when the PostgreSQL server terminates the link but the client 16 | * isn't actively being used) the error is raised via the Pool. In Node.js if 17 | * an 'error' event is raised and it isn't handled, the entire process exits. 18 | * This NOOP handler avoids this occurring on our pools. 19 | * 20 | * TODO: log this to an error reporting service. 21 | */ 22 | function swallowPoolError(_error: Error) { 23 | /* noop */ 24 | } 25 | 26 | export default (app: Express) => { 27 | // This pool runs as the database owner, so it can do anything. 28 | const rootPgPool = new Pool({ 29 | connectionString: process.env.DATABASE_URL, 30 | }); 31 | rootPgPool.on("error", swallowPoolError); 32 | app.set("rootPgPool", rootPgPool); 33 | 34 | // This pool runs as the unprivileged user, it's what PostGraphile uses. 35 | const authPgPool = new Pool({ 36 | connectionString: process.env.AUTH_DATABASE_URL, 37 | }); 38 | authPgPool.on("error", swallowPoolError); 39 | app.set("authPgPool", authPgPool); 40 | 41 | const shutdownActions = getShutdownActions(app); 42 | shutdownActions.push(() => { 43 | rootPgPool.end(); 44 | }); 45 | shutdownActions.push(() => { 46 | authPgPool.end(); 47 | }); 48 | }; 49 | -------------------------------------------------------------------------------- /@app/server/src/middleware/installForceSSL.ts: -------------------------------------------------------------------------------- 1 | import { Express } from "express"; 2 | 3 | export default (app: Express) => { 4 | if (!process.env.ROOT_URL || !process.env.ROOT_URL.startsWith("https://")) { 5 | throw new Error( 6 | "Invalid configuration - FORCE_SSL is enabled, but ROOT_URL doesn't start with https://" 7 | ); 8 | } 9 | app.use((req, res, next) => { 10 | if (req.protocol !== "https") { 11 | if (req.method === "GET" || req.method === "HEAD") { 12 | res.redirect(`${process.env.ROOT_URL}${req.path}`); 13 | } else { 14 | res 15 | .status(405) 16 | .send(`'${req.method}' requests may only be performed over HTTPS.`); 17 | } 18 | } else { 19 | next(); 20 | } 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /@app/server/src/middleware/installHelmet.ts: -------------------------------------------------------------------------------- 1 | import { Express } from "express"; 2 | import type { HelmetOptions } from "helmet" assert { "resolution-mode": "import" }; 3 | 4 | const tmpRootUrl = process.env.ROOT_URL; 5 | 6 | if (!tmpRootUrl || typeof tmpRootUrl !== "string") { 7 | throw new Error("Envvar ROOT_URL is required."); 8 | } 9 | const ROOT_URL = tmpRootUrl; 10 | 11 | const isDevOrTest = 12 | process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test"; 13 | 14 | export default async function installHelmet(app: Express) { 15 | const { default: helmet, contentSecurityPolicy } = await import("helmet"); 16 | 17 | const options: HelmetOptions = { 18 | contentSecurityPolicy: { 19 | directives: { 20 | ...contentSecurityPolicy.getDefaultDirectives(), 21 | "connect-src": [ 22 | "'self'", 23 | // Safari doesn't allow using wss:// origins as 'self' from 24 | // an https:// page, so we have to translate explicitly for 25 | // it. 26 | ROOT_URL.replace(/^http/, "ws"), 27 | ], 28 | }, 29 | }, 30 | }; 31 | if (isDevOrTest) { 32 | // Appease TypeScript 33 | if ( 34 | typeof options.contentSecurityPolicy === "boolean" || 35 | !options.contentSecurityPolicy 36 | ) { 37 | throw new Error(`contentSecurityPolicy must be an object`); 38 | } 39 | // Dev needs 'unsafe-eval' due to 40 | // https://github.com/vercel/next.js/issues/14221 41 | options.contentSecurityPolicy.directives!["script-src"] = [ 42 | "'self'", 43 | "'unsafe-eval'", 44 | ]; 45 | } 46 | if (isDevOrTest || !!process.env.ENABLE_GRAPHIQL) { 47 | // Enables prettier script and SVG icon in GraphiQL 48 | options.crossOriginEmbedderPolicy = false; 49 | } 50 | app.use(helmet(options)); 51 | } 52 | -------------------------------------------------------------------------------- /@app/server/src/middleware/installLogging.ts: -------------------------------------------------------------------------------- 1 | import { Express } from "express"; 2 | import morgan from "morgan"; 3 | 4 | const isDev = process.env.NODE_ENV === "development"; 5 | 6 | export default (app: Express) => { 7 | if (isDev) { 8 | // To enable logging on development, uncomment the next line: 9 | // app.use(morgan("tiny")); 10 | } else { 11 | app.use(morgan(isDev ? "tiny" : "combined")); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /@app/server/src/middleware/installPassport.ts: -------------------------------------------------------------------------------- 1 | import { Express } from "express"; 2 | import { get } from "lodash"; 3 | import passport from "passport"; 4 | import { Strategy as GitHubStrategy } from "passport-github2"; 5 | 6 | import { getWebsocketMiddlewares } from "../app"; 7 | import installPassportStrategy from "./installPassportStrategy"; 8 | 9 | interface DbSession { 10 | session_id: string; 11 | } 12 | 13 | export default async (app: Express) => { 14 | passport.serializeUser((sessionObject: DbSession, done) => { 15 | done(null, sessionObject.session_id); 16 | }); 17 | 18 | passport.deserializeUser((session_id: string, done) => { 19 | done(null, { session_id }); 20 | }); 21 | 22 | const passportInitializeMiddleware = passport.initialize(); 23 | app.use(passportInitializeMiddleware); 24 | getWebsocketMiddlewares(app).push(passportInitializeMiddleware); 25 | 26 | const passportSessionMiddleware = passport.session(); 27 | app.use(passportSessionMiddleware); 28 | getWebsocketMiddlewares(app).push(passportSessionMiddleware); 29 | 30 | app.get("/logout", (req, res) => { 31 | req.logout(() => { 32 | res.redirect("/"); 33 | }); 34 | }); 35 | 36 | if (process.env.GITHUB_KEY) { 37 | await installPassportStrategy( 38 | app, 39 | "github", 40 | GitHubStrategy, 41 | { 42 | clientID: process.env.GITHUB_KEY, 43 | clientSecret: process.env.GITHUB_SECRET, 44 | scope: ["user:email"], 45 | }, 46 | {}, 47 | async (profile, _accessToken, _refreshToken, _extra, _req) => ({ 48 | id: profile.id, 49 | displayName: profile.displayName || "", 50 | username: profile.username, 51 | avatarUrl: get(profile, "photos.0.value"), 52 | email: profile.email || get(profile, "emails.0.value"), 53 | }), 54 | ["token", "tokenSecret"] 55 | ); 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /@app/server/src/middleware/installPostGraphile.ts: -------------------------------------------------------------------------------- 1 | import { Express, Request, Response } from "express"; 2 | import { createServer } from "http"; 3 | import { enhanceHttpServerWithSubscriptions, postgraphile } from "postgraphile"; 4 | 5 | import { 6 | getHttpServer, 7 | getUpgradeHandlers, 8 | getWebsocketMiddlewares, 9 | } from "../app"; 10 | import { getPostGraphileOptions } from "../graphile.config"; 11 | import { getAuthPgPool, getRootPgPool } from "./installDatabasePools"; 12 | 13 | export default async function installPostGraphile(app: Express) { 14 | const websocketMiddlewares = getWebsocketMiddlewares(app); 15 | const authPgPool = getAuthPgPool(app); 16 | const rootPgPool = getRootPgPool(app); 17 | const httpServer = getHttpServer(app); 18 | // Forbid PostGraphile from adding websocket listeners to httpServer 19 | (httpServer as any)["__postgraphileSubscriptionsEnabled"] = true; 20 | const middleware = postgraphile( 21 | authPgPool, 22 | "app_public", 23 | getPostGraphileOptions({ 24 | websocketMiddlewares, 25 | rootPgPool, 26 | }) 27 | ); 28 | 29 | app.set("postgraphileMiddleware", middleware); 30 | 31 | app.use(middleware); 32 | 33 | // Extract the upgrade handler from PostGraphile so we can mix it with 34 | // other upgrade handlers. 35 | const fakeHttpServer = createServer(); 36 | await enhanceHttpServerWithSubscriptions(fakeHttpServer, middleware); 37 | const postgraphileUpgradeHandler = fakeHttpServer.listeners( 38 | "upgrade" 39 | )[0] as any; 40 | // Prevent PostGraphile registering its websocket handler 41 | 42 | // Now handle websockets 43 | if (postgraphileUpgradeHandler) { 44 | const upgradeHandlers = getUpgradeHandlers(app); 45 | upgradeHandlers.push({ 46 | name: "PostGraphile", 47 | check(req) { 48 | return ( 49 | (req.url === "/graphql" || req.url?.startsWith("/graphql?")) ?? false 50 | ); 51 | }, 52 | upgrade: postgraphileUpgradeHandler, 53 | }); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /@app/server/src/middleware/installSSR.ts: -------------------------------------------------------------------------------- 1 | import { Express } from "express"; 2 | import { createServer } from "http"; 3 | import next from "next"; 4 | import { parse } from "url"; 5 | 6 | import { getUpgradeHandlers } from "../app"; 7 | 8 | if (!process.env.NODE_ENV) { 9 | throw new Error("No NODE_ENV envvar! Try `export NODE_ENV=development`"); 10 | } 11 | 12 | const isDev = process.env.NODE_ENV === "development"; 13 | 14 | export default async function installSSR(app: Express) { 15 | const fakeHttpServer = createServer(); 16 | const nextApp = next({ 17 | dev: isDev, 18 | dir: `${__dirname}/../../../client/src`, 19 | quiet: !isDev, 20 | // Don't specify 'conf' key 21 | 22 | // Trick Next.js into adding its upgrade handler here, so we can extract 23 | // it. Calling `getUpgradeHandler()` is insufficient because that doesn't 24 | // handle the assets. 25 | httpServer: fakeHttpServer, 26 | }); 27 | const handlerPromise = (async () => { 28 | await nextApp.prepare(); 29 | return nextApp.getRequestHandler(); 30 | })(); 31 | handlerPromise.catch((e) => { 32 | console.error("Error occurred starting Next.js; aborting process"); 33 | console.error(e); 34 | process.exit(1); 35 | }); 36 | app.get("*", async (req, res) => { 37 | const handler = await handlerPromise; 38 | const parsedUrl = parse(req.url, true); 39 | handler(req, res, { 40 | ...parsedUrl, 41 | query: { 42 | ...parsedUrl.query, 43 | CSRF_TOKEN: req.csrfToken(), 44 | // See 'next.config.js': 45 | ROOT_URL: process.env.ROOT_URL || "http://localhost:5678", 46 | T_AND_C_URL: process.env.T_AND_C_URL, 47 | }, 48 | }); 49 | }); 50 | 51 | // Now handle websockets 52 | if (!(nextApp as any).getServer) { 53 | console.warn( 54 | `Our Next.js workaround for getting the upgrade handler without giving Next.js dominion over all websockets might no longer work - nextApp.getServer (private API) is no more.` 55 | ); 56 | } else { 57 | await (nextApp as any).getServer(); 58 | } 59 | const nextJsUpgradeHandler = fakeHttpServer.listeners("upgrade")[0] as any; 60 | if (nextJsUpgradeHandler) { 61 | const upgradeHandlers = getUpgradeHandlers(app); 62 | upgradeHandlers.push({ 63 | name: "Next.js", 64 | check(req) { 65 | return req.url?.includes("/_next/") ?? false; 66 | }, 67 | upgrade: nextJsUpgradeHandler, 68 | }); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /@app/server/src/middleware/installSameOrigin.ts: -------------------------------------------------------------------------------- 1 | import { Express, RequestHandler } from "express"; 2 | 3 | import { getWebsocketMiddlewares } from "../app"; 4 | 5 | declare module "express-serve-static-core" { 6 | interface Request { 7 | /** 8 | * True if either the request 'Origin' header matches our ROOT_URL, or if 9 | * there was no 'Origin' header (in which case we must give the benefit of 10 | * the doubt; for example for normal resource GETs). 11 | */ 12 | isSameOrigin?: boolean; 13 | } 14 | } 15 | 16 | export default (app: Express) => { 17 | const middleware: RequestHandler = (req, res, next) => { 18 | req.isSameOrigin = 19 | !req.headers.origin || req.headers.origin === process.env.ROOT_URL; 20 | next(); 21 | }; 22 | app.use(middleware); 23 | getWebsocketMiddlewares(app).push(middleware); 24 | }; 25 | -------------------------------------------------------------------------------- /@app/server/src/middleware/installSharedStatic.ts: -------------------------------------------------------------------------------- 1 | import { Express, static as staticMiddleware } from "express"; 2 | 3 | export default (app: Express) => { 4 | app.use(staticMiddleware(`${__dirname}/../../public`)); 5 | }; 6 | -------------------------------------------------------------------------------- /@app/server/src/middleware/installWorkerUtils.ts: -------------------------------------------------------------------------------- 1 | import { Express } from "express"; 2 | import { makeWorkerUtils, WorkerUtils } from "graphile-worker"; 3 | 4 | import { getRootPgPool } from "./installDatabasePools"; 5 | 6 | export function getWorkerUtils(app: Express): WorkerUtils { 7 | return app.get("workerUtils"); 8 | } 9 | 10 | export default async (app: Express) => { 11 | const workerUtils = await makeWorkerUtils({ 12 | pgPool: getRootPgPool(app), 13 | }); 14 | 15 | app.set("workerUtils", workerUtils); 16 | }; 17 | -------------------------------------------------------------------------------- /@app/server/src/plugins/Orders.ts: -------------------------------------------------------------------------------- 1 | import { makeAddPgTableOrderByPlugin, orderByAscDesc } from "graphile-utils"; 2 | 3 | export default makeAddPgTableOrderByPlugin( 4 | "app_public", 5 | "organization_memberships", 6 | ({ pgSql: sql }) => { 7 | const sqlIdentifier = sql.identifier(Symbol("member")); 8 | return orderByAscDesc( 9 | "MEMBER_NAME", 10 | // @ts-ignore 11 | ({ queryBuilder }) => sql.fragment`( 12 | select ${sqlIdentifier}.name 13 | from app_public.users as ${sqlIdentifier} 14 | where ${sqlIdentifier}.id = ${queryBuilder.getTableAlias()}.user_id 15 | limit 1 16 | )` 17 | ); 18 | } 19 | ); 20 | -------------------------------------------------------------------------------- /@app/server/src/plugins/PrimaryKeyMutationsOnlyPlugin.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "graphile-build"; 2 | 3 | type PgConstraint = any; 4 | 5 | const PrimaryKeyMutationsOnlyPlugin: Plugin = (builder) => { 6 | builder.hook( 7 | "build", 8 | (build) => { 9 | build.pgIntrospectionResultsByKind.constraint.forEach( 10 | (constraint: PgConstraint) => { 11 | if (!constraint.tags.omit && constraint.type !== "p") { 12 | constraint.tags.omit = ["update", "delete"]; 13 | } 14 | } 15 | ); 16 | return build; 17 | }, 18 | [], 19 | [], 20 | ["PgIntrospection"] 21 | ); 22 | }; 23 | 24 | export default PrimaryKeyMutationsOnlyPlugin; 25 | -------------------------------------------------------------------------------- /@app/server/src/plugins/RemoveQueryQueryPlugin.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "postgraphile"; 2 | 3 | const RemoveQueryQueryPlugin: Plugin = (builder) => { 4 | builder.hook("GraphQLObjectType:fields", (fields, build, context) => { 5 | if (context.scope.isRootQuery) { 6 | delete fields.query; 7 | } 8 | return fields; 9 | }); 10 | }; 11 | 12 | export default RemoveQueryQueryPlugin; 13 | -------------------------------------------------------------------------------- /@app/server/src/shutdownActions.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * For a clean nodemon shutdown, we need to close all our sockets/etc otherwise 3 | * we might not come up cleanly again (inside nodemon). 4 | */ 5 | 6 | export type ShutdownAction = () => void | Promise; 7 | 8 | function ignore() {} 9 | 10 | export function makeShutdownActions(): ShutdownAction[] { 11 | const shutdownActions: ShutdownAction[] = []; 12 | 13 | let shutdownActionsCalled = false; 14 | function callShutdownActions(): Array | void> { 15 | if (shutdownActionsCalled) { 16 | return []; 17 | } 18 | shutdownActionsCalled = true; 19 | return shutdownActions.map((fn) => { 20 | // Ensure that all actions are called, even if a previous action throws an error 21 | try { 22 | return fn(); 23 | } catch (e: any) { 24 | return Promise.reject(e); 25 | } 26 | }); 27 | } 28 | function gracefulShutdown(callback: () => void) { 29 | const promises = callShutdownActions(); 30 | if (promises.length === 0) { 31 | return callback(); 32 | } 33 | 34 | let called = false; 35 | function callbackOnce() { 36 | if (!called) { 37 | called = true; 38 | callback(); 39 | } 40 | } 41 | 42 | // Guarantee the callback will be called 43 | const guaranteeCallback = setTimeout(callbackOnce, 3000); 44 | guaranteeCallback.unref(); 45 | 46 | Promise.all(promises).then(callbackOnce, callbackOnce); 47 | } 48 | 49 | process.once("SIGINT", () => { 50 | // Ignore further SIGINT signals whilst we're processing 51 | process.on("SIGINT", ignore); 52 | gracefulShutdown(() => { 53 | process.kill(process.pid, "SIGINT"); 54 | process.exit(1); 55 | }); 56 | }); 57 | 58 | process.once("exit", () => { 59 | callShutdownActions(); 60 | }); 61 | 62 | return shutdownActions; 63 | } 64 | -------------------------------------------------------------------------------- /@app/server/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export function sanitizeEnv() { 2 | const requiredEnvvars = [ 3 | "AUTH_DATABASE_URL", 4 | "DATABASE_URL", 5 | "SECRET", 6 | "NODE_ENV", 7 | ]; 8 | requiredEnvvars.forEach((envvar) => { 9 | if (!process.env[envvar]) { 10 | throw new Error( 11 | `Could not find process.env.${envvar} - did you remember to run the setup script? Have you sourced the environmental variables file '.env'?` 12 | ); 13 | } 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /@app/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "rootDir": "src", 6 | "outDir": "dist", 7 | "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo", 8 | "declarationDir": "dist", 9 | "lib": ["es2018", "esnext.asynciterable"], 10 | "target": "es2018", 11 | "module": "commonjs" 12 | }, 13 | "include": ["src"], 14 | "references": [{ "path": "../config" }], 15 | "ts-node": { 16 | "compilerOptions": { 17 | "rootDir": null 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /@app/worker/README.md: -------------------------------------------------------------------------------- 1 | # @app/worker 2 | 3 | It's bad practice to perform unnecessary actions during the normal request cycle 4 | as it makes our web app seem slower. It's also bad practice to have a user retry 5 | an action a few seconds later that we could handle easily on the backend for 6 | them. For this reason, we use a job queue to offload this work that does not 7 | have to run synchronously. 8 | 9 | Examples of things you may want to run in a job queue: 10 | 11 | - sending emails 12 | - building a complex report 13 | - resizing images 14 | - fetching data from potentially slow sources 15 | 16 | ## Graphile Worker 17 | 18 | Our job queue is [Graphile Worker](https://github.com/graphile/worker) which 19 | focusses on simplicity and performance. There are many other job queues, and you 20 | may find that it makes sense for your team to switch out Graphile Worker with 21 | one of those should you prefer. 22 | 23 | It's recommended you 24 | [read the Graphile Worker README](https://github.com/graphile/worker) before 25 | writing your own tasks. 26 | 27 | ## Modularity 28 | 29 | Each "task" in the job queue should only perform one small action; this enables 30 | that small action to be retried on error without causing unforeseen 31 | consequences. For this reason, it's not uncommon for tasks to schedule other 32 | tasks to run; for example if you were to email a user a report you might have 33 | one task that generates and stores the report, and another task that's 34 | responsible for emailing it. All our jobs follow this pattern, so all emails 35 | sent go via the central [send_email](src/tasks/send_email.ts) task. 36 | 37 | ## Cost cutting 38 | 39 | It's intended that the worker and the server run and scale independently; 40 | however if you want to run a tiny server suitable for only a few thousand users 41 | you might choose to run the worker within the server process. 42 | 43 | ## Automatic retries 44 | 45 | Graphile Worker will automatically retry any jobs that fail using an exponential 46 | back-off algorithm; see 47 | [the Graphile Worker README](https://github.com/graphile/worker#exponential-backoff). 48 | -------------------------------------------------------------------------------- /@app/worker/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require("../../babel.config"); 2 | -------------------------------------------------------------------------------- /@app/worker/crontab: -------------------------------------------------------------------------------- 1 | # Put your repeating tasks in this file. 2 | # 3 | # See: https://worker.graphile.org/docs/cron 4 | # 5 | # ┌───────────── UTC minute (0 - 59) 6 | # │ ┌───────────── UTC hour (0 - 23) 7 | # │ │ ┌───────────── UTC day of the month (1 - 31) 8 | # │ │ │ ┌───────────── UTC month (1 - 12) 9 | # │ │ │ │ ┌───────────── UTC day of the week (0 - 6) (Sunday to Saturday) 10 | # │ │ │ │ │ ┌───────────── task (identifier) to schedule 11 | # │ │ │ │ │ │ ┌────────── optional scheduling options 12 | # │ │ │ │ │ │ │ ┌────── optional payload to merge 13 | # │ │ │ │ │ │ │ │ 14 | # │ │ │ │ │ │ │ │ 15 | # * * * * * task ?opts {payload} 16 | -------------------------------------------------------------------------------- /@app/worker/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require("../../jest.config.base")(__dirname); 2 | -------------------------------------------------------------------------------- /@app/worker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@app/worker", 3 | "private": true, 4 | "version": "0.0.0", 5 | "scripts": { 6 | "gw": "cd dist && cross-env NODE_OPTIONS=\"${NODE_OPTIONS:-} -r @app/config/env\" graphile-worker --crontab ../crontab", 7 | "dev": "cd dist && cross-env NODE_OPTIONS=\"${NODE_OPTIONS:-} -r @app/config/env --inspect=9757\" graphile-worker --crontab ../crontab --watch", 8 | "build": "tsc -b", 9 | "start": "yarn gw", 10 | "install-db-schema": "mkdirp dist && yarn gw --schema-only", 11 | "test": "cross-env NODE_ENV=test NODE_OPTIONS=\"${NODE_OPTIONS:-} -r @app/config/env\" jest" 12 | }, 13 | "dependencies": { 14 | "@app/config": "0.0.0", 15 | "@types/html-to-text": "^9.0.0", 16 | "@types/lodash": "^4.14.191", 17 | "@types/mjml": "^4.7.0", 18 | "@types/nodemailer": "^6.4.7", 19 | "aws-sdk": "^2.1325.0", 20 | "cross-env": "^7.0.3", 21 | "graphile-worker": "^0.13.0", 22 | "html-to-text": "^9.0.4", 23 | "lodash": "^4.17.21", 24 | "mjml": "^4.13.0", 25 | "nodemailer": "^6.9.9", 26 | "tslib": "^2.5.0" 27 | }, 28 | "devDependencies": { 29 | "jest": "^29.4.3", 30 | "mkdirp": "^2.1.3" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /@app/worker/src/fs.ts: -------------------------------------------------------------------------------- 1 | import { promises as fsp } from "fs"; 2 | 3 | const { utimes, open } = fsp; 4 | 5 | export const touch = async (filepath: string): Promise => { 6 | try { 7 | const time = new Date(); 8 | await utimes(filepath, time, time); 9 | } catch (err) { 10 | const filehandle = await open(filepath, "w"); 11 | await filehandle.close(); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /@app/worker/src/tasks/organization_invitations__send_invite.ts: -------------------------------------------------------------------------------- 1 | import { Task } from "graphile-worker"; 2 | 3 | import { SendEmailPayload } from "./send_email"; 4 | 5 | interface OrganizationInvitationSendInvitePayload { 6 | /** 7 | * invitation id 8 | */ 9 | id: string; 10 | } 11 | 12 | const task: Task = async (inPayload, { addJob, withPgClient }) => { 13 | const payload: OrganizationInvitationSendInvitePayload = inPayload as any; 14 | const { id: invitationId } = payload; 15 | const { 16 | rows: [invitation], 17 | } = await withPgClient((pgClient) => 18 | pgClient.query( 19 | ` 20 | select * 21 | from app_public.organization_invitations 22 | where id = $1 23 | `, 24 | [invitationId] 25 | ) 26 | ); 27 | if (!invitation) { 28 | console.error("Invitation not found; aborting"); 29 | return; 30 | } 31 | 32 | let email = invitation.email; 33 | if (!email) { 34 | const { 35 | rows: [primaryEmail], 36 | } = await withPgClient((pgClient) => 37 | pgClient.query( 38 | `select * from app_public.user_emails where user_id = $1 and is_primary = true`, 39 | [invitation.user_id] 40 | ) 41 | ); 42 | if (!primaryEmail) { 43 | console.error( 44 | `No primary email found for user ${invitation.user_id}; aborting` 45 | ); 46 | return; 47 | } 48 | email = primaryEmail.email; 49 | } 50 | 51 | const { 52 | rows: [organization], 53 | } = await withPgClient((pgClient) => 54 | pgClient.query(`select * from app_public.organizations where id = $1`, [ 55 | invitation.organization_id, 56 | ]) 57 | ); 58 | 59 | const sendEmailPayload: SendEmailPayload = { 60 | options: { 61 | to: email, 62 | subject: `You have been invited to ${organization.name}`, 63 | }, 64 | template: "organization_invite.mjml", 65 | variables: { 66 | organizationName: organization.name, 67 | link: 68 | `${process.env.ROOT_URL}/invitations/accept?id=${encodeURIComponent( 69 | invitation.id 70 | )}` + 71 | (invitation.code ? `&code=${encodeURIComponent(invitation.code)}` : ""), 72 | }, 73 | }; 74 | await addJob("send_email", sendEmailPayload); 75 | }; 76 | 77 | module.exports = task; 78 | -------------------------------------------------------------------------------- /@app/worker/src/tasks/user__forgot_password.ts: -------------------------------------------------------------------------------- 1 | import { Task } from "graphile-worker"; 2 | 3 | import { SendEmailPayload } from "./send_email"; 4 | 5 | interface UserForgotPasswordPayload { 6 | /** 7 | * user id 8 | */ 9 | id: string; 10 | 11 | /** 12 | * email address 13 | */ 14 | email: string; 15 | 16 | /** 17 | * secret token 18 | */ 19 | token: string; 20 | } 21 | 22 | const task: Task = async (inPayload, { addJob, withPgClient }) => { 23 | const payload: UserForgotPasswordPayload = inPayload as any; 24 | const { id: userId, email, token } = payload; 25 | const { 26 | rows: [user], 27 | } = await withPgClient((pgClient) => 28 | pgClient.query( 29 | ` 30 | select users.* 31 | from app_public.users 32 | where id = $1 33 | `, 34 | [userId] 35 | ) 36 | ); 37 | if (!user) { 38 | console.error("User not found; aborting"); 39 | return; 40 | } 41 | const sendEmailPayload: SendEmailPayload = { 42 | options: { 43 | to: email, 44 | subject: "Password reset", 45 | }, 46 | template: "password_reset.mjml", 47 | variables: { 48 | token, 49 | verifyLink: `${process.env.ROOT_URL}/reset?user_id=${encodeURIComponent( 50 | user.id 51 | )}&token=${encodeURIComponent(token)}`, 52 | }, 53 | }; 54 | await addJob("send_email", sendEmailPayload); 55 | }; 56 | 57 | module.exports = task; 58 | -------------------------------------------------------------------------------- /@app/worker/src/tasks/user__forgot_password_unregistered_email.ts: -------------------------------------------------------------------------------- 1 | import { projectName } from "@app/config"; 2 | import { Task } from "graphile-worker"; 3 | 4 | import { SendEmailPayload } from "./send_email"; 5 | 6 | interface UserForgotPasswordUnregisteredEmailPayload { 7 | email: string; 8 | } 9 | 10 | const task: Task = async (inPayload, { addJob }) => { 11 | const payload: UserForgotPasswordUnregisteredEmailPayload = inPayload as any; 12 | const { email } = payload; 13 | 14 | const sendEmailPayload: SendEmailPayload = { 15 | options: { 16 | to: email, 17 | subject: `Password reset request failed: you don't have a ${projectName} account`, 18 | }, 19 | template: "password_reset_unregistered.mjml", 20 | variables: { 21 | url: process.env.ROOT_URL, 22 | }, 23 | }; 24 | await addJob("send_email", sendEmailPayload); 25 | }; 26 | 27 | module.exports = task; 28 | -------------------------------------------------------------------------------- /@app/worker/src/tasks/user__send_delete_account_email.ts: -------------------------------------------------------------------------------- 1 | import { Task } from "graphile-worker"; 2 | 3 | import { SendEmailPayload } from "./send_email"; 4 | 5 | interface UserSendAccountDeletionEmailPayload { 6 | /** 7 | * email address 8 | */ 9 | email: string; 10 | 11 | /** 12 | * secret token 13 | */ 14 | token: string; 15 | } 16 | 17 | const task: Task = async (inPayload, { addJob }) => { 18 | const payload: UserSendAccountDeletionEmailPayload = inPayload as any; 19 | const { email, token } = payload; 20 | const sendEmailPayload: SendEmailPayload = { 21 | options: { 22 | to: email, 23 | subject: "Confirmation required: really delete account?", 24 | }, 25 | template: "delete_account.mjml", 26 | variables: { 27 | token, 28 | deleteAccountLink: `${ 29 | process.env.ROOT_URL 30 | }/settings/delete?token=${encodeURIComponent(token)}`, 31 | }, 32 | }; 33 | await addJob("send_email", sendEmailPayload); 34 | }; 35 | 36 | module.exports = task; 37 | -------------------------------------------------------------------------------- /@app/worker/src/tasks/user_emails__send_verification.ts: -------------------------------------------------------------------------------- 1 | import { Task } from "graphile-worker"; 2 | 3 | import { SendEmailPayload } from "./send_email"; 4 | 5 | // At least 3 minutes between resending email verifications 6 | const MIN_INTERVAL = 1000 * 60 * 3; 7 | 8 | interface UserEmailsSendVerificationPayload { 9 | id: string; 10 | } 11 | 12 | const task: Task = async (inPayload, { addJob, withPgClient }) => { 13 | const payload: UserEmailsSendVerificationPayload = inPayload as any; 14 | const { id: userEmailId } = payload; 15 | const { 16 | rows: [userEmail], 17 | } = await withPgClient((pgClient) => 18 | pgClient.query( 19 | ` 20 | select user_emails.id, email, verification_token, username, name, extract(epoch from now()) - extract(epoch from verification_email_sent_at) as seconds_since_verification_sent 21 | from app_public.user_emails 22 | inner join app_private.user_email_secrets 23 | on user_email_secrets.user_email_id = user_emails.id 24 | inner join app_public.users 25 | on users.id = user_emails.user_id 26 | where user_emails.id = $1 27 | and user_emails.is_verified is false 28 | `, 29 | [userEmailId] 30 | ) 31 | ); 32 | if (!userEmail) { 33 | console.warn( 34 | `user_emails__send_verification task for non-existent userEmail ignored (userEmailId = ${userEmailId})` 35 | ); 36 | // No longer relevant 37 | return; 38 | } 39 | const { 40 | email, 41 | verification_token, 42 | username, 43 | name, 44 | seconds_since_verification_sent, 45 | } = userEmail; 46 | if ( 47 | seconds_since_verification_sent != null && 48 | seconds_since_verification_sent < MIN_INTERVAL / 1000 49 | ) { 50 | console.log("Email sent too recently"); 51 | return; 52 | } 53 | const sendEmailPayload: SendEmailPayload = { 54 | options: { 55 | to: email, 56 | subject: "Please verify your email address", 57 | }, 58 | template: "verify_email.mjml", 59 | variables: { 60 | token: verification_token, 61 | verifyLink: `${process.env.ROOT_URL}/verify?id=${encodeURIComponent( 62 | String(userEmailId) 63 | )}&token=${encodeURIComponent(verification_token)}`, 64 | username, 65 | name, 66 | }, 67 | }; 68 | await addJob("send_email", sendEmailPayload); 69 | await withPgClient((pgClient) => 70 | pgClient.query( 71 | "update app_private.user_email_secrets set verification_email_sent_at = now() where user_email_id = $1", 72 | [userEmailId] 73 | ) 74 | ); 75 | }; 76 | 77 | export default task; 78 | -------------------------------------------------------------------------------- /@app/worker/src/transport.ts: -------------------------------------------------------------------------------- 1 | import { awsRegion } from "@app/config"; 2 | import * as aws from "aws-sdk"; 3 | import { promises as fsp } from "fs"; 4 | import * as nodemailer from "nodemailer"; 5 | 6 | const { readFile, writeFile } = fsp; 7 | 8 | const isTest = process.env.NODE_ENV === "test"; 9 | const isDev = process.env.NODE_ENV !== "production"; 10 | 11 | let transporterPromise: Promise; 12 | const etherealFilename = `${process.cwd()}/.ethereal`; 13 | 14 | let logged = false; 15 | 16 | export default function getTransport(): Promise { 17 | if (!transporterPromise) { 18 | transporterPromise = (async () => { 19 | if (isTest) { 20 | return nodemailer.createTransport({ 21 | jsonTransport: true, 22 | }); 23 | } else if (isDev) { 24 | let account; 25 | try { 26 | const testAccountJson = await readFile(etherealFilename, "utf8"); 27 | account = JSON.parse(testAccountJson); 28 | } catch (e: any) { 29 | account = await nodemailer.createTestAccount(); 30 | await writeFile(etherealFilename, JSON.stringify(account)); 31 | } 32 | if (!logged) { 33 | logged = true; 34 | console.log(); 35 | console.log(); 36 | console.log( 37 | // Escapes equivalent to chalk.bold 38 | "\x1B[1m" + 39 | " ✉️ Emails in development are sent via ethereal.email; your credentials follow:" + 40 | "\x1B[22m" 41 | ); 42 | console.log(" Site: https://ethereal.email/login"); 43 | console.log(` Username: ${account.user}`); 44 | console.log(` Password: ${account.pass}`); 45 | console.log(); 46 | console.log(); 47 | } 48 | return nodemailer.createTransport({ 49 | host: "smtp.ethereal.email", 50 | port: 587, 51 | secure: false, 52 | auth: { 53 | user: account.user, 54 | pass: account.pass, 55 | }, 56 | }); 57 | } else { 58 | if (!process.env.AWS_ACCESS_KEY_ID) { 59 | throw new Error("Misconfiguration: no AWS_ACCESS_KEY_ID"); 60 | } 61 | if (!process.env.AWS_SECRET_ACCESS_KEY) { 62 | throw new Error("Misconfiguration: no AWS_SECRET_ACCESS_KEY"); 63 | } 64 | return nodemailer.createTransport({ 65 | SES: new aws.SES({ 66 | apiVersion: "2010-12-01", 67 | region: awsRegion, 68 | }), 69 | }); 70 | } 71 | })(); 72 | } 73 | return transporterPromise!; 74 | } 75 | -------------------------------------------------------------------------------- /@app/worker/templates/account_activity.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | [[actionDescription]] 20 | 21 | 22 | This email is purely for your information and security, if you performed the action above then no further action is necessary. 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | Best,
31 | The [[projectName]] Team 32 |
33 |
34 |
35 | 36 | 37 | 38 | 39 | [[legalText]] 40 | 41 | 42 | 43 | 44 |
45 |
46 | -------------------------------------------------------------------------------- /@app/worker/templates/delete_account.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | Click the button below to continue deleting your account. 20 | 21 | 22 | 23 | 24 | 25 | 26 | Confirm intent 27 | 28 | 29 | 30 | Verification code: 31 | 32 | 33 | [[token]] 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | Best,
42 | The [[projectName]] Team 43 |
44 |
45 |
46 | 47 | 48 | 49 | 50 | [[legalText]] 51 | 52 | 53 | 54 | 55 |
56 |
57 | -------------------------------------------------------------------------------- /@app/worker/templates/organization_invite.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | You have been invited to join [[organizationName]]. Please click 20 | the button below to accept the invitation. If you do not wish to 21 | join this organization then no action is necessary. 22 | 23 | 24 | 25 | 26 | 27 | 28 | Accept invitation 29 | 30 | 31 | 32 | 33 | 34 | 35 | Best,
36 | The [[projectName]] Team 37 |
38 |
39 |
40 | 41 | 42 | 43 | 44 | [[legalText]] 45 | 46 | 47 | 48 | 49 |
50 |
51 | -------------------------------------------------------------------------------- /@app/worker/templates/password_reset.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | Click below to reset your password. 20 | 21 | 22 | 23 | 24 | 25 | 26 | Change Passphrase 27 | 28 | 29 | 30 | Verification code: 31 | 32 | 33 | [[token]] 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | Best,
42 | The [[projectName]] Team 43 |
44 |
45 |
46 | 47 | 48 | 49 | 50 | [[legalText]] 51 | 52 | 53 | 54 | 55 |
56 |
57 | -------------------------------------------------------------------------------- /@app/worker/templates/password_reset_unregistered.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | Someone (hopefully you) tried to reset your password for [[projectName]] ([[url]]), but no account is registered for this email address. 20 | 21 | 22 | This message is purely for your information. No action is required. 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | Best,
31 | The [[projectName]] Team 32 |
33 |
34 |
35 | 36 | 37 | 38 | 39 | [[legalText]] 40 | 41 | 42 | 43 | 44 |
45 |
46 | -------------------------------------------------------------------------------- /@app/worker/templates/verify_email.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | Please confirm your email by clicking the link below. 20 | 21 | 22 | 23 | 24 | 25 | 26 | Verify Email 27 | 28 | 29 | 30 | Verification code: 31 | 32 | 33 | [[token]] 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | Best,
42 | The [[projectName]] Team 43 |
44 |
45 |
46 | 47 | 48 | 49 | 50 | [[legalText]] 51 | 52 | 53 | 54 | 55 |
56 |
57 | -------------------------------------------------------------------------------- /@app/worker/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "rootDir": "src", 6 | "outDir": "dist", 7 | "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo", 8 | "declarationDir": "dist", 9 | "lib": ["es2018", "esnext.asynciterable"], 10 | "target": "es2018", 11 | "module": "commonjs" 12 | }, 13 | "include": ["src"], 14 | "references": [{ "path": "../config" }] 15 | } 16 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We welcome contributions; but please don't expand the scope of the project 4 | without first discussing it with the maintainers (by opening an issue). 5 | 6 | ## Migrations 7 | 8 | This project will only ever have one migration; we do not need to cater to 9 | backwards compatibility, prefering simplicity for new users. 10 | 11 | If you wish to change the database, run 12 | 13 | ``` 14 | yarn db uncommit 15 | ``` 16 | 17 | to move the migration back to `current.sql`; then make your changes, and when 18 | you're happy run: 19 | 20 | ``` 21 | yarn db commit 22 | ``` 23 | 24 | and commit your changes. 25 | 26 | ## Docker and non-Docker 27 | 28 | This project is designed to work both with and without Docker. Please don't 29 | break this! 30 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-bullseye 2 | 3 | # The node image comes with a base non-root 'node' user which this Dockerfile 4 | # gives sudo access. However, for Linux, this user's GID/UID must match your local 5 | # user UID/GID to avoid permission issues with bind mounts. Update USER_UID / USER_GID 6 | # if yours is not 1000. See https://aka.ms/vscode-remote/containers/non-root-user. 7 | ARG USER_UID=${UID:-1000} 8 | ARG SETUP_MODE=normal 9 | 10 | COPY docker/setup.sh /setup.sh 11 | RUN /setup.sh $SETUP_MODE 12 | -------------------------------------------------------------------------------- /GRAPHILE_STARTER_LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) Copyright © 2020 Graphile Ltd. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the “Software”), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | This project was built on the Graphile Starter project (which, at time of 2 | writing (2019-09-13), is located at https://github.com/graphile/starter) and 3 | thus contains code that is copyright Graphile Ltd. See 4 | `GRAPHILE_STARTER_LICENSE.md` for details of the license of that code. 5 | 6 | TODO: insert your own copyright notices here. 7 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: yarn server start 2 | worker: yarn worker start 3 | release: yarn db wipe-if-demo && yarn db migrate 4 | # TODO: remove wipe-if-demo from the above line, so it should be simply: 5 | # release: yarn db migrate 6 | -------------------------------------------------------------------------------- /SPONSORS.md: -------------------------------------------------------------------------------- 1 | # Sponsors 2 | 3 | These individuals and companies sponsor ongoing development of projects in the 4 | Graphile ecosystem. Find out 5 | [how you can become a sponsor](https://graphile.org/sponsor/). 6 | 7 | ## Featured 8 | 9 | - The Guild 10 | - Steelhead 11 | 12 | ## Leaders 13 | 14 | - Robert Claypool 15 | - Principia Mentis 16 | - nigelrmtaylor 17 | - Trigger.dev 18 | - Axinom 19 | - Taiste 20 | - BairesDev 21 | - Cintra 22 | - Two Bit Solutions 23 | - Dimply 24 | - Ndustrial 25 | - Apollo 26 | - Beacon 27 | - deliver.media 28 | - Ravio 29 | - prodready 30 | - Locomote 31 | 32 | ## Supporters 33 | 34 | - HR-ON 35 | - stlbucket 36 | - Simon Elliott 37 | - Matt Bretl 38 | - Alvin Ali Khaled 39 | - Paul Melnikow 40 | - Keith Layne 41 | - nullachtvierzehn 42 | - Zymego 43 | - garpulon 44 | - Ether 45 | - Nate Smith 46 | - The Outbound Collective 47 | - Charlie Hadden 48 | - Vizcom 49 | - Kiron Open Higher Education 50 | - Andrew Joseph 51 | - SIED 70 - TE 70 52 | - Peter C. Romano 53 | - mateo 54 | - Dialo 55 | - kontakto-fi 56 | - Tailos, Inc. 57 | - sheilamosaik 58 | - Jody Hoon-Starr 59 | - Justin Carrus 60 | - WorkOS 61 | -------------------------------------------------------------------------------- /apollo.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | client: { 3 | includes: [`${__dirname}/@app/client/src/**/*.graphql`], 4 | service: { 5 | name: "postgraphile", 6 | localSchemaFile: `${__dirname}/data/schema.graphql`, 7 | }, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ["@babel/preset-env", { targets: { node: "current" } }], 4 | "@babel/preset-typescript", 5 | ], 6 | plugins: ["@babel/plugin-syntax-import-assertions"], 7 | }; 8 | -------------------------------------------------------------------------------- /data/README.md: -------------------------------------------------------------------------------- 1 | # Generated Data 2 | 3 | Normally you wouldn't track generated data in git; however we deliberately keep 4 | these resources under version control: 5 | 6 | - `schema.graphql` is compared during upgrades, migrations and pull requests so 7 | you can see what has changed and ensure there’s no accidental GraphQL 8 | regressions 9 | - `schema.sql` is kept for similar (but database) reasons, and to help you to 10 | ensure that all developers are running the same version of the database 11 | without accidental differences caused by faulty migration hygiene 12 | -------------------------------------------------------------------------------- /docker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docker-helpers", 3 | "private": true, 4 | "version": "0.0.0", 5 | "scripts": { 6 | "setup": "node ./scripts/yarn-setup.js", 7 | "start": "yarn compose up server", 8 | "bash": "yarn compose exec server bash", 9 | "dev": "yarn compose:exec:dev bash", 10 | "dev:start": "yarn compose:exec:dev yarn start", 11 | "reset": "yarn down && yarn rebuild && yarn compose run server yarn reset && yarn down --volumes && yarn reset:volumes && rm -f .env", 12 | "--DOCKER HELPERS--": "", 13 | "db:up": "yarn compose up -d db", 14 | "compose": "docker-compose -f ../docker-compose.yml", 15 | "compose:exec:dev": "yarn down && yarn compose up -d dev && yarn compose exec dev ", 16 | "reset:volumes": "node ./scripts/clean-volumes.js", 17 | "rebuild": "yarn compose build", 18 | "down": "yarn compose down --remove-orphans" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /docker/scripts/clean-volumes.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { execSync } = require("child_process"); 3 | const { basename, dirname, resolve } = require("path"); 4 | 5 | const projectName = basename(dirname(resolve(__dirname, ".."))).replace( 6 | /[^a-z0-9]/g, 7 | "" 8 | ); 9 | 10 | try { 11 | execSync( 12 | `docker volume rm ${projectName}_vscode-extensions ${projectName}_devcontainer_db-volume ${projectName}_devcontainer_node_modules-volume ${projectName}_devcontainer_vscode-extensions`, 13 | { stdio: "inherit" } 14 | ); 15 | } catch (e) { 16 | /* noop */ 17 | } 18 | -------------------------------------------------------------------------------- /docker/scripts/copy-local-config-and-ssh-creds.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | echo "copy local configs" 4 | 5 | # Copy ssh creed from local home 6 | # Needed for e.g. git push etc 7 | mkdir -p /root/.ssh 8 | cp -r /root/.home-localhost/.ssh/* /root/.ssh 9 | chmod 600 /root/.ssh/* 10 | 11 | # Copy gitconfig 12 | cp /root/.home-localhost/.gitconfig /root/.gitconfig 13 | # Copy bashconfig 14 | cp /root/.home-localhost/.bashrc /root/.bashrc 15 | # Copy tmux 16 | cp /root/.home-localhost/tmux.conf /root/tmux.conf 17 | # Copy vimrc for vim & nvim 18 | mkdir -p /root/.config/nvim 19 | echo "/root/.vimrc /root/.config/nvim/init.vim" | xargs -n 1 cp -v /root/.home-localhost/.vimrc 20 | 21 | # Copy vim plugins, eg. "https://github.com/junegunn/vim-plug" 22 | mkdir -p ~/.vim/autoload 23 | mkdir -p /root/.local/share/nvim/site/autoload 24 | echo "/root/.vim/autoload/ /root/.local/share/nvim/site/autoload/" | xargs -n 1 cp -r -v /root/.home-localhost/.vim/autoload/* 25 | echo "/root/.bashrc /root/.gitconfig /root/.ssh/config /root/.vimrc /root/.config/nvim/init.vim" | xargs -n 1 dos2unix 26 | 27 | # Installs (n)vim plugins 28 | nvim +'PlugInstall --sync' +qa true 29 | -------------------------------------------------------------------------------- /docker/scripts/lsfix.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # We don't understand how this works, but on Mac you get the following error sometimes due to Docker volume ownership issues: 4 | # fatal: detected dubious ownership in repository at '/work' 5 | # To add an exception for this directory, call: [...] 6 | # But weirdly, just listing the directory fixes it. 🤷 7 | ls >/dev/null 8 | -------------------------------------------------------------------------------- /docker/setup.sh: -------------------------------------------------------------------------------- 1 | #------------------------------------------------------------------------------------------------------------- 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. 4 | #------------------------------------------------------------------------------------------------------------- 5 | 6 | set -e 7 | 8 | # Avoid warnings by switching to noninteractive 9 | export DEBIAN_FRONTEND=noninteractive 10 | 11 | # Remove outdated yarn from /opt 12 | rm -rf /opt/yarn-* /usr/local/bin/yarn /usr/local/bin/yarnpkg 13 | 14 | # Install most things we need 15 | apt-get update 16 | apt-get install -y --no-install-recommends \ 17 | apt-transport-https \ 18 | apt-utils \ 19 | bash-completion \ 20 | curl \ 21 | dialog \ 22 | git \ 23 | iproute2 \ 24 | lsb-release \ 25 | procps \ 26 | sudo 27 | 28 | LINUX_DISTRO=$(lsb_release -is | tr '[:upper:]' '[:lower:]') # eg. debian 29 | 30 | # Add additional apt sources... 31 | curl -sS https://dl.yarnpkg.com/$LINUX_DISTRO/pubkey.gpg | apt-key add - 2>/dev/null 32 | echo "deb https://dl.yarnpkg.com/$LINUX_DISTRO/ stable main" | tee /etc/apt/sources.list.d/yarn.list 33 | # ... and import them 34 | apt-get update 35 | 36 | # Install yarn via package so it can be easily updated via apt-get upgrade yarn 37 | apt-get -y install --no-install-recommends yarn 38 | 39 | # './setup.sh dev' installs more handy development utilities... 40 | if [ "$1" = "dev" ]; then 41 | # locales for tmux: https://github.com/GameServerManagers/LinuxGSM/issues/817 42 | # dos2unix for config files of windows user 43 | apt-get install -y --no-install-recommends neovim tmux locales dos2unix 44 | fi 45 | 46 | # Install eslint globally 47 | yarn global add eslint 48 | 49 | # Install docker and docker-compose (for setup) 50 | curl https://get.docker.com/builds/Linux/x86_64/docker-latest.tgz | tar xvz -C /tmp/ && mv /tmp/docker/docker /usr/bin/docker 51 | curl -L "https://github.com/docker/compose/releases/download/1.24.1/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose 52 | chmod 755 /usr/local/bin/docker-compose 53 | 54 | 55 | # on windows there is no USER_UID 56 | if [ "$USER_UID" != "" ]; then 57 | echo "\$USER_UID given: $USER_UID"; 58 | # [Optional] Update a non-root user to match UID/GID - see https://aka.ms/vscode-remote/containers/non-root-user. 59 | if [ "$USER_UID" != "1000" ]; then 60 | usermod --uid "$USER_UID" node; 61 | fi 62 | fi 63 | 64 | # Add the user to the docker group so they can access /var/run/docker.sock 65 | groupadd -g 999 docker 66 | usermod -a -G docker node 67 | 68 | # [Optional] Add add sudo support for non-root user 69 | echo node ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/node 70 | chmod 0440 /etc/sudoers.d/node 71 | 72 | # Clean up 73 | apt-get autoremove -y 74 | apt-get clean -y 75 | rm -rf /var/lib/apt/lists/* 76 | 77 | # Fix permissions (inspired by https://hub.docker.com/r/bitnami/express/dockerfile/) 78 | mkdir -p /dist /app /.npm /.yarn /.config /.cache /.local 79 | touch /.yarnrc 80 | chmod g+rwX /dist /app /.npm /.yarn /.config /.cache /.local /.yarnrc 81 | 82 | # Self-destruct 83 | rm /setup.sh 84 | -------------------------------------------------------------------------------- /dockerctl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | export UID 3 | docker-compose -f docker-compose.builder.yml run --rm $@ 4 | -------------------------------------------------------------------------------- /docs/error_codes.md: -------------------------------------------------------------------------------- 1 | # Error codes 2 | 3 | PostgreSQL has a built in list of error codes that are associated with the 4 | errors that it produces. These are outlined 5 | [in the PostgreSQL documentation](https://www.postgresql.org/docs/current/errcodes-appendix.html). 6 | 7 | Our custom functions may also raise exceptions with custom error codes. When we 8 | add a custom errorcode to our database, we document it in this file. 9 | 10 | ## Error code rules 11 | 12 | To try and avoid clashes with present or future PostgreSQL error codes, we 13 | require that all custom error codes match the following criteria: 14 | 15 | - 5 alphanumeric (capitals) letters 16 | - First character must be a letter 17 | - First character cannot be F, H, P or X. 18 | - Third character cannot be 0 or P. 19 | - Fourth character must be a letter. 20 | - Must not end `000` 21 | 22 | Rewritten, the above rules state: 23 | 24 | - Char 1: A-Z except F, H, P, X 25 | - Char 2: A-Z0-9 26 | - Char 3: A-Z0-9 except 0, P 27 | - Char 4: A-Z 28 | - Char 5: A-Z0-9 29 | 30 | ## General 31 | 32 | - FFFFF: unknown error 33 | - DNIED: permission denied 34 | - NUNIQ: not unique (from PostgreSQL 23505) 35 | - NTFND: not found 36 | - BADFK: foreign key violation (from PostgreSQL 23503) 37 | 38 | ## Registration 39 | 40 | - MODAT: more data required (e.g. missing email address) 41 | 42 | ## Authentication 43 | 44 | - WEAKP: password is too weak 45 | - LOCKD: too many failed login/password reset attempts; try again in 6 hours 46 | - TAKEN: a different user account is already linked to this profile 47 | - EMTKN: a different user account is already linked to this email 48 | - CREDS: bad credentials (incorrect username/password) 49 | - LOGIN: you're not logged in 50 | 51 | ## Email management 52 | 53 | - VRFY1: you need to verify your email before you can do that 54 | - VRFY2: the target user needs to verify their email before you can do that 55 | - CDLEA: cannot delete last email address (or last verified email address if you 56 | have verified email addresses) 57 | 58 | ## Organization membership 59 | 60 | - ISMBR: this person is already a member 61 | 62 | ## Deleting account 63 | 64 | - OWNER: you cannot delete your account because you are the owner of an 65 | organization 66 | -------------------------------------------------------------------------------- /docs/production_todo.md: -------------------------------------------------------------------------------- 1 | # Production Todolist 2 | 3 | The @graphile/starter project this project is based on is designed to be easy 4 | for you to pick up and get going. Therefore, there are certain things in it that 5 | may not be optimal when you are dealing with large amounts of traffic. This file 6 | contains some suggestions about things you might want to improve in order to 7 | make your server more efficient. 8 | 9 | ## Sessions 10 | 11 | By default, sessions are stored into your PostgreSQL database by 12 | `connect-pg-simple`, but this increases the load on your database. To reduce 13 | database load, you should set the `REDIS_URL` environment variable to store 14 | sessions to redis instead. Read more in `installSession.ts`. 15 | -------------------------------------------------------------------------------- /jest.config.base.js: -------------------------------------------------------------------------------- 1 | module.exports = (dir) => { 2 | const package = require(`${dir}/package.json`); 3 | 4 | return { 5 | testEnvironment: "node", 6 | transform: { 7 | "^.+\\.tsx?$": "babel-jest", 8 | }, 9 | testMatch: ["/**/__tests__/**/*.test.[jt]s?(x)"], 10 | moduleFileExtensions: ["js", "json", "jsx", "ts", "tsx", "node"], 11 | roots: [``], 12 | 13 | rootDir: dir, 14 | displayName: package.name, 15 | }; 16 | }; 17 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | projects: ["/@app/*/jest.config.js"], 3 | }; 4 | -------------------------------------------------------------------------------- /scripts/clean.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | (async () => { 3 | const { default: rimraf } = await import("rimraf"); 4 | const { globSync } = await import("glob"); 5 | try { 6 | await rimraf(globSync(`${__dirname}/../@app/*/dist`)); 7 | await rimraf(globSync(`${__dirname}/../@app/*/tsconfig.tsbuildinfo`)); 8 | await rimraf(globSync(`${__dirname}/../@app/client/.next`)); 9 | console.log("Deleted"); 10 | } catch (e) { 11 | console.error("Failed to clean up, perhaps rimraf isn't installed?"); 12 | console.error(e); 13 | } 14 | })(); 15 | -------------------------------------------------------------------------------- /scripts/delete-env-file.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const fs = require("fs"); 3 | 4 | try { 5 | fs.unlinkSync(`${__dirname}/../.env`); 6 | } catch (e) { 7 | /* NOOP */ 8 | } 9 | -------------------------------------------------------------------------------- /scripts/lib/dotenv.js: -------------------------------------------------------------------------------- 1 | const fsp = require("fs").promises; 2 | const dotenv = require("dotenv"); 3 | 4 | const DOTENV_PATH = `${__dirname}/../../.env`; 5 | 6 | async function readDotenv() { 7 | let buffer = null; 8 | try { 9 | buffer = await fsp.readFile(DOTENV_PATH); 10 | } catch (e) { 11 | /* noop */ 12 | } 13 | const config = buffer ? dotenv.parse(buffer) : null; 14 | // also read from current env, because docker-compose already needs to know some of it 15 | // eg. $PG_DUMP, $CONFIRM 16 | return { ...config, ...process.env }; 17 | } 18 | 19 | function encodeDotenvValue(str) { 20 | if (typeof str !== "string") { 21 | throw new Error(`'${str}' is not a string`); 22 | } 23 | if (str.trim() !== str) { 24 | // `dotenv` would escape this with single/double quotes but that won't work in docker-compose 25 | throw new Error( 26 | "We don't support leading/trailing whitespace in config variables" 27 | ); 28 | } 29 | if (str.indexOf("\n") >= 0) { 30 | // `dotenv` would escape this with single/double quotes and `\n` but that won't work in docker-compose 31 | throw new Error("We don't support newlines in config variables"); 32 | } 33 | return str; 34 | } 35 | 36 | async function withDotenvUpdater(overrides, callback) { 37 | let data; 38 | try { 39 | data = await fsp.readFile(DOTENV_PATH, "utf8"); 40 | // Trim whitespace, and prefix with newline so we can do easier checking later 41 | data = "\n" + data.trim(); 42 | } catch (e) { 43 | data = ""; 44 | } 45 | 46 | const config = data ? dotenv.parse(data) : null; 47 | const answers = { 48 | ...config, 49 | ...process.env, 50 | ...overrides, 51 | }; 52 | 53 | function add(varName, defaultValue, comment) { 54 | const SET = `\n${varName}=`; 55 | const encodedValue = encodeDotenvValue( 56 | varName in answers ? answers[varName] : defaultValue || "" 57 | ); 58 | const pos = data.indexOf(SET); 59 | if (pos >= 0) { 60 | /* Replace this value with the new value */ 61 | 62 | // Where's the next newline (or the end of the file if there is none) 63 | let nlpos = data.indexOf("\n", pos + 1); 64 | if (nlpos < 0) { 65 | nlpos = data.length; 66 | } 67 | 68 | // Surgical editing 69 | data = 70 | data.substr(0, pos + SET.length) + encodedValue + data.substr(nlpos); 71 | } else { 72 | /* This value didn't already exist; add it to the end */ 73 | 74 | if (comment) { 75 | data += `\n\n${comment}`; 76 | } 77 | 78 | data += `${SET}${encodedValue}`; 79 | } 80 | } 81 | 82 | await callback(add); 83 | 84 | data = data.trim() + "\n"; 85 | 86 | await fsp.writeFile(DOTENV_PATH, data); 87 | } 88 | 89 | exports.readDotenv = readDotenv; 90 | exports.withDotenvUpdater = withDotenvUpdater; 91 | -------------------------------------------------------------------------------- /scripts/lib/random.js: -------------------------------------------------------------------------------- 1 | const { randomBytes } = require("crypto"); 2 | 3 | function safeRandomString(length) { 4 | // Roughly equivalent to shell `openssl rand -base64 30 | tr '+/' '-_'` 5 | return randomBytes(length) 6 | .toString("base64") 7 | .replace(/\+/g, "-") 8 | .replace(/\//g, "_"); 9 | } 10 | 11 | exports.safeRandomString = safeRandomString; 12 | -------------------------------------------------------------------------------- /scripts/lib/run.js: -------------------------------------------------------------------------------- 1 | const { spawnSync } = require("child_process"); 2 | 3 | const runSync = (cmd, args, options = {}) => { 4 | const result = spawnSync(cmd, args, { 5 | stdio: ["inherit", "inherit", "inherit"], 6 | windowsHide: true, 7 | ...options, 8 | env: { 9 | ...process.env, 10 | // YARN_SILENT: "1", 11 | npm_config_loglevel: "silent", 12 | ...options.env, 13 | }, 14 | }); 15 | 16 | const { error, status, signal, stderr, stdout } = result; 17 | 18 | if (error) { 19 | throw error; 20 | } 21 | 22 | if (status || signal) { 23 | if (stdout) { 24 | console.log(stdout.toString("utf8")); 25 | } 26 | if (stderr) { 27 | console.error(stderr.toString("utf8")); 28 | } 29 | if (status) { 30 | process.exitCode = status; 31 | throw new Error( 32 | `Process exited with status '${status}' (running '${cmd} ${ 33 | args ? args.join(" ") : "" 34 | }')` 35 | ); 36 | } else { 37 | throw new Error( 38 | `Process exited due to signal '${signal}' (running '${cmd} ${ 39 | args ? args.join(" ") : null 40 | }')` 41 | ); 42 | } 43 | } 44 | 45 | return result; 46 | }; 47 | 48 | exports.runSync = runSync; 49 | -------------------------------------------------------------------------------- /scripts/run-docker-with-env.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require("dotenv").config(); 3 | const { runSync } = require("./lib/run"); 4 | 5 | const { 6 | DATABASE_OWNER, 7 | DATABASE_OWNER_PASSWORD, 8 | DATABASE_AUTHENTICATOR, 9 | DATABASE_AUTHENTICATOR_PASSWORD, 10 | DATABASE_VISITOR, 11 | SECRET, 12 | JWT_SECRET, 13 | GITHUB_KEY, 14 | GITHUB_SECRET, 15 | DATABASE_NAME, 16 | GRAPHILE_LICENSE, 17 | } = process.env; 18 | 19 | const DATABASE_HOST = "172.17.0.1"; 20 | const DATABASE_URL = `postgres://${DATABASE_OWNER}:${DATABASE_OWNER_PASSWORD}@${DATABASE_HOST}/${DATABASE_NAME}`; 21 | const AUTH_DATABASE_URL = `postgres://${DATABASE_AUTHENTICATOR}:${DATABASE_AUTHENTICATOR_PASSWORD}@${DATABASE_HOST}/${DATABASE_NAME}`; 22 | 23 | runSync("docker", [ 24 | "run", 25 | "--rm", 26 | "-it", 27 | "--init", 28 | "-p", 29 | "5678:5678", 30 | "-e", 31 | `DATABASE_VISITOR=${DATABASE_VISITOR}`, 32 | "-e", 33 | `GRAPHILE_LICENSE=${GRAPHILE_LICENSE}`, 34 | "-e", 35 | `SECRET=${SECRET}`, 36 | "-e", 37 | `JWT_SECRET=${JWT_SECRET}`, 38 | "-e", 39 | `DATABASE_URL=${DATABASE_URL}`, 40 | "-e", 41 | `AUTH_DATABASE_URL=${AUTH_DATABASE_URL}`, 42 | "-e", 43 | `GITHUB_KEY=${GITHUB_KEY}`, 44 | "-e", 45 | `GITHUB_SECRET=${GITHUB_SECRET}`, 46 | process.argv[2], 47 | ]); 48 | -------------------------------------------------------------------------------- /scripts/setup_env.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { 3 | yarnCmd, 4 | runMain, 5 | checkGit, 6 | outro, 7 | withDotenvUpdater, 8 | updateDotenv, 9 | readDotenv, 10 | runSync, 11 | } = require("./_setup_utils"); 12 | 13 | runMain(async () => { 14 | await checkGit(); 15 | const config = (await readDotenv()) || {}; 16 | const mergeAnswers = (cb) => (answers) => cb({ ...config, ...answers }); 17 | const questions = [ 18 | { 19 | type: "input", 20 | name: "DATABASE_NAME", 21 | message: "What would you like to call your database?", 22 | default: "graphile_starter", 23 | validate: (name) => 24 | /^[a-z][a-z0-9_]+$/.test(name) 25 | ? true 26 | : "That doesn't look like a good name for a database, try something simpler - just lowercase alphanumeric and underscores", 27 | when: !config.DATABASE_NAME, 28 | }, 29 | { 30 | type: "input", 31 | name: "DATABASE_HOST", 32 | message: 33 | "What's the hostname of your database server (include :port if it's not the default :5432)?", 34 | default: "localhost", 35 | when: !("DATABASE_HOST" in config), 36 | }, 37 | 38 | { 39 | type: "input", 40 | name: "ROOT_DATABASE_URL", 41 | message: mergeAnswers( 42 | (answers) => 43 | `Please enter a superuser connection string to the database server (so we can drop/create the '${answers.DATABASE_NAME}' and '${answers.DATABASE_NAME}_shadow' databases) - IMPORTANT: it must not be a connection to the '${answers.DATABASE_NAME}' database itself, instead try 'template1'.` 44 | ), 45 | default: mergeAnswers( 46 | (answers) => 47 | `postgres://${ 48 | answers.DATABASE_HOST === "localhost" ? "" : answers.DATABASE_HOST 49 | }/template1` 50 | ), 51 | when: !config.ROOT_DATABASE_URL, 52 | }, 53 | ]; 54 | const { default: inquirer } = await import("inquirer"); 55 | const answers = await inquirer.prompt(questions); 56 | 57 | await withDotenvUpdater(answers, (add) => 58 | updateDotenv(add, { 59 | ...config, 60 | ...answers, 61 | }) 62 | ); 63 | 64 | // And perform setup 65 | runSync(yarnCmd, ["server", "build"]); 66 | 67 | if (process.argv[2] === "auto") { 68 | // We're advancing automatically 69 | console.log(`\ 70 | ✅ Environment file setup success`); 71 | } else { 72 | outro(`\ 73 | ✅ Environment file setup success 74 | 75 | 🚀 The next step is to set up the database, run: 76 | 77 | ${yarnCmd} setup:db 78 | 79 | If you're not using graphile-migrate, then you should run your preferred migration framework now. This step should also include creating the necessary schemas and roles. Consult the generated .env file for what is needed.`); 80 | } 81 | }); 82 | -------------------------------------------------------------------------------- /scripts/start.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const fs = require("fs"); 3 | const { spawn } = require("child_process"); 4 | 5 | const ENVFILE = `${__dirname}/../.env`; 6 | 7 | if (!fs.existsSync(ENVFILE)) { 8 | console.error("🛠️ Please run 'yarn setup' before running 'yarn start'"); 9 | process.exit(1); 10 | } 11 | 12 | spawn("yarn", ["dev"], { 13 | stdio: "inherit", 14 | env: { 15 | ...process.env, 16 | // YARN_SILENT: "1", 17 | npm_config_loglevel: "silent", 18 | }, 19 | shell: true, 20 | }); 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "incremental": true, 5 | "noEmitOnError": true, 6 | 7 | "allowJs": false, 8 | "allowSyntheticDefaultImports": true, 9 | "esModuleInterop": true, 10 | "jsx": "preserve", 11 | "lib": ["dom", "es2017"], 12 | "target": "esnext", 13 | "module": "nodenext", 14 | "moduleResolution": "nodenext", 15 | "noFallthroughCasesInSwitch": true, 16 | "noImplicitAny": true, 17 | "preserveConstEnums": true, 18 | "resolveJsonModule": true, 19 | "sourceMap": true, 20 | "removeComments": false, 21 | "strict": true, 22 | "strictNullChecks": true, 23 | "importHelpers": true, 24 | "preserveWatchOutput": true, 25 | "forceConsistentCasingInFileNames": true, 26 | "skipLibCheck": true, 27 | 28 | "noUnusedLocals": false, 29 | "noUnusedParameters": false, 30 | 31 | "noEmit": true, 32 | "declaration": true, 33 | "declarationMap": true, 34 | "baseUrl": ".", 35 | "paths": { 36 | "@app/*": ["@app/*"] 37 | } 38 | }, 39 | "include": [], 40 | "exclude": ["@app/e2e"], 41 | "references": [ 42 | { "path": "@app/config" }, 43 | { "path": "@app/components" }, 44 | { "path": "@app/graphql" }, 45 | { "path": "@app/server" }, 46 | { "path": "@app/worker" } 47 | ] 48 | } 49 | --------------------------------------------------------------------------------