From dc2e4c3a87ddf3a272fdb6b9d4dd7e2562fc6b5e Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 6 Dec 2022 12:28:16 +0000 Subject: [PATCH] Initial commit of the mono repo --- .dockerignore | 37 ++ .eslintignore | 4 + .eslintrc.js | 14 + .gitignore | 45 ++ README.md | 64 +++ apps/webapp/.eslintrc | 7 + apps/webapp/.gitignore | 10 + apps/webapp/.prettierignore | 11 + apps/webapp/Dockerfile | 64 +++ apps/webapp/README.md | 10 + apps/webapp/app/components/CopyTextButton.tsx | 55 +++ .../webapp/app/components/code/JSONEditor.tsx | 88 ++++ .../app/components/code/JavascriptEditor.tsx | 79 ++++ .../app/components/code/codeMirrorSetup.ts | 56 +++ .../app/components/code/codeMirrorTheme.ts | 321 +++++++++++++++ .../app/components/primitives/Buttons.tsx | 90 ++++ .../app/components/primitives/Input.tsx | 34 ++ .../app/components/primitives/Select.tsx | 17 + .../app/components/primitives/Spinner.tsx | 28 ++ .../webapp/app/components/primitives/Tabs.tsx | 98 +++++ .../app/components/primitives/text/Body.tsx | 8 + .../components/primitives/text/BodyBold.tsx | 12 + .../primitives/text/ExtraLargeTitle.tsx | 8 + .../primitives/text/ExtraSmallBody.tsx | 8 + .../components/primitives/text/LargeTitle.tsx | 8 + .../components/primitives/text/SmallBody.tsx | 8 + .../components/primitives/text/SmallTitle.tsx | 8 + .../app/components/primitives/text/Title.tsx | 8 + apps/webapp/app/db.server.ts | 71 ++++ apps/webapp/app/entry.client.tsx | 22 + apps/webapp/app/entry.server.tsx | 41 ++ apps/webapp/app/env.server.ts | 14 + apps/webapp/app/lib.es5.d.ts | 13 + apps/webapp/app/models/user.server.ts | 116 ++++++ apps/webapp/app/root.tsx | 122 ++++++ apps/webapp/app/routes/__app.tsx | 34 ++ apps/webapp/app/routes/healthcheck.tsx | 24 ++ apps/webapp/app/routes/legal.tsx | 60 +++ apps/webapp/app/routes/legal/abuse.mdx | 34 ++ apps/webapp/app/routes/legal/privacy.mdx | 120 ++++++ apps/webapp/app/routes/legal/terms.mdx | 88 ++++ apps/webapp/app/services/authUser.ts | 3 + apps/webapp/app/services/redirectTo.server.ts | 53 +++ apps/webapp/app/services/session.server.ts | 43 ++ .../app/services/sessionStorage.server.ts | 22 + apps/webapp/app/utils.test.ts | 13 + apps/webapp/app/utils.ts | 71 ++++ apps/webapp/cypress.config.ts | 27 ++ apps/webapp/cypress/.eslintrc.js | 6 + apps/webapp/cypress/e2e/smoke.cy.ts | 16 + apps/webapp/cypress/fixtures/test.json | 3 + apps/webapp/cypress/support/commands.ts | 35 ++ apps/webapp/cypress/support/e2e.ts | 15 + apps/webapp/cypress/tsconfig.json | 39 ++ apps/webapp/fly.toml | 51 +++ apps/webapp/mocks/README.md | 7 + apps/webapp/mocks/index.js | 9 + apps/webapp/package.json | 178 ++++++++ apps/webapp/postcss.config.js | 7 + apps/webapp/prisma/schema.prisma | 16 + apps/webapp/prisma/seed.ts | 16 + apps/webapp/public/favicon.ico | Bin 0 -> 15406 bytes .../webapp/public/react-date-range/styles.css | 197 +++++++++ .../public/react-date-range/theme/default.css | 386 ++++++++++++++++++ apps/webapp/remix.config.js | 16 + apps/webapp/remix.env.d.ts | 2 + apps/webapp/server.ts | 111 +++++ apps/webapp/start.sh | 3 + apps/webapp/styles/tailwind-include.css | 9 + apps/webapp/tailwind.config.js | 32 ++ apps/webapp/test/setup-test-env.ts | 4 + apps/webapp/tsconfig.json | 31 ++ apps/webapp/vitest.config.ts | 15 + .../eslint-config-custom-next/index.js | 12 + .../eslint-config-custom-next/package.json | 17 + config-packages/eslint-config-custom/index.js | 8 + .../eslint-config-custom/package.json | 17 + .../eslint-config-vite/eslint-preset.js | 22 + .../eslint-config-vite/package.json | 20 + config-packages/tailwind-config/package.json | 9 + .../tailwind-config/postcss.config.js | 6 + .../tailwind-config/tailwind.config.js | 24 ++ config-packages/tsconfig/base.json | 25 ++ config-packages/tsconfig/nextjs.json | 22 + config-packages/tsconfig/node18.json | 16 + config-packages/tsconfig/package.json | 9 + config-packages/tsconfig/react-library.json | 11 + docker-compose.yml | 30 ++ package.json | 56 +++ pnpm-workspace.yaml | 5 + turbo.json | 127 ++++++ 91 files changed, 3831 insertions(+) create mode 100644 .dockerignore create mode 100644 .eslintignore create mode 100644 .eslintrc.js create mode 100644 .gitignore create mode 100644 apps/webapp/.eslintrc create mode 100644 apps/webapp/.gitignore create mode 100644 apps/webapp/.prettierignore create mode 100644 apps/webapp/Dockerfile create mode 100644 apps/webapp/README.md create mode 100644 apps/webapp/app/components/CopyTextButton.tsx create mode 100644 apps/webapp/app/components/code/JSONEditor.tsx create mode 100644 apps/webapp/app/components/code/JavascriptEditor.tsx create mode 100644 apps/webapp/app/components/code/codeMirrorSetup.ts create mode 100644 apps/webapp/app/components/code/codeMirrorTheme.ts create mode 100644 apps/webapp/app/components/primitives/Buttons.tsx create mode 100644 apps/webapp/app/components/primitives/Input.tsx create mode 100644 apps/webapp/app/components/primitives/Select.tsx create mode 100644 apps/webapp/app/components/primitives/Spinner.tsx create mode 100644 apps/webapp/app/components/primitives/Tabs.tsx create mode 100644 apps/webapp/app/components/primitives/text/Body.tsx create mode 100644 apps/webapp/app/components/primitives/text/BodyBold.tsx create mode 100644 apps/webapp/app/components/primitives/text/ExtraLargeTitle.tsx create mode 100644 apps/webapp/app/components/primitives/text/ExtraSmallBody.tsx create mode 100644 apps/webapp/app/components/primitives/text/LargeTitle.tsx create mode 100644 apps/webapp/app/components/primitives/text/SmallBody.tsx create mode 100644 apps/webapp/app/components/primitives/text/SmallTitle.tsx create mode 100644 apps/webapp/app/components/primitives/text/Title.tsx create mode 100644 apps/webapp/app/db.server.ts create mode 100644 apps/webapp/app/entry.client.tsx create mode 100644 apps/webapp/app/entry.server.tsx create mode 100644 apps/webapp/app/env.server.ts create mode 100644 apps/webapp/app/lib.es5.d.ts create mode 100644 apps/webapp/app/models/user.server.ts create mode 100644 apps/webapp/app/root.tsx create mode 100644 apps/webapp/app/routes/__app.tsx create mode 100644 apps/webapp/app/routes/healthcheck.tsx create mode 100644 apps/webapp/app/routes/legal.tsx create mode 100644 apps/webapp/app/routes/legal/abuse.mdx create mode 100644 apps/webapp/app/routes/legal/privacy.mdx create mode 100644 apps/webapp/app/routes/legal/terms.mdx create mode 100644 apps/webapp/app/services/authUser.ts create mode 100644 apps/webapp/app/services/redirectTo.server.ts create mode 100644 apps/webapp/app/services/session.server.ts create mode 100644 apps/webapp/app/services/sessionStorage.server.ts create mode 100644 apps/webapp/app/utils.test.ts create mode 100644 apps/webapp/app/utils.ts create mode 100644 apps/webapp/cypress.config.ts create mode 100644 apps/webapp/cypress/.eslintrc.js create mode 100644 apps/webapp/cypress/e2e/smoke.cy.ts create mode 100644 apps/webapp/cypress/fixtures/test.json create mode 100644 apps/webapp/cypress/support/commands.ts create mode 100644 apps/webapp/cypress/support/e2e.ts create mode 100644 apps/webapp/cypress/tsconfig.json create mode 100644 apps/webapp/fly.toml create mode 100644 apps/webapp/mocks/README.md create mode 100644 apps/webapp/mocks/index.js create mode 100644 apps/webapp/package.json create mode 100644 apps/webapp/postcss.config.js create mode 100644 apps/webapp/prisma/schema.prisma create mode 100644 apps/webapp/prisma/seed.ts create mode 100644 apps/webapp/public/favicon.ico create mode 100644 apps/webapp/public/react-date-range/styles.css create mode 100644 apps/webapp/public/react-date-range/theme/default.css create mode 100644 apps/webapp/remix.config.js create mode 100644 apps/webapp/remix.env.d.ts create mode 100644 apps/webapp/server.ts create mode 100644 apps/webapp/start.sh create mode 100644 apps/webapp/styles/tailwind-include.css create mode 100644 apps/webapp/tailwind.config.js create mode 100644 apps/webapp/test/setup-test-env.ts create mode 100644 apps/webapp/tsconfig.json create mode 100644 apps/webapp/vitest.config.ts create mode 100644 config-packages/eslint-config-custom-next/index.js create mode 100644 config-packages/eslint-config-custom-next/package.json create mode 100644 config-packages/eslint-config-custom/index.js create mode 100644 config-packages/eslint-config-custom/package.json create mode 100644 config-packages/eslint-config-vite/eslint-preset.js create mode 100644 config-packages/eslint-config-vite/package.json create mode 100644 config-packages/tailwind-config/package.json create mode 100644 config-packages/tailwind-config/postcss.config.js create mode 100644 config-packages/tailwind-config/tailwind.config.js create mode 100644 config-packages/tsconfig/base.json create mode 100644 config-packages/tsconfig/nextjs.json create mode 100644 config-packages/tsconfig/node18.json create mode 100644 config-packages/tsconfig/package.json create mode 100644 config-packages/tsconfig/react-library.json create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 pnpm-workspace.yaml create mode 100644 turbo.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..26feb14a1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,37 @@ +*.log +.git +.github +# editor +.idea +# dependencies +node_modules +.pnp +.pnp.js + +# testing +coverage + +# next.js +.next/ +build + +# packages +build +dist +packages/**/dist + +# misc +.DS_Store +*.pem + +.turbo +.vercel +.cache +.output +apps/**/public/build + +cypress/screenshots +cypress/videos + +apps/**/styles/tailwind.css +packages/**/styles/tailwind.css \ No newline at end of file diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 000000000..827344f1f --- /dev/null +++ b/.eslintignore @@ -0,0 +1,4 @@ +*/**.js +*/**.d.ts +packages/*/dist +packages/*/lib \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 000000000..af2839164 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,14 @@ +module.exports = { + root: true, + // This tells ESLint to load the config from the package `eslint-config-custom` + extends: ["custom"], + settings: { + next: { + rootDir: ["apps/*/"], + }, + }, + parserOptions: { + sourceType: "module", + ecmaVersion: 2020, + }, +}; diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..b510da33b --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +postgres-data +# dependencies +node_modules +.pnp +.pnp.js + +# testing +coverage + +# next.js +.next/ +out/ +build +dist +packages/**/dist + +# Tailwind +apps/**/styles/tailwind.css +packages/**/styles/tailwind.css + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.docker +.env.local +.env.development.local +.env.test.local +.env.production.local + +# turbo +.turbo +.vercel +.cache +.env +.output +apps/**/public/build \ No newline at end of file diff --git a/README.md b/README.md index ca33fc431..84f625c27 100644 --- a/README.md +++ b/README.md @@ -105,3 +105,67 @@ new Workflow({ }, }).listen(); ``` + +## Development + +> **Warning** +> All the following commands should be launched from the **monorepo root directory** + +1. Install the dependencies. + ```bash + pnpm install + ``` + You also have to copy the example .env.example: + ```sh + cp .env.example .env + cp .env.example .env.docker + ``` +2. Start the postgresql docker container + ```bash + pnpm run docker:db + ``` + > **Note:** The npm script will complete while Docker sets up the container in the background. Ensure that Docker has finished and your container is running before proceeding. + +3. Generate prisma schema + ```bash + pnpm run generate + ``` +4. Run the Prisma migration to the database + ```bash + pnpm run db:migrate:deploy + ``` +5. Run the first build (with dependencies via the `...` option) + ```bash + pnpm run build --filter=webapp... + ``` + **Running simply `pnpm run build` will build everything, including the NextJS app.** +6. Run the Remix dev server + ```bash + pnpm run dev --filter=webapp + ``` +## Tests, Typechecks, Lint, Install packages... +Check the `turbo.json` file to see the available pipelines. +- Run the Cypress tests and Dev + ```bash + pnpm run test:e2e:dev --filter=webapp + ``` +- Lint everything + ```bash + pnpm run lint + ``` +- Typecheck the whole monorepo + ```bash + pnpm run typecheck + ``` +- Test the whole monorepo + ```bash + pnpm run test + or + pnpm run test:dev + ``` +- How to install an npm package in the Remix app ? + ```bash + pnpm add dayjs --filter webapp + ``` +- Tweak the tsconfigs, eslint configs in the `config-package` folder. Any package or app will then extend from these configs. + diff --git a/apps/webapp/.eslintrc b/apps/webapp/.eslintrc new file mode 100644 index 000000000..778ebab8a --- /dev/null +++ b/apps/webapp/.eslintrc @@ -0,0 +1,7 @@ +{ + "extends": [ + "@remix-run/eslint-config", + "@remix-run/eslint-config/node", + "prettier" + ] +} diff --git a/apps/webapp/.gitignore b/apps/webapp/.gitignore new file mode 100644 index 000000000..ec7688ea2 --- /dev/null +++ b/apps/webapp/.gitignore @@ -0,0 +1,10 @@ +node_modules + +/.cache +/build +/public/build + +/cypress/screenshots +/cypress/videos + +/app/styles/tailwind.css \ No newline at end of file diff --git a/apps/webapp/.prettierignore b/apps/webapp/.prettierignore new file mode 100644 index 000000000..835d1a6cd --- /dev/null +++ b/apps/webapp/.prettierignore @@ -0,0 +1,11 @@ +node_modules + +/build +/public/build +.env + +/cypress/screenshots +/cypress/videos +/postgres-data + +/app/styles/tailwind.css \ No newline at end of file diff --git a/apps/webapp/Dockerfile b/apps/webapp/Dockerfile new file mode 100644 index 000000000..0c777dbfc --- /dev/null +++ b/apps/webapp/Dockerfile @@ -0,0 +1,64 @@ +FROM node:lts-bullseye-slim AS pruner +RUN apt-get update && apt-get install -y openssl +WORKDIR /app +RUN yarn global add turbo +COPY . . +RUN turbo prune --scope=webapp --docker +RUN find . -name "node_modules" -type d -prune -exec rm -rf '{}' + + +# Base strategy to have layer caching +FROM node:lts-bullseye-slim AS base +RUN apt-get update && apt-get install -y openssl +WORKDIR /app +COPY .gitignore .gitignore +COPY --from=pruner /app/out/json/ . +COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml +COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml +# COPY --from=pruner /app/out/full/ . + +FROM base AS dev-deps +WORKDIR /app +RUN corepack enable +RUN pnpm install --ignore-scripts --frozen-lockfile + +FROM base AS production-deps +WORKDIR /app +RUN corepack enable +ENV NODE_ENV production +RUN pnpm install --prod --frozen-lockfile +COPY --from=pruner /app/out/full/apps/webapp/prisma/schema.prisma /app/apps/webapp/prisma/schema.prisma +RUN pnpx prisma generate --schema /app/apps/webapp/prisma/schema.prisma + +FROM base AS builder +WORKDIR /app +RUN corepack enable +ENV NODE_ENV production +COPY --from=pruner /app/out/full/ . +COPY --from=dev-deps /app/ . +COPY turbo.json turbo.json +RUN pnpm run generate +RUN pnpm run build --filter=webapp... + +# Runner +FROM node:lts-bullseye-slim AS runner +RUN apt-get update && apt-get install -y openssl +WORKDIR /app +RUN corepack enable +ENV NODE_ENV production +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 remixjs +RUN chown -R remixjs:nodejs /app +USER remixjs + +COPY --from=pruner --chown=remixjs:nodejs /app/out/full/ . +COPY --from=production-deps --chown=remixjs:nodejs /app . +COPY --from=builder --chown=remixjs:nodejs /app/apps/webapp/app/styles/tailwind.css ./apps/webapp/app/styles/tailwind.css +COPY --from=builder --chown=remixjs:nodejs /app/apps/webapp/build/server.js ./apps/webapp/build/server.js +COPY --from=builder --chown=remixjs:nodejs /app/apps/webapp/build ./apps/webapp/build +COPY --from=builder --chown=remixjs:nodejs /app/apps/webapp/public ./apps/webapp/public +COPY --from=builder --chown=remixjs:nodejs /app/apps/webapp/prisma/schema.prisma ./apps/webapp/build/schema.prisma +COPY --from=builder --chown=remixjs:nodejs /app/apps/webapp/prisma/migrations ./apps/webapp/build/migrations +COPY --from=builder --chown=remixjs:nodejs /app/apps/webapp/node_modules/.prisma/client/libquery_engine-debian-openssl-1.1.x.so.node ./apps/webapp/build/libquery_engine-debian-openssl-1.1.x.so.node + +# release_command = "pnpx prisma migrate deploy --schema apps/webapp/prisma/schema.prisma" +CMD ["pnpm", "--filter", "webapp", "run", "start"] \ No newline at end of file diff --git a/apps/webapp/README.md b/apps/webapp/README.md new file mode 100644 index 000000000..c83d00239 --- /dev/null +++ b/apps/webapp/README.md @@ -0,0 +1,10 @@ +## API Hero webapp - powered by Remix + +To start, run with `pnpm run dev --filter webapp` + +### Build the docker image locally: + +```sh +pnpm run docker:build:webapp +docker run -it apihero-webapp sh +``` diff --git a/apps/webapp/app/components/CopyTextButton.tsx b/apps/webapp/app/components/CopyTextButton.tsx new file mode 100644 index 000000000..7f68bb65b --- /dev/null +++ b/apps/webapp/app/components/CopyTextButton.tsx @@ -0,0 +1,55 @@ +import { ClipboardIcon } from "@heroicons/react/24/outline"; +import classNames from "classnames"; +import { useCallback, useState } from "react"; +import { CopyText } from "../libraries/ui/src/components/CopyText"; + +const variantStyle = { + slate: + "bg-slate-600 text-white transition hover:text-slate-700 hover:bg-slate-700 hover:bg-slate-700 hover:text-slate-100 active:bg-slate-800 active:text-slate-300 focus-visible:outline-slate-900", + blue: "bg-blue-500 transition text-white hover:text-slate-100 hover:bg-blue-600 active:bg-blue-800 active:text-blue-100 focus-visible:outline-blue-600", + darkTransparent: + "bg-black/10 text-slate-900 transition hover:bg-blue-50 active:bg-blue-200 active:text-slate-600 focus-visible:outline-white", + lightTransparent: + "bg-white/10 text-white-900 transition hover:bg-blue-50 active:bg-blue-200 active:text-slate-600 focus-visible:outline-white", +}; + +export type CopyTextButtonProps = { + value: string; + className?: string; + variant?: "slate" | "blue" | "darkTransparent" | "lightTransparent"; +}; + +export function CopyTextButton({ + value, + className, + variant = "blue", +}: CopyTextButtonProps) { + const [copied, setCopied] = useState(false); + const onCopied = useCallback(() => { + setCopied(true); + setTimeout(() => { + setCopied(false); + }, 1500); + }, [setCopied]); + return ( + + {copied ? ( +
+

+ Copied! +

+
+ ) : ( +
+ +

Copy

+
+ )} +
+ ); +} diff --git a/apps/webapp/app/components/code/JSONEditor.tsx b/apps/webapp/app/components/code/JSONEditor.tsx new file mode 100644 index 000000000..eddc19e2c --- /dev/null +++ b/apps/webapp/app/components/code/JSONEditor.tsx @@ -0,0 +1,88 @@ +import { json as jsonLang } from "@codemirror/lang-json"; +import type { ViewUpdate } from "@codemirror/view"; +import type { + ReactCodeMirrorProps, + UseCodeMirror, +} from "@uiw/react-codemirror"; +import { useCodeMirror } from "@uiw/react-codemirror"; +import clsx from "clsx"; +import { useRef, useEffect } from "react"; +import { getEditorSetup } from "./codeMirrorSetup"; +import { lightTheme } from "./codeMirrorTheme"; + +export interface JSONEditorProps extends Omit { + content: string; + language?: "json"; + readOnly?: boolean; + onChange?: (value: string) => void; + onUpdate?: (update: ViewUpdate) => void; + onBlur?: (code: string) => void; +} + +const languages = { + json: jsonLang, +}; + +type JSONEditorDefaultProps = Partial; + +const defaultProps: JSONEditorDefaultProps = { + language: "json", + readOnly: true, + basicSetup: false, +}; + +export function JSONEditor(opts: JSONEditorProps) { + const { + content, + language, + readOnly, + onChange, + onUpdate, + onBlur, + basicSetup, + } = { + ...defaultProps, + ...opts, + }; + + const extensions = getEditorSetup(); + + if (!language) throw new Error("language is required"); + const languageExtension = languages[language]; + + extensions.push(languageExtension()); + + const editor = useRef(null); + const settings: Omit = { + ...opts, + container: editor.current, + extensions, + editable: !readOnly, + contentEditable: !readOnly, + value: content, + autoFocus: false, + theme: lightTheme(), + indentWithTab: false, + basicSetup, + onChange, + onUpdate, + }; + const { setContainer } = useCodeMirror(settings); + + useEffect(() => { + if (editor.current) { + setContainer(editor.current); + } + }, [setContainer]); + + return ( +
{ + if (!onBlur) return; + onBlur(editor.current?.textContent ?? ""); + }} + /> + ); +} diff --git a/apps/webapp/app/components/code/JavascriptEditor.tsx b/apps/webapp/app/components/code/JavascriptEditor.tsx new file mode 100644 index 000000000..e71e2c8ff --- /dev/null +++ b/apps/webapp/app/components/code/JavascriptEditor.tsx @@ -0,0 +1,79 @@ +import { javascript } from "@codemirror/lang-javascript"; +import type { ViewUpdate } from "@codemirror/view"; +import type { + ReactCodeMirrorProps, + UseCodeMirror, +} from "@uiw/react-codemirror"; +import { useCodeMirror } from "@uiw/react-codemirror"; +import clsx from "clsx"; +import { useRef, useEffect } from "react"; +import { getEditorSetup } from "./codeMirrorSetup"; +import { darkTheme } from "./codeMirrorTheme"; + +export interface CodeEditorProps extends Omit { + content: string; + language?: "typescript" | "shell"; + showLineNumbers?: boolean; + showHighlights?: boolean; + readOnly?: boolean; + onChange?: (value: string) => void; + onUpdate?: (update: ViewUpdate) => void; + onBlur?: (code: string) => void; +} + +type CodeEditorDefaultProps = Partial; + +const defaultProps: CodeEditorDefaultProps = { + language: "typescript", + showLineNumbers: true, + showHighlights: true, + readOnly: true, + basicSetup: false, +}; + +export function CodeEditor(opts: CodeEditorProps) { + const { content, readOnly, onChange, onUpdate, onBlur } = { + ...defaultProps, + ...opts, + }; + + const extensions = getEditorSetup(opts.showLineNumbers, opts.showHighlights); + + if (opts.language === "typescript") { + extensions.push(javascript({ typescript: true })); + } + + const editor = useRef(null); + const settings: Omit = { + ...opts, + container: editor.current, + extensions, + editable: !readOnly, + contentEditable: !readOnly, + value: content, + autoFocus: false, + theme: darkTheme(), + indentWithTab: false, + basicSetup: false, + onChange, + onUpdate, + }; + const { setContainer } = useCodeMirror(settings); + + useEffect(() => { + if (editor.current) { + setContainer(editor.current); + } + }, [setContainer]); + + return ( +
{ + if (!onBlur) return; + onBlur(editor.current?.textContent ?? ""); + }} + /> + ); +} diff --git a/apps/webapp/app/components/code/codeMirrorSetup.ts b/apps/webapp/app/components/code/codeMirrorSetup.ts new file mode 100644 index 000000000..1a0fcfe27 --- /dev/null +++ b/apps/webapp/app/components/code/codeMirrorSetup.ts @@ -0,0 +1,56 @@ +import { + highlightSpecialChars, + drawSelection, + highlightActiveLine, + dropCursor, + lineNumbers, + highlightActiveLineGutter, +} from "@codemirror/view"; +import type { Extension } from "@codemirror/state"; +import { highlightSelectionMatches } from "@codemirror/search"; +import { json as jsonLang } from "@codemirror/lang-json"; +import { closeBrackets } from "@codemirror/autocomplete"; +import { bracketMatching } from "@codemirror/language"; + +export function getPreviewSetup(): Array { + return [ + jsonLang(), + highlightSpecialChars(), + drawSelection(), + dropCursor(), + bracketMatching(), + highlightSelectionMatches(), + lineNumbers(), + ]; +} + +export function getViewerSetup(): Array { + return [drawSelection(), dropCursor(), bracketMatching(), lineNumbers()]; +} + +export function getEditorSetup( + showLineNumbers = true, + showHighlights = true +): Array { + const options = [ + drawSelection(), + dropCursor(), + bracketMatching(), + closeBrackets(), + ]; + + if (showLineNumbers) { + options.push(lineNumbers()); + } + + if (showHighlights) { + options.push([ + highlightActiveLineGutter(), + highlightSpecialChars(), + highlightActiveLine(), + highlightSelectionMatches(), + ]); + } + + return options; +} diff --git a/apps/webapp/app/components/code/codeMirrorTheme.ts b/apps/webapp/app/components/code/codeMirrorTheme.ts new file mode 100644 index 000000000..00ff1fcc1 --- /dev/null +++ b/apps/webapp/app/components/code/codeMirrorTheme.ts @@ -0,0 +1,321 @@ +import { EditorView } from "@codemirror/view"; +import type { Extension } from "@codemirror/state"; +import { HighlightStyle } from "@codemirror/language"; +import { tagHighlighter, tags } from "@lezer/highlight"; +import { syntaxHighlighting } from "@codemirror/language"; + +export function darkTheme(): Extension { + const chalky = "#e5c07b", + coral = "#e06c75", + cyan = "#56b6c2", + invalid = "#ffffff", + ivory = "#abb2bf", + stone = "#7d8799", + malibu = "#61afef", + sage = "#98c379", + whiskey = "#d19a66", + violet = "#c678dd", + darkBackground = "#21252b", + highlightBackground = "rgba(234,179,8,0.3)", + background = "rgb(51 65 85)", + tooltipBackground = "#353a42", + selection = "rgb(71 85 105)", + cursor = "#528bff"; + + const jsonHeroEditorTheme = EditorView.theme( + { + "&": { + color: ivory, + backgroundColor: background, + }, + + ".cm-content": { + caretColor: cursor, + fontFamily: "monospace", + fontSize: "14px", + }, + + ".cm-cursor, .cm-dropCursor": { borderLeftColor: cursor }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": + { backgroundColor: selection }, + + ".cm-panels": { backgroundColor: darkBackground, color: ivory }, + ".cm-panels.cm-panels-top": { borderBottom: "2px solid black" }, + ".cm-panels.cm-panels-bottom": { borderTop: "2px solid black" }, + + ".cm-searchMatch": { + backgroundColor: "#72a1ff59", + outline: "1px solid #457dff", + }, + ".cm-searchMatch.cm-searchMatch-selected": { + backgroundColor: "#6199ff2f", + }, + + ".cm-activeLine": { backgroundColor: highlightBackground }, + ".cm-selectionMatch": { backgroundColor: "#aafe661a" }, + + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + backgroundColor: "#bad0f847", + outline: "1px solid #515a6b", + }, + + ".cm-gutters": { + backgroundColor: background, + color: stone, + border: "none", + }, + + ".cm-activeLineGutter": { + backgroundColor: highlightBackground, + }, + + ".cm-foldPlaceholder": { + backgroundColor: "transparent", + border: "none", + color: "#ddd", + }, + + ".cm-tooltip": { + border: "none", + backgroundColor: tooltipBackground, + }, + ".cm-tooltip .cm-tooltip-arrow:before": { + borderTopColor: "transparent", + borderBottomColor: "transparent", + }, + ".cm-tooltip .cm-tooltip-arrow:after": { + borderTopColor: tooltipBackground, + borderBottomColor: tooltipBackground, + }, + ".cm-tooltip-autocomplete": { + "& > ul > li[aria-selected]": { + backgroundColor: highlightBackground, + color: ivory, + }, + }, + }, + { dark: true } + ); + + /// The highlighting style for code in the JSON Hero theme. + const jsonHeroHighlightStyle = HighlightStyle.define([ + { tag: tags.keyword, color: violet }, + { + tag: [ + tags.name, + tags.deleted, + tags.character, + tags.propertyName, + tags.macroName, + ], + color: coral, + }, + { tag: [tags.function(tags.variableName), tags.labelName], color: malibu }, + { + tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)], + color: whiskey, + }, + { tag: [tags.definition(tags.name), tags.separator], color: ivory }, + { + tag: [ + tags.typeName, + tags.className, + tags.number, + tags.changed, + tags.annotation, + tags.modifier, + tags.self, + tags.namespace, + ], + color: chalky, + }, + { + tag: [ + tags.operator, + tags.operatorKeyword, + tags.url, + tags.escape, + tags.regexp, + tags.link, + tags.special(tags.string), + ], + color: cyan, + }, + { tag: [tags.meta, tags.comment], color: stone }, + { tag: tags.strong, fontWeight: "bold" }, + { tag: tags.emphasis, fontStyle: "italic" }, + { tag: tags.strikethrough, textDecoration: "line-through" }, + { tag: tags.link, color: stone, textDecoration: "underline" }, + { tag: tags.heading, fontWeight: "bold", color: coral }, + { + tag: [tags.atom, tags.bool, tags.special(tags.variableName)], + color: whiskey, + }, + { + tag: [tags.processingInstruction, tags.string, tags.inserted], + color: sage, + }, + { tag: tags.invalid, color: invalid }, + ]); + + return [jsonHeroEditorTheme, syntaxHighlighting(jsonHeroHighlightStyle)]; +} + +export function lightTheme(): Extension[] { + const stringColor = "text-[#53a053]", + numberColor = "text-[#447bef]", + variableColor = "text-[#a42ea2]", + booleanColor = "text-[#e2574e]", + coral = "text-[#e06c75]", + invalid = "text-[#ffffff]", + ivory = "text-[#abb2bf]", + stone = "text-[#7d8799]", + malibu = "text-[#61afef]", + whiskey = "text-[#d19a66]", + violet = "text-[#c678dd]", + darkBackground = "text-[#21252b]", + highlightBackground = "text-[#D0D0D0]", + background = "text-[#ffffff]", + tooltipBackground = "text-[#353a42]", + selection = "text-[#D0D0D0]", + cursor = "text-[#528bff]"; + + const jsonHeroEditorTheme = EditorView.theme( + { + "&": { + color: ivory, + backgroundColor: background, + }, + + ".cm-content": { + caretColor: cursor, + fontFamily: "monospace", + fontSize: "14px", + }, + + ".cm-cursor, .cm-dropCursor": { borderLeftColor: cursor }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": + { backgroundColor: selection }, + + ".cm-panels": { backgroundColor: darkBackground, color: ivory }, + ".cm-panels.cm-panels-top": { borderBottom: "2px solid black" }, + ".cm-panels.cm-panels-bottom": { borderTop: "2px solid black" }, + + ".cm-searchMatch": { + backgroundColor: "#72a1ff59", + outline: "1px solid #457dff", + }, + ".cm-searchMatch.cm-searchMatch-selected": { + backgroundColor: "#6199ff2f", + }, + + ".cm-activeLine": { backgroundColor: highlightBackground }, + ".cm-selectionMatch": { backgroundColor: "#aafe661a" }, + + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + backgroundColor: "#bad0f847", + outline: "1px solid #515a6b", + }, + + ".cm-gutters": { + backgroundColor: background, + color: stone, + border: "none", + }, + + ".cm-activeLineGutter": { + backgroundColor: highlightBackground, + }, + + ".cm-foldPlaceholder": { + backgroundColor: "transparent", + border: "none", + color: "#ddd", + }, + + ".cm-tooltip": { + border: "none", + backgroundColor: tooltipBackground, + }, + ".cm-tooltip .cm-tooltip-arrow:before": { + borderTopColor: "transparent", + borderBottomColor: "transparent", + }, + ".cm-tooltip .cm-tooltip-arrow:after": { + borderTopColor: tooltipBackground, + borderBottomColor: tooltipBackground, + }, + ".cm-tooltip-autocomplete": { + "& > ul > li[aria-selected]": { + backgroundColor: highlightBackground, + color: ivory, + }, + }, + }, + { dark: false } + ); + + /// The highlighting style for code in the JSON Hero theme. + const jsonHeroHighlightStyle = tagHighlighter([ + { tag: tags.keyword, class: violet }, + { + tag: [ + tags.name, + tags.deleted, + tags.character, + tags.propertyName, + tags.macroName, + ], + class: variableColor, + }, + { + tag: [tags.function(tags.variableName), tags.labelName], + class: malibu, + }, + { + tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)], + class: whiskey, + }, + { tag: [tags.definition(tags.name), tags.separator], class: ivory }, + { + tag: [ + tags.typeName, + tags.className, + tags.number, + tags.changed, + tags.annotation, + tags.modifier, + tags.self, + tags.namespace, + ], + class: numberColor, + }, + { + tag: [ + tags.operator, + tags.operatorKeyword, + tags.url, + tags.escape, + tags.regexp, + tags.link, + tags.special(tags.string), + ], + class: stringColor, + }, + { tag: [tags.meta, tags.comment], class: stone }, + + { tag: tags.link, class: stone }, + { tag: tags.heading, class: coral }, + { + tag: [tags.atom, tags.bool, tags.special(tags.variableName)], + class: booleanColor, + }, + { + tag: [tags.processingInstruction, tags.string, tags.inserted], + class: stringColor, + }, + { tag: tags.invalid, class: invalid }, + ]); + + return [jsonHeroEditorTheme, syntaxHighlighting(jsonHeroHighlightStyle)]; +} diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx new file mode 100644 index 000000000..fb2aa62ea --- /dev/null +++ b/apps/webapp/app/components/primitives/Buttons.tsx @@ -0,0 +1,90 @@ +import { Link } from "@remix-run/react"; +import classnames from "classnames"; + +const commonClasses = + "inline-flex items-center justify-center rounded max-w-max px-4 py-2 gap-2 text-sm transition whitespace-nowrap"; +const primaryClasses = classnames( + commonClasses, + "bg-blue-500 text-white hover:bg-blue-600 focus:bg-blue-600" +); +const secondaryClasses = classnames( + commonClasses, + "bg-slate-600 text-white hover:bg-slate-700 focus:bg-slate-700" +); + +type ButtonProps = React.DetailedHTMLProps< + React.ButtonHTMLAttributes, + HTMLButtonElement +>; + +type LinkProps = Parameters[0]; + +type AProps = React.DetailedHTMLProps< + React.AnchorHTMLAttributes, + HTMLAnchorElement +>; + +export function PrimaryButton({ children, className, ...props }: ButtonProps) { + return ( + + ); +} + +export function SecondaryButton({ + children, + className, + ...props +}: ButtonProps) { + return ( + + ); +} + +export function PrimaryLink({ children, className, to, ...props }: LinkProps) { + return ( + + {children} + + ); +} + +export function SecondaryLink({ + children, + className, + to, + ...props +}: LinkProps) { + return ( + + {children} + + ); +} + +export function PrimaryA({ children, className, href, ...props }: AProps) { + return ( + + {children} + + ); +} + +export function SecondaryA({ children, className, href, ...props }: AProps) { + return ( + + {children} + + ); +} diff --git a/apps/webapp/app/components/primitives/Input.tsx b/apps/webapp/app/components/primitives/Input.tsx new file mode 100644 index 000000000..48a056fa9 --- /dev/null +++ b/apps/webapp/app/components/primitives/Input.tsx @@ -0,0 +1,34 @@ +import clsx from "clsx"; + +const roundedStyles = { + roundedLeft: + "rounded-l focus:outline-offset-[0px] focus:outline-blue-500 -mr-1", + roundedRight: + "rounded-r focus:outline-offset-[0px] focus:outline-blue-500 -ml-1", + roundedFull: "rounded focus:outline-offset-[0px] focus:outline-blue-500", +}; + +type InputProps = React.DetailedHTMLProps< + React.InputHTMLAttributes, + HTMLInputElement +> & { + roundedEdges?: "roundedLeft" | "roundedRight" | "roundedFull"; +}; + +export function Input({ + children, + className, + roundedEdges = "roundedFull", + ...props +}: InputProps) { + const classes = clsx(roundedStyles[roundedEdges], className); + + return ( + + {children} + + ); +} diff --git a/apps/webapp/app/components/primitives/Select.tsx b/apps/webapp/app/components/primitives/Select.tsx new file mode 100644 index 000000000..8bbe43482 --- /dev/null +++ b/apps/webapp/app/components/primitives/Select.tsx @@ -0,0 +1,17 @@ +import classNames from "classnames"; + +type SelectProps = React.DetailedHTMLProps< + React.SelectHTMLAttributes, + HTMLSelectElement +>; + +const defaultClasses = + "mt-1 block w-full rounded-md border-gray-300 py-1 pl-2 pr-10 text-base focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm"; + +export function Select({ children, className, ...props }: SelectProps) { + return ( + + ); +} diff --git a/apps/webapp/app/components/primitives/Spinner.tsx b/apps/webapp/app/components/primitives/Spinner.tsx new file mode 100644 index 000000000..bfc074834 --- /dev/null +++ b/apps/webapp/app/components/primitives/Spinner.tsx @@ -0,0 +1,28 @@ +export function Spinner() { + return ( + + + + + ); +} diff --git a/apps/webapp/app/components/primitives/Tabs.tsx b/apps/webapp/app/components/primitives/Tabs.tsx new file mode 100644 index 000000000..96f8dc903 --- /dev/null +++ b/apps/webapp/app/components/primitives/Tabs.tsx @@ -0,0 +1,98 @@ +import { Tab as HeadlessTab } from "@headlessui/react"; +import classNames from "classnames"; +import classnames from "classnames"; + +type HeadlessTabProps = Parameters[0]; +type HeadlessTabListProps = Parameters[0]; + +export function ClassicList({ children, ...props }: HeadlessTabListProps) { + return ( + + {children} + + ); +} + +export function Classic({ children, ...props }: HeadlessTabProps) { + return ( + + classnames( + selected + ? "border-t border-slate-200 bg-white text-slate-600" + : "border-b border-t border-slate-200 bg-slate-50 text-slate-700 hover:border-slate-200 hover:text-slate-800", + "flex whitespace-nowrap border-r py-3 px-3 text-xs focus:outline-none" + ) + } + {...props} + > + {children} + + ); +} + +export function UnderlinedList({ children, ...props }: HeadlessTabListProps) { + return ( + + {children} + + ); +} + +export function Underlined({ children, ...props }: HeadlessTabProps) { + return ( + + classnames( + selected + ? "border-blue-500 text-slate-900 outline-none" + : "border-transparent text-slate-800 hover:border-slate-200 hover:text-slate-700", + "disabled:text-slate-300 disabled:hover:border-transparent", + "flex whitespace-nowrap border-b-2 py-2 px-4 text-xs font-medium" + ) + } + {...props} + > + {children} + + ); +} + +export function SegmentedList({ + children, + className, + ...props +}: HeadlessTabListProps) { + return ( + + {children} + + ); +} + +export function Segmented({ children, ...props }: HeadlessTabProps) { + return ( + + classnames( + selected + ? "bg-blue-500 text-white rounded shadow outline-none" + : "text-slate-800 hover:bg-slate-300 rounded hover:text-slate-700 hover:shadow-none transition", + "flex whitespace-nowrap py-2 px-4 text-xs font-medium" + ) + } + {...props} + > + {children} + + ); +} diff --git a/apps/webapp/app/components/primitives/text/Body.tsx b/apps/webapp/app/components/primitives/text/Body.tsx new file mode 100644 index 000000000..af266c166 --- /dev/null +++ b/apps/webapp/app/components/primitives/text/Body.tsx @@ -0,0 +1,8 @@ +export type BodyProps = { + children: React.ReactNode; + className?: string; +}; + +export function Body({ children, className }: BodyProps) { + return

{children}

; +} diff --git a/apps/webapp/app/components/primitives/text/BodyBold.tsx b/apps/webapp/app/components/primitives/text/BodyBold.tsx new file mode 100644 index 000000000..18e8cc18e --- /dev/null +++ b/apps/webapp/app/components/primitives/text/BodyBold.tsx @@ -0,0 +1,12 @@ +export type BodyBoldProps = { + children: React.ReactNode; + className: string; +}; + +export function BodyBold({ children, className }: BodyBoldProps) { + return ( +

+ {children} +

+ ); +} diff --git a/apps/webapp/app/components/primitives/text/ExtraLargeTitle.tsx b/apps/webapp/app/components/primitives/text/ExtraLargeTitle.tsx new file mode 100644 index 000000000..60aed1867 --- /dev/null +++ b/apps/webapp/app/components/primitives/text/ExtraLargeTitle.tsx @@ -0,0 +1,8 @@ +export type ExtraLargeTitleProps = { + children: React.ReactNode; + className: string; +}; + +export function ExtraLargeTitle({ children, className }: ExtraLargeTitleProps) { + return

{children}

; +} diff --git a/apps/webapp/app/components/primitives/text/ExtraSmallBody.tsx b/apps/webapp/app/components/primitives/text/ExtraSmallBody.tsx new file mode 100644 index 000000000..6a927eedc --- /dev/null +++ b/apps/webapp/app/components/primitives/text/ExtraSmallBody.tsx @@ -0,0 +1,8 @@ +export type ExtraSmallBodyProps = { + children: React.ReactNode; + className?: string; +}; + +export function ExtraSmallBody({ children, className }: ExtraSmallBodyProps) { + return

{children}

; +} diff --git a/apps/webapp/app/components/primitives/text/LargeTitle.tsx b/apps/webapp/app/components/primitives/text/LargeTitle.tsx new file mode 100644 index 000000000..6b957d866 --- /dev/null +++ b/apps/webapp/app/components/primitives/text/LargeTitle.tsx @@ -0,0 +1,8 @@ +export type LargeTitleProps = { + children: React.ReactNode; + className?: string; +}; + +export function LargeTitle({ children, className }: LargeTitleProps) { + return

{children}

; +} diff --git a/apps/webapp/app/components/primitives/text/SmallBody.tsx b/apps/webapp/app/components/primitives/text/SmallBody.tsx new file mode 100644 index 000000000..e770abbc3 --- /dev/null +++ b/apps/webapp/app/components/primitives/text/SmallBody.tsx @@ -0,0 +1,8 @@ +export type SmallBodyProps = { + children: React.ReactNode; + className: string; +}; + +export function SmallBody({ children, className }: SmallBodyProps) { + return

{children}

; +} diff --git a/apps/webapp/app/components/primitives/text/SmallTitle.tsx b/apps/webapp/app/components/primitives/text/SmallTitle.tsx new file mode 100644 index 000000000..7579cf7fd --- /dev/null +++ b/apps/webapp/app/components/primitives/text/SmallTitle.tsx @@ -0,0 +1,8 @@ +export type SmallTitleProps = { + children: React.ReactNode; + className?: string; +}; + +export function SmallTitle({ children, className }: SmallTitleProps) { + return

{children}

; +} diff --git a/apps/webapp/app/components/primitives/text/Title.tsx b/apps/webapp/app/components/primitives/text/Title.tsx new file mode 100644 index 000000000..565763869 --- /dev/null +++ b/apps/webapp/app/components/primitives/text/Title.tsx @@ -0,0 +1,8 @@ +export type TitleProps = { + children: React.ReactNode; + className?: string; +}; + +export function Title({ children, className }: TitleProps) { + return

{children}

; +} diff --git a/apps/webapp/app/db.server.ts b/apps/webapp/app/db.server.ts new file mode 100644 index 000000000..e3a94d313 --- /dev/null +++ b/apps/webapp/app/db.server.ts @@ -0,0 +1,71 @@ +import { PrismaClient, Prisma } from ".prisma/client"; +import invariant from "tiny-invariant"; +import { fieldEncryptionMiddleware } from "prisma-field-encryption"; + +let prisma: PrismaClient; + +declare global { + var __db__: PrismaClient; +} + +// this is needed because in development we don't want to restart +// the server with every change, but we want to make sure we don't +// create a new connection to the DB with every change either. +// in production we'll have a single connection to the DB. +if (process.env.NODE_ENV === "production") { + prisma = getClient(); +} else { + if (!global.__db__) { + global.__db__ = getClient(); + } + prisma = global.__db__; +} + +function getClient() { + const { DATABASE_URL } = process.env; + invariant(typeof DATABASE_URL === "string", "DATABASE_URL env var not set"); + + const databaseUrl = new URL(DATABASE_URL); + + const isLocalHost = databaseUrl.hostname === "localhost"; + + const PRIMARY_REGION = isLocalHost ? null : process.env.PRIMARY_REGION; + const FLY_REGION = isLocalHost ? null : process.env.FLY_REGION; + + const isReadReplicaRegion = !PRIMARY_REGION || PRIMARY_REGION === FLY_REGION; + + if (!isLocalHost) { + databaseUrl.host = `${FLY_REGION}.${databaseUrl.host}`; + if (!isReadReplicaRegion) { + // 5433 is the read-replica port + databaseUrl.port = "5433"; + } + } + + console.log(`🔌 setting up prisma client to ${databaseUrl.host}`); + // NOTE: during development if you change anything in this function, remember + // that this only runs once per server restart and won't automatically be + // re-run per request like everything else is. So if you need to change + // something in this file, you'll need to manually restart the server. + const client = new PrismaClient({ + datasources: { + db: { + url: databaseUrl.toString(), + }, + }, + }); + + client.$use( + fieldEncryptionMiddleware({ + dmmf: Prisma.dmmf, + }) + ); + + // connect eagerly + client.$connect(); + + return client; +} + +export { prisma }; +export type { PrismaClient } from ".prisma/client"; diff --git a/apps/webapp/app/entry.client.tsx b/apps/webapp/app/entry.client.tsx new file mode 100644 index 000000000..6342ff939 --- /dev/null +++ b/apps/webapp/app/entry.client.tsx @@ -0,0 +1,22 @@ +import { RemixBrowser, useLocation, useMatches } from "@remix-run/react"; +import { hydrate } from "react-dom"; +import * as Sentry from "@sentry/remix"; +import { useEffect } from "react"; + +hydrate(, document); + +if (process.env.NODE_ENV === "production") { + Sentry.init({ + dsn: "https://a014169306c748b1adf61875c64b90de:a7fa7bfcc28d43e1bd293e121c677e4a@o4504169280569344.ingest.sentry.io/4504169281880064", + tracesSampleRate: 1, + integrations: [ + new Sentry.BrowserTracing({ + routingInstrumentation: Sentry.remixRouterInstrumentation( + useEffect, + useLocation, + useMatches + ), + }), + ], + }); +} diff --git a/apps/webapp/app/entry.server.tsx b/apps/webapp/app/entry.server.tsx new file mode 100644 index 000000000..fdf5da7c6 --- /dev/null +++ b/apps/webapp/app/entry.server.tsx @@ -0,0 +1,41 @@ +// import * as apihero from "~/services/apihero.server"; +import type { EntryContext } from "@remix-run/node"; +import { RemixServer } from "@remix-run/react"; +import { renderToString } from "react-dom/server"; +import * as Sentry from "@sentry/remix"; +import { prisma } from "./db.server"; +import { env } from "./env.server"; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + // deepcode ignore Ssti: + const markup = renderToString( + // deepcode ignore OR: + + ); + + responseHeaders.set("Content-Type", "text/html; charset=utf-8"); + + return new Response("" + markup, { + status: responseStatusCode, + headers: responseHeaders, + }); +} + +if (process.env.NODE_ENV === "production") { + Sentry.init({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1, + integrations: [new Sentry.Integrations.Prisma({ client: prisma })], + }); + + console.log("🚦 Sentry initialized"); +} + +// apihero.proxy.start(() => { +// console.info("🔶 API Hero proxy running"); +// }); diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts new file mode 100644 index 000000000..f6e8737e2 --- /dev/null +++ b/apps/webapp/app/env.server.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +const EnvironmentSchema = z.object({ + APP_ORIGIN: z.string().default("https://app.trigger.dev"), + SENTRY_DSN: z + .string() + .default( + "https://a014169306c748b1adf61875c64b90de:a7fa7bfcc28d43e1bd293e121c677e4a@o4504169280569344.ingest.sentry.io/4504169281880064" + ), +}); + +export type Environment = z.infer; + +export const env = EnvironmentSchema.parse(process.env); diff --git a/apps/webapp/app/lib.es5.d.ts b/apps/webapp/app/lib.es5.d.ts new file mode 100644 index 000000000..b145864f6 --- /dev/null +++ b/apps/webapp/app/lib.es5.d.ts @@ -0,0 +1,13 @@ +type Falsy = false | 0 | "" | null | undefined; + +interface Array { + /** + * Returns the elements of an array that meet the condition specified in a callback function. + * @param predicate A function that accepts up to three arguments. The filter method calls the predicate function one time for each element in the array. + * @param thisArg An object to which the this keyword can refer in the predicate function. If thisArg is omitted, undefined is used as the this value. + */ + filter( + predicate: BooleanConstructor, + thisArg?: any + ): Exclude[]; +} diff --git a/apps/webapp/app/models/user.server.ts b/apps/webapp/app/models/user.server.ts new file mode 100644 index 000000000..55e58b771 --- /dev/null +++ b/apps/webapp/app/models/user.server.ts @@ -0,0 +1,116 @@ +import type { Prisma, User } from ".prisma/client"; +import type { GitHubProfile } from "remix-auth-github"; +import { prisma } from "~/db.server"; +export type { User } from ".prisma/client"; + +type FindOrCreateMagicLink = { + authenticationMethod: "MAGIC_LINK"; + email: string; +}; + +type FindOrCreateGithub = { + authenticationMethod: "GITHUB"; + email: User["email"]; + accessToken: User["accessToken"]; + authenticationProfile: GitHubProfile; + authenticationExtraParams: Record; +}; + +type FindOrCreateUser = FindOrCreateMagicLink | FindOrCreateGithub; + +type LoggedInUser = { + user: User; + isNewUser: boolean; +}; + +export async function findOrCreateUser( + input: FindOrCreateUser +): Promise { + switch (input.authenticationMethod) { + case "GITHUB": { + return findOrCreateGithubUser(input); + } + case "MAGIC_LINK": { + return findOrCreateMagicLinkUser(input); + } + } +} + +export async function findOrCreateMagicLinkUser( + input: FindOrCreateMagicLink +): Promise { + const existingUser = await prisma.user.findFirst({ + where: { + email: input.email, + }, + }); + + const user = await prisma.user.upsert({ + where: { + email: input.email, + }, + update: { email: input.email }, + create: { email: input.email, authenticationMethod: "MAGIC_LINK" }, + }); + + return { + user, + isNewUser: !existingUser, + }; +} + +export async function findOrCreateGithubUser({ + email, + accessToken, + authenticationProfile, + authenticationExtraParams, +}: FindOrCreateGithub): Promise { + const name = authenticationProfile._json.name; + let avatarUrl: string | undefined = undefined; + if (authenticationProfile.photos[0]) { + avatarUrl = authenticationProfile.photos[0].value; + } + const displayName = authenticationProfile.displayName; + const authProfile = authenticationProfile + ? (authenticationProfile as unknown as Prisma.JsonObject) + : undefined; + const authExtraParams = authenticationExtraParams + ? (authenticationExtraParams as unknown as Prisma.JsonObject) + : undefined; + + const fields = { + accessToken, + authenticationProfile: authProfile, + authenticationExtraParams: authExtraParams, + name, + avatarUrl, + displayName, + }; + + const existingUser = await prisma.user.findFirst({ + where: { + email, + }, + }); + + const user = await prisma.user.upsert({ + where: { + email, + }, + update: fields, + create: { ...fields, email, authenticationMethod: "GITHUB" }, + }); + + return { + user, + isNewUser: !existingUser, + }; +} + +export async function getUserById(id: User["id"]) { + return prisma.user.findUnique({ where: { id } }); +} + +export async function getUserByEmail(email: User["email"]) { + return prisma.user.findUnique({ where: { email } }); +} diff --git a/apps/webapp/app/root.tsx b/apps/webapp/app/root.tsx new file mode 100644 index 000000000..631fb8bf4 --- /dev/null +++ b/apps/webapp/app/root.tsx @@ -0,0 +1,122 @@ +import type { + LinksFunction, + LoaderFunction, + MetaFunction, +} from "@remix-run/node"; +import { json } from "@remix-run/node"; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, + useLoaderData, +} from "@remix-run/react"; + +import tailwindStylesheetUrl from "./styles/tailwind.css"; +import { getUser } from "./services/session.server"; + +import { Toaster, toast } from "react-hot-toast"; + +import type { ToastMessage } from "~/models/message.server"; +import { commitSession, getSession } from "~/models/message.server"; +import { useEffect, useRef } from "react"; +import posthog from "posthog-js"; +import { withSentry } from "@sentry/remix"; + +export const links: LinksFunction = () => { + return [{ rel: "stylesheet", href: tailwindStylesheetUrl }]; +}; + +export const meta: MetaFunction = () => ({ + charset: "utf-8", + title: "API Hero", + viewport: "width=device-width,initial-scale=1", +}); + +type LoaderData = { + user: Awaited>; + toastMessage: ToastMessage | null; + posthogProjectKey?: string; +}; + +export const loader: LoaderFunction = async ({ request }) => { + const session = await getSession(request.headers.get("cookie")); + const toastMessage = session.get("toastMessage") as ToastMessage; + const posthogProjectKey = process.env.POSTHOG_PROJECT_KEY; + + return json( + { + user: await getUser(request), + toastMessage, + posthogProjectKey, + }, + { headers: { "Set-Cookie": await commitSession(session) } } + ); +}; + +function App() { + const { toastMessage, posthogProjectKey, user } = useLoaderData(); + const postHogInitialised = useRef(false); + + useEffect(() => { + if (!toastMessage) { + return; + } + const { message, type } = toastMessage; + + switch (type) { + case "success": + toast.success(message); + break; + case "error": + toast.error(message); + break; + default: + throw new Error(`${type} is not handled`); + } + }, [toastMessage]); + + useEffect(() => { + if (posthogProjectKey !== undefined) { + posthog.init(posthogProjectKey, { + api_host: "https://app.posthog.com", + loaded: function (posthog) { + if (user !== null) { + posthog.identify(user.id, { email: user.email }); + } + }, + }); + postHogInitialised.current = true; + } + }); + + useEffect(() => { + if (postHogInitialised.current) { + if (user === null) { + posthog.reset(); + } else { + posthog.identify(user.id, { email: user.email }); + } + } + }, [user]); + + return ( + + + + + + + + + + + + + + ); +} + +export default withSentry(App); diff --git a/apps/webapp/app/routes/__app.tsx b/apps/webapp/app/routes/__app.tsx new file mode 100644 index 000000000..d95e9ce49 --- /dev/null +++ b/apps/webapp/app/routes/__app.tsx @@ -0,0 +1,34 @@ +import { Outlet, useMatches } from "@remix-run/react"; +import type { LoaderArgs } from "@remix-run/server-runtime"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import type { UseDataFunctionReturn } from "remix-typedjson/dist/remix"; +import { Footer, Header } from "~/libraries/ui"; +import WorkspaceMenu from "~/libraries/ui/src/components/WorkspaceMenu"; +import { getWorkspaces } from "~/models/workspace.server"; +import { clearRedirectTo, commitSession } from "~/services/redirectTo.server"; +import { requireUserId } from "~/services/session.server"; + +export type LoaderData = UseDataFunctionReturn; + +export async function loader({ request }: LoaderArgs) { + return typedjson( + { }, + { + headers: { + "Set-Cookie": await commitSession(await clearRedirectTo(request)), + }, + } + ); +} + +export default function AppLayout() { + + return ( +
+
+

Root

+
+ +
+ ); +} diff --git a/apps/webapp/app/routes/healthcheck.tsx b/apps/webapp/app/routes/healthcheck.tsx new file mode 100644 index 000000000..df0656b00 --- /dev/null +++ b/apps/webapp/app/routes/healthcheck.tsx @@ -0,0 +1,24 @@ +// learn more: https://fly.io/docs/reference/configuration/#services-http_checks +import { prisma } from "~/db.server"; +import type { LoaderFunction } from "@remix-run/node"; + +export const loader: LoaderFunction = async ({ request }) => { + const host = + request.headers.get("X-Forwarded-Host") ?? request.headers.get("host"); + + try { + const url = new URL("/", `http://${host}`); + // if we can connect to the database and make a simple query + // and make a HEAD request to ourselves, then we're good. + await Promise.all([ + prisma.user.count(), + fetch(url.toString(), { method: "HEAD" }).then((r) => { + if (!r.ok) return Promise.reject(r); + }), + ]); + return new Response("OK"); + } catch (error: unknown) { + console.log("healthcheck ❌", { error }); + return new Response("ERROR", { status: 500 }); + } +}; diff --git a/apps/webapp/app/routes/legal.tsx b/apps/webapp/app/routes/legal.tsx new file mode 100644 index 000000000..70804e8ff --- /dev/null +++ b/apps/webapp/app/routes/legal.tsx @@ -0,0 +1,60 @@ +import { BookOpenIcon } from "@heroicons/react/24/solid"; +import { Link, Outlet } from "@remix-run/react"; +import { Header } from "~/libraries/ui"; + +const pages = [ + { + title: "Terms of Service", + href: "/legal/terms", + }, + { + title: "Privacy Policy", + href: "/legal/privacy", + }, + { + title: "Abuse", + href: "/legal/abuse", + }, +]; + +export default function Legal() { + return ( +
+
+
Dashboard
+
+
+
    +
  • +
    +

    Legal stuff

    +
    + +
      + {pages.map((page) => ( +
    • + + +

      {page.title}

      + +
    • + ))} +
    +
  • +
+ +
+
+ +
+
+
+
+ ); +} diff --git a/apps/webapp/app/routes/legal/abuse.mdx b/apps/webapp/app/routes/legal/abuse.mdx new file mode 100644 index 000000000..8b322b722 --- /dev/null +++ b/apps/webapp/app/routes/legal/abuse.mdx @@ -0,0 +1,34 @@ +# Use Restrictions + +_Last updated: September 22, 2022_ + +We recognize that however good the maker’s intentions, technology can amplify the ability to cause great harm. That’s why we’ve established this policy. We feel an ethical obligation to counter such harm: both in terms of dealing with instances where API Hero is used (and abused) to further such harm, and to state unequivocally that the products we make at API Hero are not safe havens for people who wish to commit such harm. If you have an account with any of our products, you can’t use them for any of the restricted purposes listed below. + +## Restricted purposes + +- **Violence, or threats thereof**: If an activity qualifies as violent crime in the United States or where you live, you may not use API Hero products to plan, perpetrate, or threaten that activity. +- **Child exploitation, sexualization, or abuse**: We don’t tolerate any activities that create, disseminate, or otherwise cause child abuse. Keep away and stop. Just stop. +- **Hate speech**: You cannot use our products to advocate for the extermination, domination, or oppression of people. +- **Harassment**: Intimidating or targeting people or groups through repeated communication, including using racial slurs or dehumanizing language, is not welcome at API Hero. +- **Doxing**: If you are using API Hero products to share other peoples’ private personal information for the purposes of harassment, we don’t want anything to do with you. +- **Malware or spyware**: Code for good, not evil. If you are using our products to make or distribute anything that qualifies as malware or spyware — including remote user surveillance — begone. +- **Phishing or otherwise attempting fraud**: It is not okay to lie about who you are or who you affiliate with to steal from, extort, or otherwise harm others. +- **Spamming**: No one wants unsolicited commercial emails. We don’t tolerate folks (including their bots) using API Hero products for spamming purposes. If your emails don’t pass muster with [CAN-SPAM](https://www.ftc.gov/tips-advice/business-center/guidance/can-spam-act-compliance-guide-business) or any other anti-spam law, it’s not allowed. +- **Cybersquatting**: We don’t like username extortionists. If you purchase a API Hero product account in someone else’s name and then try to sell that account to them, you are [cybersquatting](https://www.law.cornell.edu/uscode/text/15/1125). Cybersquatting accounts are subject to immediate cancellation. +- **Infringing on intellectual property**: You can’t use API Hero products to make or disseminate work that uses the intellectual property of others beyond the bounds of [fair use](https://www.copyright.gov/fair-use/more-info.html). + +While our use restrictions are comprehensive, they can’t be exhaustive — it’s possible an offense could defy categorization, present for the first time, or illuminate a moral quandary we hadn’t yet considered. That said, we hope the overarching spirit is clear: API Hero is not to be harnessed for harm, whether mental, physical, personal or civic. Different points of view — philosophical, religious, and political — are welcome, but ideologies like white nationalism, or hate-fueled movements anchored by oppression, violence, abuse, extermination, or domination of one group over another, will not be accepted here. + +## How to report abuse + +For cases of suspected malware, spyware, phishing, spamming, and cybersquatting, please alert us at [hello@apihero.run](mailto:hello@apihero.run). + +For all other cases, please let us know by emailing [hello@apihero.run](mailto:hello@apihero.run). If you’re not 100% sure if something rises to the level of our use restrictions policy, report it anyway. + +Please share as much as you are comfortable with about the account, the content or behavior you are reporting, and how you found it. Sending us a URL or screenshots is super helpful. If you need a secure file transfer, let us know and we will send you a link. We will not disclose your identity to anyone associated with the reported account. + +Someone on our team will respond within one business day to let you know we’ve begun investigating. We will also let you know the outcome of our investigation (unless you ask us not to, or we are not allowed to under law). + +This policy and process applies to any product created and owned by API Hero Ltd. + +Adapted from the [Basecamp open-source policies](https://github.com/basecamp/policies) / [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) diff --git a/apps/webapp/app/routes/legal/privacy.mdx b/apps/webapp/app/routes/legal/privacy.mdx new file mode 100644 index 000000000..3c711412c --- /dev/null +++ b/apps/webapp/app/routes/legal/privacy.mdx @@ -0,0 +1,120 @@ +# Privacy policy + +_Last updated: September 22, 2022_ + +The privacy of your data—and it is your data, not ours!—is a big deal to us. In this policy, we lay out: what data we collect and why; how your data is handled; and your rights with respect to your data. We promise we never sell your data: never have, never will. + +This policy applies to all products built and maintained by API Hero Ltd. + +## What we collect and why + +Our guiding principle is to collect only what we need. Here’s what that means in practice: + +### Identity & access + +When you sign up for a API Hero product, we ask for identifying information such as your name, email address, and maybe a company name. That’s so you can personalize your new account, and we can send you product updates and other essential information. We may also send you optional surveys from time to time to help us understand how you use our products and to make improvements. With your consent, we will send you our newsletter and other updates. We sometimes also give you the option to add a profile picture that displays in our products. + +We’ll never sell your personal information to third parties, and we won’t use your name or company in marketing statements without your permission either. + +### Billing information + +If you sign up for a paid API Hero product, you will be asked to provide your payment information and billing address. Credit card information is submitted directly to our payment processor and doesn’t hit API Hero servers. We store a record of the payment transaction, including the last 4 digits of the credit card number, for purposes of account history, invoicing, and billing support. We store your billing address so we can charge you for service, calculate any sales tax due, send you invoices, and detect fraudulent credit card transactions. We occasionally use aggregate billing information to guide our marketing efforts. + +### Product interactions + +We store on our servers the content that you upload or receive or maintain in your API Hero product accounts. This is so you can use our products as intended, for example, to create projects in API Hero. We keep this content as long as your account is active. If you delete your account, we’ll delete the content within 60 days. + +### Geolocation data + +For most of our products, we log the full IP address used to sign up a product account and retain that for use in mitigating future spammy signups. We also log all account access by full IP address for security and fraud prevention purposes, and we keep this login data for as long as your product account is active. + +### Website interactions + +We collect information about your browsing activity for analytics and statistical purposes such as conversion rate testing and experimenting with new product designs. This includes, for example, your browser and operating system versions, your IP address, which web pages you visited and how long they took to load, and which website referred you to us. If you have an account and are signed in, these web analytics data are tied to your IP address and user account until your account is no longer active. The web analytics we use are described further in the Advertising and Cookies section. + +### Anti-bot assessments + +We use third-party [CAPTCHA](https://en.wikipedia.org/wiki/CAPTCHA) services across our applications to mitigate brute force logins. We have a legitimate interest in protecting our apps and the broader Internet community from credential stuffing attacks and spam. When you log into your API Hero accounts, the CAPTCHA service evaluates various information (e.g., IP address, how long the visitor has been on the app, mouse movements) to try to detect if the activity is from an automated program instead of a human. We retain these data via our subprocessor indefinitely for use in spam mitigation. + +### Advertising and Cookies + +API Hero runs contextual ads on various third-party platforms such as Google, Reddit, and LinkedIn. Users who click on one of our ads will be sent to the API Hero marketing site. Where permissible under law, we may load an ad-company script on their browsers that sets a third-party cookie and sends information to the ad network to enable evaluation of the effectiveness of our ads, e.g., which ad they clicked and which keyword triggered the ad, and whether they performed certain actions such as clicking a button or submitting a form. + +We also use persistent first-party cookies and some third-party cookies to store certain preferences, make it easier for you to use our applications, and perform A/B testing as well as support some analytics. + +A cookie is a piece of text stored by your browser. It may help remember login information and site preferences. It might also collect information such as your browser type, operating system, web pages visited, duration of visit, content viewed, and other click-stream data. You can adjust cookie retention settings and accept or block individual cookies in your browser settings, although our apps won’t work and other aspects of our service may not function properly if you turn cookies off. + +### Voluntary correspondence + +When you email API Hero with a question or to ask for help, we keep that correspondence, including your email address, so that we have a history of past correspondence to reference if you reach out in the future. + +We also store information you may volunteer, for example, written responses to surveys. If you agree to a customer interview, we may ask for your permission to record the conversation for future reference or use. We will only do so with your express consent. + +## When we access or share your information + +**To provide products or services you’ve requested**. We use some third-party subprocessors to help run our applications and provide the Services to you. We also use third-party processors for other business functions such as managing newsletter subscriptions, sending customer surveys, and providing our company storefront. + +We may share your information at your direction if you integrate a third-party service into your use of our products. + +**To exclude you from seeing our ads.** Where permissible by law and if you have an API Hero account, we may share a one-way hash of your email address with ad companies to exclude you from seeing our ads. + +**To help you troubleshoot or squash a software bug, with your permission.** If at any point we need to access your content to help you with a support case, we will ask for your consent before proceeding. + +**To investigate, prevent, or take action regarding [restricted uses](../abuse/index.md).** Accessing a customer’s account when investigating potential abuse is a measure of last resort. We want to protect the privacy and safety of both our customers and the people reporting issues to us, and we do our best to balance those responsibilities throughout the process. If we discover you are using our products for a restricted purpose, we will take action as necessary, including notifying appropriate authorities where warranted. + +**When required under applicable law.** + +- Requests for user data. Our policy is to not respond to government requests for user data unless we are compelled by legal process or in limited circumstances in the event of an emergency request. However, if U.S. law enforcement authorities have the necessary warrant, criminal subpoena, or court order requiring us to share data, we must comply. Likewise, we will only respond to requests from government authorities outside the U.S. if compelled by the U.S. government through procedures outlined in a mutual legal assistance treaty or agreement. It is API Hero’s policy to notify affected users before we share data unless we are legally prohibited from doing so, and except in some emergency cases. +- Preservation requests. Similarly, API Hero’s policy is to comply with requests to preserve data only if compelled by the U.S. Federal Stored Communications Act, 18 U.S.C. Section 2703(f), or by a properly served U.S. subpoena for civil matters. We do not share preserved data unless required by law or compelled by a court order that we choose not to appeal. Furthermore, unless we receive a proper warrant, court order, or subpoena before the required preservation period expires, we will destroy any preserved copies of customer data at the end of the preservation period. +- If we are audited by a tax authority, we may be required to share billing-related information. If that happens, we will share only the minimum needed, such as billing addresses and tax exemption information. + +Finally, if API Hero Ltd is acquired by or merges with another company — we don’t plan on that, but if it happens — we’ll notify you well before any of your personal information is transferred or becomes subject to a different privacy policy. + +## Your rights with respect to your information + +At API Hero, we strive to apply the same data rights to all customers, regardless of their location. Some of these rights include: + +- **Right to Know.** You have the right to know what personal information is collected, used, shared or sold. We outline both the categories and specific bits of data we collect, as well as how they are used, in this privacy policy. +- **Right of Access.** This includes your right to access the personal information we gather about you, and your right to obtain information about the sharing, storage, security and processing of that information. +- **Right to Correction.** You have the right to request correction of your personal information. +- **Right to Erasure / “To Be Forgotten”.** This is your right to request, subject to certain limitations under applicable law, that your personal information be erased from our possession and, by extension, from all of our service providers. Fulfillment of some data deletion requests may prevent you from using API Hero services because our applications may then no longer work. In such cases, a data deletion request may result in closing your account. +- **Right to Complain.** You have the right to make a complaint regarding our handling of your personal information with the appropriate supervisory authority. +- **Right to Restrict Processing.** This is your right to request restriction of how and why your personal information is used or processed, including opting out of sale of personal information. (Again: we never have and never will sell your personal data.) +- **Right to Object.** You have the right, in certain situations, to object to how or why your personal information is processed. +- **Right to Portability.** You have the right to receive the personal information we have about you and the right to transmit it to another party. +- **Right to not Be Subject to Automated Decision-Making.** You have the right to object to and prevent any decision that could have a legal or similarly significant effect on you from being made solely based on automated processes. This right is limited if the decision is necessary for performance of any contract between you and us, is allowed by applicable law, or is based on your explicit consent. +- **Right to Non-Discrimination.** We do not and will not charge you a different amount to use our products, offer you different discounts, or give you a lower level of customer service because you have exercised your data privacy rights. However, the exercise of certain rights may, by virtue of your exercising those rights, prevent you from using our Services. + +Many of these rights can be exercised by signing in and updating your account information. + +If you have questions about exercising these rights or need assistance, please contact us at [hello@apihero.run](mailto:hello@apihero.run). If an authorized agent is corresponding on your behalf, we will need written consent with a signature from the account holder before proceeding. + +If you are in the EU or UK, you can contact your data protection authority to file a complaint or learn more about local privacy laws. + +## How we secure your data + +All data is encrypted via [SSL/TLS](https://en.wikipedia.org/wiki/Transport_Layer_Security) when transmitted from our servers to your browser. The database backups are also encrypted. In addition, we go to great lengths to secure your data at rest. + +Most data is not encrypted while they live in our database (since they need to be ready to send to you when you need them). The disks storing the data keys are encrypted as well. Our servers decrypt the data to send it to you when you need it. + +## What happens when you delete content in your product accounts + +If you choose to cancel your account, your content will become immediately inaccessible and should be purged from our systems in full within 60 days. This applies both for cases when an account owner directly cancels and for auto-cancelled accounts. + +## Location of site and data + +Our products and other web properties are operated in the United Kingdom. If you are located in the European Union, US, or elsewhere outside of the United Kingdom, **please be aware that any information you provide to us might be transferred to and stored in the United States**. By using our websites or Services and/or providing us with your personal information, you consent to this transfer. + +## When transferring personal data from the EU + +The European Data Protection Board (EDPB) has issued guidance that personal data transferred out of the EU must be treated with the same level of protection that is granted under EU privacy law. UK law provides similar safeguards for UK user data that is transferred out of the UK. Accordingly, API Hero has adopted a data processing addendum with Standard Contractual Clauses to help ensure this protection. + +There are also a few ad hoc cases where EU personal data may be transferred to the U.S. in connection with API Hero Ltd operations, for instance, if an EU user signs up for our newsletter or participates in one of our surveys or buys swag from our company online store. Such transfers are only occasional and data is transferred under the [Article 49(1)(b) derogation](https://gdpr-info.eu/art-49-gdpr/) under GDPR and the UK version of GDPR. + +## Changes & questions + +We may update this policy as needed to comply with relevant regulations and reflect any new practices. Whenever we make a significant change to our policies, we will refresh the date at the top of this page and take any other appropriate steps to notify users. + +Have any questions, comments, or concerns about this privacy policy, your data, or your rights with respect to your information? Please get in touch by emailing us at [hello@apihero.run](mailto:hello@apihero.run) and we’ll be happy to try to answer them! + +Adapted from the [Basecamp open-source policies](https://github.com/basecamp/policies) / [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) diff --git a/apps/webapp/app/routes/legal/terms.mdx b/apps/webapp/app/routes/legal/terms.mdx new file mode 100644 index 000000000..51b848377 --- /dev/null +++ b/apps/webapp/app/routes/legal/terms.mdx @@ -0,0 +1,88 @@ +# Terms of Service + +_Last updated: September 22, 2022_ + +From everyone at API Hero, thank you for using our products! We build them to help you do your best work. There are millions of people using API Hero products every day. Because we don’t know every one of our customers personally, we have to put in place some Terms of Service to help keep the ship afloat. + +When we say “Company”, “we”, “our”, or “us” in this document, we are referring to API Hero Ltd. + +When we say “Services”, we mean any product created and maintained by API Hero Ltd. + +When we say “You” or “your”, we are referring to the people or organizations that own an account with one or more of our Services. + +We may update these Terms of Service in the future. Whenever we make a significant change to our policies, we will refresh the date at the top of this page and take any other appropriate steps to notify account holders. + +When you use our Services, now or in the future, you are agreeing to the latest Terms of Service. That’s true for any of our existing and future products and all features that we add to our Services over time. There may be times where we do not exercise or enforce any right or provision of the Terms of Service; in doing so, we are not waiving that right or provision. **These terms do contain a limitation of our liability.** + +If you violate any of the terms, we may terminate your account. That’s a broad statement and it means you need to place a lot of trust in us. + +## Account Terms + +1. You are responsible for maintaining the security of your account and password. The Company cannot and will not be liable for any loss or damage from your failure to comply with this security obligation. +2. You may not use the Services for any purpose outlined in our [Use Restrictions policy](../abuse/index.md). +3. You are responsible for all content posted and activity that occurs under your account. That includes content posted by others who either: (a) have access to your login credentials; or (b) have their own logins under your account. +4. You must be a human. Accounts registered by “bots” or other automated methods are not permitted. + +## Payment, Refunds, and Plan Changes + +1. If you are using a free version of one of our Services, it is really free: we do not ask you for your credit card and — just like for customers who pay for our Services — we do not sell your data. + +## Cancellation and Termination + +1. You are solely responsible for properly canceling your account. An email or phone request to cancel your account is not automatically considered cancellation. If you need help cancelling your account, you can always [contact our Support team](mailto:hello@apihero.run). +2. All of your content will be inaccessible from the Services immediately upon account cancellation. Within 30 days, all content will be permanently deleted from active systems and logs. Within 60 days, all content will be permanently deleted from our backups. We cannot recover this information once it has been permanently deleted. +3. We have the right to suspend or terminate your account and refuse any and all current or future use of our Services for any reason at any time. Suspension means you and any other users on your account will not be able to access the account or any content in the account. Termination will furthermore result in the deletion of your account or your access to your account, and the forfeiture and relinquishment of all content in your account. +4. Verbal, physical, written or other abuse (including threats of abuse or retribution) of Company employee or officer will result in immediate account termination. + +## Modifications to the Service and Prices + +1. Sometimes we change the pricing structure for our products. When we do that, we tend to exempt existing customers from those changes. However, we may choose to change the prices for existing customers. If we do so, we will give at least 30 days notice and will notify you via the email address on record. We may also post a notice about changes on our websites or the affected Services themselves. + +## Uptime, Security, and Privacy + +1. Your use of the Services is at your sole risk. We provide these Services on an “as is” and “as available” basis. We do not offer service-level agreements but do take uptime of our applications seriously. +2. We reserve the right to temporarily disable your account. Of course, we’ll reach out to the account owner before taking any action except in rare cases where the level of use may negatively impact the performance of the Service for other customers. +3. We take many measures to protect and secure your data through backups, redundancies, and encryption. We enforce encryption for data transmission from the public Internet. +4. When you use our Services, you entrust us with your data. We take that trust to heart. You agree that API Hero may process your data as described in our [Privacy Policy](../privacy/index.md) and for no other purpose. We as humans can access your data for the following reasons: + +- **To help you with support requests you make.** We’ll ask for express consent before accessing your account. +- **On the rare occasions when an error occurs that stops an automated process partway through.** We get automated alerts when such errors occur. When we can fix the issue and restart automated processing without looking at any personal data, we do. In rare cases, we have to look at a minimum amount of personal data to fix the issue. In these rare cases, we aim to fix the root cause as much as possible to avoid the errors from reoccurring. +- **To safeguard API Hero.** We’ll look at logs and metadata as part of our work to ensure the security of your data and the Services as a whole. +- **To the extent required by applicable law.** + +5. We use third party vendors and hosting partners to provide the necessary hardware, software, networking, storage, and related technology required to run the Services. +6. Under the California Consumer Privacy Act (“CCPA”), API Hero is a “service provider”, not a “business” or “third party”, with respect to your use of the Services. That means we process any data you share with us only for the purpose you signed up for and as described in these Terms of Service, [Privacy policy](../privacy/index.md), and [other policies](../index.md). We do not retain, use, disclose, or sell any of that information for any other commercial purposes unless we have your explicit permission. And on the flip-side, you agree to comply with your requirements under the CCPA and not use API Hero’s Services in a way that violates the regulations. + +## Copyright and Content Ownership + +1. We claim no intellectual property rights over the material you provide to the Services. All materials uploaded remain yours. +2. We do not pre-screen content, but reserve the right (but not the obligation) in our sole discretion to refuse or remove any content that is available via the Service. +3. The names, look, and feel of the Services are copyright© to the Company. All rights reserved. You may not duplicate, copy, or reuse any portion of the HTML, CSS, JavaScript, or visual design elements without express written permission from the Company. You must request permission to use the Company’s logo or any Service logos for promotional purposes. Please [email us](mailto:hello@apihero.run) requests to use logos. We reserve the right to rescind this permission if you violate these Terms of Service. +4. You agree not to reproduce, duplicate, copy, sell, resell or exploit any portion of the Services, use of the Services, or access to the Services without the express written permission by the Company. +5. You must not modify another website so as to falsely imply that it is associated with the Services or the Company. + +## Features and Bugs + +We design our Services with care, based on our own experience and the experiences of customers who share their time and feedback. However, there is no such thing as a service that pleases everybody. We make no guarantees that our Services will meet your specific requirements or expectations. + +We also test all of our features extensively before shipping them. As with any software, our Services inevitably have some bugs. We track the bugs reported to us and work through priority ones, especially any related to security or privacy. Not all reported bugs will get fixed and we don’t guarantee completely error-free Services. + +## Services Adaptations and API Terms + +We offer Application Program Interfaces (“API”s) for some of our Services (currently API Hero). Any use of the API, including through a third-party product that accesses the Services, is bound by the terms of this agreement plus the following specific terms: + +1. You expressly understand and agree that we are not liable for any damages or losses resulting from your use of the API or third-party products that access data via the API. +2. Third parties may not access and employ the API if the functionality is part of an application that remotely records, monitors, or reports a Service user’s activity _other than time tracking_, both inside and outside the applications. The Company, in its sole discretion, will determine if an integration service violates this bylaw. A third party that has built and deployed an integration for the purpose of remote user surveillance will be required to remove that integration. +3. Abuse or excessively frequent requests to the Services via the API may result in the temporary or permanent suspension of your account’s access to the API. The Company, in its sole discretion, will determine abuse or excessive usage of the API. If we need to suspend your account’s access, we will attempt to warn the account owner first. If your API usage could or has caused downtime, we may cut off access without prior notice. + +## Liability + +We mention liability throughout these Terms but to put it all in one section: + +**_You expressly understand and agree that the Company shall not be liable, in law or in equity, to you or to any third party for any direct, indirect, incidental, lost profits, special, consequential, punitive or exemplary damages, including, but not limited to, damages for loss of profits, goodwill, use, data or other intangible losses (even if the Company has been advised of the possibility of such damages), resulting from: (i) the use or the inability to use the Services; (ii) the cost of procurement of substitute goods and services resulting from any goods, data, information or services purchased or obtained or messages received or transactions entered into through or from the Services; (iii) unauthorized access to or alteration of your transmissions or data; (iv) statements or conduct of any third party on the service; (v) or any other matter relating to this Terms of Service or the Services, whether as a breach of contract, tort (including negligence whether active or passive), or any other theory of liability._** + +In other words: choosing to use our Services does mean you are making a bet on us. If the bet does not work out, that’s on you, not us. We do our darnedest to be as safe a bet as possible through careful management of the business; investments in security, infrastructure, and talent; and in general giving a damn. If you choose to use our Services, thank you for betting on us. + +If you have a question about any of the Terms of Service, please [contact our Support team](mailto:hello@apihero.run). + +Adapted from the [Basecamp open-source policies](https://github.com/basecamp/policies) / [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) diff --git a/apps/webapp/app/services/authUser.ts b/apps/webapp/app/services/authUser.ts new file mode 100644 index 000000000..4c1ce6a20 --- /dev/null +++ b/apps/webapp/app/services/authUser.ts @@ -0,0 +1,3 @@ +export type AuthUser = { + userId: string; +}; diff --git a/apps/webapp/app/services/redirectTo.server.ts b/apps/webapp/app/services/redirectTo.server.ts new file mode 100644 index 000000000..da6505daa --- /dev/null +++ b/apps/webapp/app/services/redirectTo.server.ts @@ -0,0 +1,53 @@ +import { createCookieSessionStorage } from "@remix-run/node"; +import invariant from "tiny-invariant"; +import { z } from "zod"; + +const ONE_DAY = 60 * 60 * 24; + +invariant(process.env.SESSION_SECRET, "SESSION_SECRET must be set"); + +export const { commitSession, getSession } = createCookieSessionStorage({ + cookie: { + name: "__redirectTo", + path: "/", + httpOnly: true, + sameSite: "lax", + secrets: [process.env.SESSION_SECRET], + secure: process.env.NODE_ENV === "production", + maxAge: ONE_DAY, + }, +}); + +export function getRedirectSession(request: Request) { + return getSession(request.headers.get("Cookie")); +} + +export async function setRedirectTo(request: Request, redirectTo: string) { + const session = await getRedirectSession(request); + + if (session) { + session.set("redirectTo", redirectTo); + } + + return session; +} + +export async function clearRedirectTo(request: Request) { + const session = await getRedirectSession(request); + + if (session) { + session.unset("redirectTo"); + } + + return session; +} + +export async function getRedirectTo( + request: Request +): Promise { + const session = await getRedirectSession(request); + + if (session) { + return z.string().optional().parse(session.get("redirectTo")); + } +} diff --git a/apps/webapp/app/services/session.server.ts b/apps/webapp/app/services/session.server.ts new file mode 100644 index 000000000..8d0215aab --- /dev/null +++ b/apps/webapp/app/services/session.server.ts @@ -0,0 +1,43 @@ +import { redirect } from "@remix-run/node"; +import { getUserById } from "~/models/user.server"; +import { authenticator } from "./auth.server"; + +export async function getUserId(request: Request): Promise { + let authUser = await authenticator.isAuthenticated(request); + return authUser?.userId; +} + +export async function getUser(request: Request) { + const userId = await getUserId(request); + if (userId === undefined) return null; + + const user = await getUserById(userId); + if (user) return user; + + throw await logout(request); +} + +export async function requireUserId(request: Request, redirectTo?: string) { + const userId = await getUserId(request); + if (!userId) { + const url = new URL(request.url); + const searchParams = new URLSearchParams([ + ["redirectTo", redirectTo ?? `${url.pathname}${url.search}`], + ]); + throw redirect(`/login?${searchParams}`); + } + return userId; +} + +export async function requireUser(request: Request) { + const userId = await requireUserId(request); + + const user = await getUserById(userId); + if (user) return user; + + throw await logout(request); +} + +export async function logout(request: Request) { + return redirect("/logout"); +} diff --git a/apps/webapp/app/services/sessionStorage.server.ts b/apps/webapp/app/services/sessionStorage.server.ts new file mode 100644 index 000000000..4428250b7 --- /dev/null +++ b/apps/webapp/app/services/sessionStorage.server.ts @@ -0,0 +1,22 @@ +import { createCookieSessionStorage } from "@remix-run/node"; +import invariant from "tiny-invariant"; + +invariant(process.env.SESSION_SECRET, "SESSION_SECRET must be set"); + +export const sessionStorage = createCookieSessionStorage({ + cookie: { + name: "__session", // use any name you want here + sameSite: "lax", // this helps with CSRF + path: "/", // remember to add this so the cookie will work in all routes + httpOnly: true, // for security reasons, make this cookie http only + secrets: [process.env.SESSION_SECRET], + secure: process.env.NODE_ENV === "production", // enable this in prod only + maxAge: 60 * 60 * 24 * 365, // 7 days + }, +}); + +export function getUserSession(request: Request) { + return sessionStorage.getSession(request.headers.get("Cookie")); +} + +export const { getSession, commitSession, destroySession } = sessionStorage; diff --git a/apps/webapp/app/utils.test.ts b/apps/webapp/app/utils.test.ts new file mode 100644 index 000000000..7ffd94a32 --- /dev/null +++ b/apps/webapp/app/utils.test.ts @@ -0,0 +1,13 @@ +import { validateEmail } from "./utils"; + +test("validateEmail returns false for non-emails", () => { + expect(validateEmail(undefined)).toBe(false); + expect(validateEmail(null)).toBe(false); + expect(validateEmail("")).toBe(false); + expect(validateEmail("not-an-email")).toBe(false); + expect(validateEmail("n@")).toBe(false); +}); + +test("validateEmail returns true for emails", () => { + expect(validateEmail("kody@example.com")).toBe(true); +}); diff --git a/apps/webapp/app/utils.ts b/apps/webapp/app/utils.ts new file mode 100644 index 000000000..a7eb66eb3 --- /dev/null +++ b/apps/webapp/app/utils.ts @@ -0,0 +1,71 @@ +import { useMatches } from "@remix-run/react"; +import { useMemo } from "react"; + +import type { User } from "~/models/user.server"; + +const DEFAULT_REDIRECT = "/"; + +/** + * This should be used any time the redirect path is user-provided + * (Like the query string on our login/signup pages). This avoids + * open-redirect vulnerabilities. + * @param {string} to The redirect destination + * @param {string} defaultRedirect The redirect to use if the to is unsafe. + */ +export function safeRedirect( + to: FormDataEntryValue | string | null | undefined, + defaultRedirect: string = DEFAULT_REDIRECT +) { + if (!to || typeof to !== "string") { + return defaultRedirect; + } + + if (!to.startsWith("/") || to.startsWith("//")) { + return defaultRedirect; + } + + return to; +} + +/** + * This base hook is used in other hooks to quickly search for specific data + * across all loader data using useMatches. + * @param {string} id The route id + * @returns {JSON|undefined} The router data or undefined if not found + */ +export function useMatchesData( + id: string +): Record | undefined { + const matchingRoutes = useMatches(); + const route = useMemo( + () => matchingRoutes.find((route) => route.id === id), + [matchingRoutes, id] + ); + return route?.data; +} + +function isUser(user: any): user is User { + return user && typeof user === "object" && typeof user.email === "string"; +} + +export function useOptionalUser(): User | undefined { + const data = useMatchesData("root"); + if (!data || !isUser(data.user)) { + return undefined; + } + return data.user; +} + +export function useUser(): User { + const maybeUser = useOptionalUser(); + if (!maybeUser) { + throw new Error( + "No user found in root loader, but user is required by useUser. If user is optional, try useOptionalUser instead." + ); + } + return maybeUser; +} + +export function validateEmail(email: unknown): email is string { + return typeof email === "string" && email.length > 3 && email.includes("@"); +} diff --git a/apps/webapp/cypress.config.ts b/apps/webapp/cypress.config.ts new file mode 100644 index 000000000..fa282b4e8 --- /dev/null +++ b/apps/webapp/cypress.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from "cypress"; + +export default defineConfig({ + e2e: { + setupNodeEvents: (on, config) => { + const isDev = config.watchForFileChanges; + const port = process.env.REMIX_APP_PORT ?? (isDev ? "3000" : "8811"); + const configOverrides: Partial = { + baseUrl: `http://localhost:${port}`, + video: !process.env.CI, + screenshotOnRunFailure: !process.env.CI, + }; + + // To use this: + // cy.task('log', whateverYouWantInTheTerminal) + on("task", { + log: (message) => { + console.log(message); + + return null; + }, + }); + + return { ...config, ...configOverrides }; + }, + }, +}); diff --git a/apps/webapp/cypress/.eslintrc.js b/apps/webapp/cypress/.eslintrc.js new file mode 100644 index 000000000..37c79ff06 --- /dev/null +++ b/apps/webapp/cypress/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + parserOptions: { + tsconfigRootDir: __dirname, + project: "./tsconfig.json", + }, +}; diff --git a/apps/webapp/cypress/e2e/smoke.cy.ts b/apps/webapp/cypress/e2e/smoke.cy.ts new file mode 100644 index 000000000..c0fbec8b9 --- /dev/null +++ b/apps/webapp/cypress/e2e/smoke.cy.ts @@ -0,0 +1,16 @@ +import { faker } from "@faker-js/faker"; + +describe("smoke tests", () => { + it("should allow you to register and login", () => { + const loginForm = { + email: `${faker.internet.userName()}@example.com`, + password: faker.internet.password(), + username: faker.internet.userName("Jeanne", "Doe"), + }; + cy.then(() => ({ email: loginForm.email })).as("user"); + + cy.visitAndCheck("/"); + cy.findByText("Gospel Stack"); + cy.findByRole("button"); + }); +}); diff --git a/apps/webapp/cypress/fixtures/test.json b/apps/webapp/cypress/fixtures/test.json new file mode 100644 index 000000000..c8c4105eb --- /dev/null +++ b/apps/webapp/cypress/fixtures/test.json @@ -0,0 +1,3 @@ +{ + "foo": "bar" +} diff --git a/apps/webapp/cypress/support/commands.ts b/apps/webapp/cypress/support/commands.ts new file mode 100644 index 000000000..cca73e3f8 --- /dev/null +++ b/apps/webapp/cypress/support/commands.ts @@ -0,0 +1,35 @@ +export {}; + +declare global { + namespace Cypress { + interface Chainable { + /** + * Extends the standard visit command to wait for the page to load + * + * @returns {typeof visitAndCheck} + * @memberof Chainable + * @example + * cy.visitAndCheck('/') + * @example + * cy.visitAndCheck('/', 500) + */ + visitAndCheck: typeof visitAndCheck; + } + } +} + +// We're waiting a second because of this issue happen randomly +// https://github.com/cypress-io/cypress/issues/7306 +// Also added custom types to avoid getting detached +// https://github.com/cypress-io/cypress/issues/7306#issuecomment-1152752612 +// =========================================================== +function visitAndCheck(url: string, waitTime: number = 1000) { + cy.visit(url); + cy.location("pathname").should("contain", url).wait(waitTime); +} + +Cypress.Commands.add("visitAndCheck", visitAndCheck); +/* +eslint + @typescript-eslint/no-namespace: "off", +*/ diff --git a/apps/webapp/cypress/support/e2e.ts b/apps/webapp/cypress/support/e2e.ts new file mode 100644 index 000000000..baa9411af --- /dev/null +++ b/apps/webapp/cypress/support/e2e.ts @@ -0,0 +1,15 @@ +import "@testing-library/cypress/add-commands"; +import "./commands"; + +Cypress.on("uncaught:exception", (err) => { + // Cypress and React Hydrating the document don't get along + // for some unknown reason. Hopefully we figure out why eventually + // so we can remove this. + if ( + /hydrat/i.test(err.message) || + /Minified React error #418/.test(err.message) || + /Minified React error #423/.test(err.message) + ) { + return false; + } +}); diff --git a/apps/webapp/cypress/tsconfig.json b/apps/webapp/cypress/tsconfig.json new file mode 100644 index 000000000..250551e88 --- /dev/null +++ b/apps/webapp/cypress/tsconfig.json @@ -0,0 +1,39 @@ +{ + "exclude": [ + "../node_modules/@types/jest", + "../node_modules/@testing-library/jest-dom" + ], + "include": [ + "e2e/**/*", + "support/**/*", + "../node_modules/cypress", + "../node_modules/@testing-library/cypress" + ], + "compilerOptions": { + "baseUrl": ".", + "noEmit": true, + "types": ["node", "cypress", "@testing-library/cypress"], + "esModuleInterop": true, + "jsx": "react-jsx", + "moduleResolution": "node", + "target": "es2019", + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "typeRoots": ["../types", "../node_modules/@types"], + "paths": { + "~/*": ["./app/*"], + "@apihero/ui/*": ["../../packages/ui/src/*"], + "@apihero/ui": ["../../packages/ui/src/index"], + "@apihero/business/*": ["../../packages/business/src/*"], + "@apihero/business": ["../../packages/business/src/index"], + "@apihero/internal-nobuild": [ + "../../packages/internal-nobuild/src/index" + ], + "@apihero/internal-nobuild/*": ["../../packages/internal-nobuild/src/*"] + } + }, + "ts-node": { + "swc": true + } +} diff --git a/apps/webapp/fly.toml b/apps/webapp/fly.toml new file mode 100644 index 000000000..b896fc905 --- /dev/null +++ b/apps/webapp/fly.toml @@ -0,0 +1,51 @@ +app = "apihero-webapp" +kill_signal = "SIGINT" +kill_timeout = 5 +processes = [ ] + +[env] +REMIX_APP_PORT = "8080" +PRIMARY_REGION = "mia" + +[deploy] +release_command = "pnpx prisma migrate deploy --schema apps/webapp/build/schema.prisma" + +[experimental] +allowed_public_ports = [ ] +auto_rollback = true + +[[services]] +internal_port = 8080 +processes = [ "app" ] +protocol = "tcp" +script_checks = [ ] + + [services.concurrency] + hard_limit = 25 + soft_limit = 20 + type = "connections" + + [[services.ports]] + handlers = [ "http" ] + port = 80 + force_https = true + + [[services.ports]] + handlers = [ "tls", "http" ] + port = 443 + + [[services.tcp_checks]] + grace_period = "1s" + interval = "15s" + restart_limit = 0 + timeout = "2s" + + [[services.http_checks]] + interval = "10s" + grace_period = "5s" + method = "get" + path = "/healthcheck" + protocol = "http" + timeout = "2s" + tls_skip_verify = false + headers = { } diff --git a/apps/webapp/mocks/README.md b/apps/webapp/mocks/README.md new file mode 100644 index 000000000..c219a4110 --- /dev/null +++ b/apps/webapp/mocks/README.md @@ -0,0 +1,7 @@ +# Mocks + +Use this to mock any third party HTTP resources that you don't have running locally and want to have mocked for local development as well as tests. + +Learn more about how to use this at [mswjs.io](https://mswjs.io/) + +For an extensive example, see the [source code for kentcdodds.com](https://github.com/kentcdodds/kentcdodds.com/blob/main/mocks/start.ts) diff --git a/apps/webapp/mocks/index.js b/apps/webapp/mocks/index.js new file mode 100644 index 000000000..2e399910a --- /dev/null +++ b/apps/webapp/mocks/index.js @@ -0,0 +1,9 @@ +const { setupServer } = require("msw/node"); + +const server = setupServer(); + +server.listen({ onUnhandledRequest: "bypass" }); +console.info("🔶 Mock server running"); + +process.once("SIGINT", () => server.close()); +process.once("SIGTERM", () => server.close()); diff --git a/apps/webapp/package.json b/apps/webapp/package.json new file mode 100644 index 000000000..3629a6223 --- /dev/null +++ b/apps/webapp/package.json @@ -0,0 +1,178 @@ +{ + "private": true, + "name": "webapp", + "version": "1.0.0", + "sideEffects": false, + "scripts": { + "build": "run-s build:*", + "build:css": "pnpm run generate:css -- --minify", + "build:remix": "remix build", + "build:server": "esbuild --platform=node --format=cjs ./server.ts --outdir=build", + "dev": "run-p dev:*", + "dev:server": "cross-env NODE_ENV=development node --inspect --require ./node_modules/dotenv/config ./build/server.js", + "dev:server:proxy": "cross-env NODE_ENV=development node --inspect --require ./node_modules/dotenv/config --require ./proxy ./build/server.js", + "dev:build": "cross-env NODE_ENV=development npm run build:server -- --watch", + "dev:remix": "cross-env NODE_ENV=development remix watch", + "dev:css": "cross-env NODE_ENV=development npm run generate:css -- --watch", + "format": "prettier --write .", + "generate:css": "tailwindcss --postcss -i ./styles/tailwind-include.css -o ./app/styles/tailwind.css", + "lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .", + "start": "cross-env NODE_ENV=production node ./build/server.js", + "docker:build": "cd ../.. && docker build -t remix-gospel-stack-webapp -f ./apps/webapp/Dockerfile .", + "test": "vitest run", + "test:cov": "vitest run --coverage", + "test:e2e:dev": "start-server-and-test dev http://localhost:3000 \"npx cypress open\"", + "test:e2e:run": "cross-env PORT=8811 start-server-and-test start:mocks http://localhost:8811 \"npx cypress run\"", + "test:e2e:ci": "pnpx cypress run", + "typecheck": "tsc -b && tsc -b cypress", + "env:pull": "pnpm dlx infisical pull dev", + "generate": "prisma generate", + "db:migrate:deploy": "prisma migrate deploy", + "db:migrate:dev": "prisma migrate dev" + }, + "prettier": {}, + "eslintIgnore": [ + "/node_modules", + "/build", + "/public/build" + ], + "dependencies": { + "@apihero/openapi-spec-generator": "^0.1.6", + "@aws-sdk/client-s3": "^3.186.0", + "@aws-sdk/s3-request-presigner": "^3.186.0", + "@cfworker/json-schema": "^1.12.4", + "@codemirror/autocomplete": "^6.3.1", + "@codemirror/commands": "^6.1.2", + "@codemirror/lang-javascript": "^6.1.1", + "@codemirror/lang-json": "^6.0.1", + "@codemirror/language": "^6.3.1", + "@codemirror/search": "^6.2.3", + "@codemirror/state": "^6.1.3", + "@codemirror/view": "^6.5.0", + "@headlessui/react": "^1.6.4", + "@heroicons/react": "^2.0.12", + "@keyv/redis": "^2.3.7", + "@lezer/highlight": "^1.1.2", + "@prisma/client": "^4.3.0", + "@remix-run/express": "^1.7.2", + "@remix-run/node": "^1.7.2", + "@remix-run/react": "^1.7.2", + "@remix-run/server-runtime": "^1.7.0", + "@sendgrid/client": "^7.7.0", + "@sendgrid/mail": "^7.7.0", + "@sentry/remix": "^7.19.0", + "@tailwindcss/forms": "^0.5.2", + "@tanstack/react-table": "^8.0.0-alpha.87", + "@tremor/react": "^1.1.4", + "@uiw/react-codemirror": "^4.13.2", + "@apihero/node": "workspace:*", + "bcryptjs": "^2.4.3", + "classnames": "^2.3.1", + "clsx": "^1.2.1", + "compression": "^1.7.4", + "cross-env": "^7.0.3", + "csstype": "^3.0.10", + "cuid": "^2.1.8", + "date-fns": "2.0.0-alpha.7 || >=2.0.0", + "express": "^4.18.1", + "internal-logs": "workspace:*", + "javascript-time-ago": "^2.5.7", + "json-query": "^2.2.2", + "jsonata": "^1.8.6", + "jsonschema": "^1.4.1", + "keyv": "^4.3.2", + "lodash": "^4.17.21", + "mailgun-js": "^0.22.0", + "marked": "^4.0.18", + "mergent": "^1.3.1", + "morgan": "^1.10.0", + "nanoid": "^3.3.4", + "openapi-types": "^12.0.0", + "postcss-import": "^14.1.0", + "posthog-js": "^1.31.0", + "pretty-bytes": "^6.0.0", + "prisma-field-encryption": "1.4.0-beta.5", + "qs": "^6.11.0", + "react": "^18.2.0", + "react-date-range": "^1.4.0", + "react-dom": "^18.2.0", + "react-hot-toast": "^2.4.0", + "react-hotkeys-hook": "^3.4.7", + "react-query": "^3.39.1", + "react-resizable-layout": "^0.2.4", + "remix-auth": "^3.2.2", + "remix-auth-email-link": "^1.4.2", + "remix-auth-github": "^1.1.1", + "remix-typedjson": "^0.1.3", + "remix-utils": "^3.3.0", + "slug": "^6.0.0", + "tiny-invariant": "^1.2.0", + "tsx": "^3.4.3", + "zod": "^3.17.3", + "zod-to-json-schema": "^3.17.0" + }, + "devDependencies": { + "@apihero/tailwind-config": "*", + "@faker-js/faker": "^7.5.0", + "@remix-run/dev": "^1.7.2", + "@remix-run/eslint-config": "^1.7.2", + "@swc/core": "^1.3.4", + "@swc/helpers": "^0.4.11", + "@tailwindcss/typography": "^0.5.4", + "@testing-library/cypress": "^8.0.3", + "@testing-library/dom": "^8.18.1", + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^13.4.0", + "@testing-library/user-event": "^14.4.3", + "@types/bcryptjs": "^2.4.2", + "@types/compression": "^1.7.2", + "@types/eslint": "^8.4.6", + "@types/express": "^4.17.13", + "@types/jest": "^29.2.0", + "@types/json-query": "^2.2.3", + "@types/lodash": "^4.14.182", + "@types/mailgun-js": "^0.22.12", + "@types/marked": "^4.0.3", + "@types/morgan": "^1.9.3", + "@types/node": "^18.8.0", + "@types/node-fetch": "^2.6.2", + "@types/qs": "^6.9.7", + "@types/react": "^18.0.21", + "@types/react-date-range": "^1.4.3", + "@types/react-dom": "^18.0.6", + "@types/slug": "^5.0.3", + "@vitejs/plugin-react": "^2.0.1", + "@vitest/coverage-c8": "^0.23.4", + "autoprefixer": "^10.4.7", + "c8": "^7.11.3", + "cli-ux": "^6.0.9", + "cypress": "^10.9.0", + "dotenv": "^16.0.3", + "esbuild": "^0.15.10", + "eslint": "^8.24.0", + "eslint-config-prettier": "^8.5.0", + "eslint-plugin-cypress": "^2.12.1", + "glob": "^8.0.3", + "happy-dom": "^6.0.4", + "msw": "^0.47.0", + "node-fetch": "2.x", + "nodemon": "^2.0.19", + "npm-run-all": "^4.1.5", + "postcss": "^8.4.14", + "prettier": "^2.6.2", + "prettier-plugin-tailwindcss": "^0.1.11", + "prisma": "^4.3.0", + "react-date-range": "^1.4.0", + "start-server-and-test": "^1.14.0", + "tailwindcss": "^3.0.24", + "ts-node": "^10.7.0", + "tsconfig-paths": "^3.14.1", + "typescript": "^4.8.4", + "vite": "^3.1.4", + "vite-tsconfig-paths": "^3.5.1", + "vitest": "^0.23.4" + }, + "engines": { + "node": ">=14" + } +} \ No newline at end of file diff --git a/apps/webapp/postcss.config.js b/apps/webapp/postcss.config.js new file mode 100644 index 000000000..e0c9cbbf9 --- /dev/null +++ b/apps/webapp/postcss.config.js @@ -0,0 +1,7 @@ +module.exports = { + plugins: [ + require("postcss-import"), + require("tailwindcss"), + require("autoprefixer"), + ], +}; diff --git a/apps/webapp/prisma/schema.prisma b/apps/webapp/prisma/schema.prisma new file mode 100644 index 000000000..68b2c35df --- /dev/null +++ b/apps/webapp/prisma/schema.prisma @@ -0,0 +1,16 @@ +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +generator client { + provider = "prisma-client-js" + output = "../node_modules/.prisma/client" + binaryTargets = ["native", "debian-openssl-1.1.x"] +} + +model User { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} \ No newline at end of file diff --git a/apps/webapp/prisma/seed.ts b/apps/webapp/prisma/seed.ts new file mode 100644 index 000000000..3294eb8a3 --- /dev/null +++ b/apps/webapp/prisma/seed.ts @@ -0,0 +1,16 @@ +import { PrismaClient } from ".prisma/client"; + +const prisma = new PrismaClient(); + +async function seed() { + console.log(`Database has been seeded. 🌱`); +} + +seed() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/apps/webapp/public/favicon.ico b/apps/webapp/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..8f18088836bb67c9b3abd577ee05e3f5c2ceb4c4 GIT binary patch literal 15406 zcmeHOcYIXE+TPoK_kN!RED(?w0%S|F*`92AFBF3m1*9V=9i-O)n-Bt_Nk>3HAfbeg z1W*A56)Os&C?cSMT@=JXfQ!%fyk~QklO=&z?)TT--|uAR%$b?@nVEOqdCNqn`-|>x zx|%h0OoMg3|E|-ybvj*eu=m|`k50FYWp;b{cY{uMpq5T&VjVVN3vW4-YW;HU_Q=Wh zh(#;hamUt2CzP#^PArpr0n6lDRh@W3Lhc@skXe|OQ-tI}3vfE)VSJg{!uNc^_ZQ<+ zG2QMel)Bac-5uc#gPxN9Ja&7{r?NU6v~4Y^Hd;4-skD{$^27{ZRsq9VVwstghELKSLbr*tk+NxDnYmW2J3Urs zYSX;671~Ps;I(JaqE`+w`E6e7ZkW)m50Z;^q0^LwX#DwgbnZ7C(sr67VomSvqhQJ@ zg#OvL*w57peKmZc6qfuVWPVzzv}=6o-^e)pJvOH{!E{H&>Q(NzvogLnQ=4JX<0BAO zuo?Q*pP<1?KGR-g+d`!7KaK`7_abunZuB3VjfZ;lQ^qE3i=z{+Yv*S`&+!OZ`xzQ6 zdKa~d&LetG5e(C|BWS_9Xt3iVrnPwj^CF{_xs|qSqY_HB?fVSKN64NsgzUNiYxZ1( zO<9LJ%TFU@-3izy730}fU484aHY&b!aYW49YWuA*iFhrgDJ;`B!tPlJ^Q1!5T2~6g zq65%xJdLh{ruy1S8)=(sb&ffj`VcuxMr6p~ot;cM>x6mE;!{V0hkowBU-Z|#_r0zY6h)L^UTyYqS$a^1g-PN{|N*ie_ z{F4*zY}G1n_OG(8&Bsng+JRG;-L?ykvCVxLH3nM5p_wTFyFw9*ez0j@3R|9?46Y`u)#LMa~5CN#-N+ z>5u+KSuFQ>Y_?7wt7A!y&GB}Q)$whP#c^4Ay!p4~TQ2KJ-9JiCZJ%p)*w}9o*QRW? z(~V6rN!Xv5f%j9ID7-2BDeE9*@@=j&iu~s&Q`)et+RtxIC(C#(j-?YE?&}@MsNRedC#?4qd32(d$s8e$NE!> z+9k=*eu_K*RGMFmZ|APyS-VC-F2zu z*k_^NdqZY6+H)0k<{iNlcU-mU3oeoGJK|E1Yja+jXt6g7y}yRj-FL|Mkx%sdk~IIO z(v_6+o#?T-E@w{NT0vH;XGh@19}(VRG(Po{8C8v6TmDKyItGS@UUm-}{ZqzA7Z7e} zG`#|ChvNa^>E;twR!Uni$Q%UB`w;o=_}k9GnT!@#Q7+E~$FA69BoCiJ8G0S@3s=Kj zuO>Bv8^2tu{rMi9hg3;hI|pH%#ws#UJ3ejx4CUF7m^h?1^~%<5l>f=ul;4!2^2D@l zfA$7t&U?5kGn-?wZ2ji8^c(HDf`Iu)F_imkLR>1kwd{()sacf0$;zCFEFBORkJOis zB7SH#HbloGDR-uicKW+tqP-ymLQ}=Sy}Iww;d7yX{@dHqS9E~c`(YS9Upar&c`?r1 zfdcNiJ_(6Pd-YR=UTofMButs_qb*~P%b$y9BciDzIQ9wsiB_wT@&c|QvtU?!0LK24 zc`iSX5Do9EG;WnXxb`cUT0PD)>SP6a>YS@-*JjO?J)CQEfb!IkYjKWP5)p&UN1`O1ngtnfT^xJ<>^1(ZPLE!xN5xD#_1g`iTq3ubV z>zeV=c{qpXBW%PhL{DCz=nI+0O3|!cC&UgLkHnEVzP&3ng*Nxx$v2WRee_ivvd3qT zccObufN|V17^ZVgh@<|t3uw@7yfQZi&Xu`kC#1jstupSIf@cs|{5|eoc?k_xe1gc4 zlhERkb~wbdqRA8e(LeFVIEALr&apZ#f57uYrR$%*!+NHnv%TZqegO?TjY4qY3EcJE zCDhq+1&*;ZkpA8`aC>IK)~Y>%S02Zm{8o4C73kM}hJIXAk%2937G*D}{R>T@O$^F; zp_coni(QeN_wN<@#(`6*uYG}_omWw_@DgbqL-f?ea15J-x*IN{?xs>0`{gM*p~^3Y z`L8H@b8x5;qU)>u%Q_2f@A#Yf%GK{`I?ow_kQaYZ=!fh)?@fgt3Xj6ocR1qb7sA|q zDDGQ#8FwrzMeqw{f0@vwZlrvKD94xs;WG3tzwBK^jhlv~wJ)Jj!4752_pH7Q!`wXzpQ`g7vgb0-%9xw_RWX#r@=PTEtNULq zSJM$;p(x=#l=nA@CAw8xII?FWYRn{<`{kff?mC#djX>b0^9bC2MZsoRun)#j3l-UA zn70?}xqsCD{8M%RPqaoDMHU@TzoA!rPCiLUNkMT`99~aMM@IKP$UJdH*<;bUvruQ# zCEU5Z6hRxlhw+JQgzWgqw?4-4%Q2ihyOFZkpQidQI*ia1+9HF=hx@i^WYBujjhnR$ zY)DgwC_Sg zXbDX(b?@ICi+%e_>Q*B6L{6%-Doc~szl?4UC*;hNbIRU7ABQR9v~;S<3k{*Ar7ih9 zo2?$_Xn)N2;QyEq{^wNN5Nx zfBN!HT{$T|E#sTZjE2W_qXn`5^>R26M04}akP;py+3RrXp!*C$b6 zcPf67J@F|Objj+BSL$f=|19FVukzJoSogQ2-n)d1PJe%X!;^@R% zo>BgIjw<#~BJym`U!@P#p1&1+3vH6P8A}k+vCBzQotnXFU5`D|PF-yc`R2+z>YSUo zzh0%ScZjlCd=2tQUdrT~)RDS;R(s5m6?AO1&&pEs1NRj;n>6`Ec%ZWHG6vecoy8_3 zo_BRascT-CNDI?x<7M_y3mvXJ$bBB*G-!`1~1u5_WUb z&!f}*RrqIOxT}%J64A_KbL5g=wo&&wLf)Yt#ePwFB+oMWrqp3wr48FkA2)UUA1)XW z67tVH$_@Ie#^%`U+llpSKA3EGVXi9*qC>2pPlP@@<)J@XDU)wfN9sx&>NzFS2W4-J z^!Xo#t#mK`IASj5e*B6UuF09ZCMtpYOr|0;KBW$Ff_}VH8O`x|`6GELlW!tdq%Qri znCczNHR+3Mv%WH4-=8rikHROepqy~~RO$y;v)u_8kkt`M0~aFcp>EUzZ?=>CWp8Eg zr#PmUJ};|aI5<@~(mu}A#7 zXZrz2o4FGv`l|ysoI|}4tC3Cqs0?3q%S0~~IlnwIR>p8$p(QjcAH!eF0X2Gt1=KuZ zu3ht%en3xh^1nPbdja|6r_pg~Xgqln%q3#$di`GwU$~5bXHUYM+zM}zes%FyRqv#% z2lbJ1zWPaM3kG#Anf}#Wr^ZNoa`Mj>tEI#oc(>vY*L?S7B}PLZ>>S~jK~38scImr5 z+^Q|oh&p1u;vZ1gvjcsp-)Zf@sxVfk{JrER^tZab{6q|bB_u@mPjkb1gCjaU{!7Br z&DYEU_l%P9_yB7%)2!AzsF%#Azp+ewU(VtaKAl>{uBO2|OHq5-88{#Asrb^WgReSu zj;1xE42z>J;wT#zZl7;y7&OW~aKt%@1w}m8=^JzHyW%vEec7Ha*FPBNnD4PUuXXP- zjJ~t4tHiF1Ved6Ou;x2FkhdEnqY@!|vO4&xQ%7vK=O~9;H4M7u?lb68#*uT1AIcmQ za3ENx)AE&qzfGGqf1^x(g8Y7QAmxjF!xy*4UhaQtz6?Cg z=mhcSMMfcR;0T|+6+I>o;dN^C@lCiUlWU7K6Z^-*lbgZ4>ceVbSNW(O{X_M}tj93Q zkm}5>wvOnvL+y5S&^Pq873{f@ZX}P&Mdr~9ij9=t(YdUTn(UF9M1e+Dc%j_~MIB)03r7&BwJ^t~A7y$r*++g-^qCdWck|;-eTcu&PCs~_GZG!hGfB^^_4$8fj-4$Z zLBh;uiM>2_&N5~0nWjAdCdN6P^s&>wOss-g@Oy%T|Hbhv;De;`E1=)<6^woJU~1h1 z2H6MO#P@$A-lWd;?@1f%Dn-4SZy+=(9tjKA_^_!MC64XOYp_dM>Qf`>r;StilK5o} z76^95KVownn#1!hkG`J9kM)CzzD@n|_h1~e*c&&t!Z2?P)sf$>qZiT-1Bn%T)!1Qou=vRK=9Rq!Y`pxtc%l!%U zEPIju!*$=#e|(xTh4Tn5xrhg*?n5ZCC#~D9;K}^-65^Ne&P0bEu(j=q*x4(P@!?P2 zF_DMTUO$1vCQV_Fa#QyIj7Y}Y-q4(Ff>q%mV&^_sJHrvF$So~?HUEA}V=Sbfsbkh- z2$xuxVBhsK^qbDY$QX!W)(gIPSMc-Hsk1kr!Q(k-&~q~C_n6H0jRuY-FnU%Xa%=(Qd`jDY3|VbDU|kGl6Y(b&?nIX;Z)`}#Bxki?7VNZ3 zKUnCB_W4NFI@(ltPR__Jl;iEVb|$%h)2uD@3-;f@&Up?Rx*Ud1LzVbQ$i_3!lkd#* z7bkDrcf;39pRcW13j}Y!$XH7$V=ASnyOHsY$s6G6(GMxf$=E{wqr^HNjf#f5_fuFM zThZ4qzE;6Z9rFnNQ0K&M5SgsSTvcA`ji8TJjXMe72#*AgDTbl*P&C-`9quW*q{Nzn zU%U!)uQ5p7eOS=}l1tt}+~SR}a6TJu_zE?Pys?p*tlx-wpkd-_bfg|3b0ze+X!He&^+R-qi^nx)_FTqbQ?Jq1O5` zC3bPwic6^f0`YerhUD#U!aZUlj6-Ik;iN4v44w%iV+_GXClRoz>_&Wra}maM$ff)c zJyv2BgXyCcT}X>Zuqys$Vi&o;igHtIZEc;Z@^4eWjOdf&+p~tbdlg;|DLJQ%M`B?2 zuDOhQ&tFl_+{k`IDRXurbi{1b+2~!Dx|=UU&-j;-d>Eq6L;3y>;ThNZ(I+AEG@mi9 z<|OTGQ1Yj~ z=xD_`m-XL6?1QEwa?lvK8AlZTBVf&W)LqAO;+X?5HSdT+4JTN=_OWv>*WhXKy-3`(GQ3hIcDZ~PnI`Yz_fK#9i1+ML`IfO*)7Zsu z51Rmq6*xyvN6_js%3c#c^?HLEp;NHBX@QgCwD~L0z19c9@xVcxg``obnAR^6S$aJ`P zf64@r%UX<8 zx~Y3UBoEC*t>RJz|DDTue)0SjTgdoiHq-Yj;Meo+a?d7@RC~@m76 zyPm!bQ&Z|oyx*X%fsyxaa(M>bN=~Yn)l{$vM!_nW1-ts=dMsgFPVUMo`%~J;{g@1w zgE8Ip(>O>->Hj$DUpg&1**v`WH$&7(kSH7s2wW-8%1e;*gV%FxTxLh^3Hborc z^;Og_TDR?}@U@ChO~qD<<^4OY{1N|6(o>^Q;EGVzPGr>>%9kvQ1^t-HeJd@V>gHvC z?4%44OoB}?YBBrgCxtt$tb4dO{o!%rr`}|%3eHsGjA;<)yAL6IH@WW+Jk5Im{xVqj zwz^pT>&ba9@qj7bIMZRl=8xC^9Sm1`tS)D!-Eu*(n>2n5@iE0c-WxNCvls7LCT~5U z+-Hg4Ss-^9Zl|~S^v-(YJbN&yIa>g?LbBOW0j5fM974B14 zgo&%Xf<0Blh^iU`V|%F)-Fts0GC%$8)AUKU{>fSI-ocEUk7u7}8PEB38Et2YQOREY zBl}a@N*~gf^eKG{4f>?Ud0ejltqNmR<;gnT-@}9JOh{Vy(lz;?mdMVJp9-sASCO~H zq9>6L>0@8Sb^S`tLAk>(`iS_-#U`rGSfq~Bl{V5=`Vf7LxWA-qe@18st*S8Is@(9v zUH3bhKD0Y!*FiWk8~wOqG>tTWl(jCPjF?5<5-eNER}zz! znD#-=t2~mIGWjNTq^`6n=aU~j>`VHTzO}S|Yd)-2O^2&x>z{3LG2fa3?rDCz_S9eR zWGta0{VV6NaTO*&by`$#9 zT1~3kMy*GFy`T1koBY+9KBZp7Ih4M=vC1QPi?f$}Q|hp;+V0Ox!vbo&B>R_r{x1Ie QGqL=)um8@#|HlmcA1w(S00000 literal 0 HcmV?d00001 diff --git a/apps/webapp/public/react-date-range/styles.css b/apps/webapp/public/react-date-range/styles.css new file mode 100644 index 000000000..930c7de43 --- /dev/null +++ b/apps/webapp/public/react-date-range/styles.css @@ -0,0 +1,197 @@ +.rdrCalendarWrapper { + box-sizing: border-box; + background: #ffffff; + display: inline-flex; + flex-direction: column; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.rdrDateDisplay{ + display: flex; + justify-content: space-between; +} + +.rdrDateDisplayItem{ + flex: 1 1; + width: 0; + text-align: center; + color: inherit; +} + +.rdrDateDisplayItem + .rdrDateDisplayItem{ + margin-left: 0.833em; + } + +.rdrDateDisplayItem input{ + text-align: inherit + } + +.rdrDateDisplayItem input:disabled{ + cursor: default; + } + +.rdrDateDisplayItemActive{} + +.rdrMonthAndYearWrapper { + box-sizing: inherit; + display: flex; + justify-content: space-between; +} + +.rdrMonthAndYearPickers{ + flex: 1 1 auto; + display: flex; + justify-content: center; + align-items: center; +} + +.rdrMonthPicker{} + +.rdrYearPicker{} + +.rdrNextPrevButton { + box-sizing: inherit; + cursor: pointer; + outline: none; +} + +.rdrPprevButton {} + +.rdrNextButton {} + +.rdrMonths{ + display: flex; +} + +.rdrMonthsVertical{ + flex-direction: column; +} + +.rdrMonthsHorizontal > div > div > div{ + display: flex; + flex-direction: row; +} + +.rdrMonth{ + width: 27.667em; +} + +.rdrWeekDays{ + display: flex; +} + +.rdrWeekDay { + flex-basis: calc(100% / 7); + box-sizing: inherit; + text-align: center; +} + +.rdrDays{ + display: flex; + flex-wrap: wrap; +} + +.rdrDateDisplayWrapper{} + +.rdrMonthName{} + +.rdrInfiniteMonths{ + overflow: auto; +} + +.rdrDateRangeWrapper{ + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.rdrDateInput { + position: relative; +} + +.rdrDateInput input { + outline: none; + } + +.rdrDateInput .rdrWarning { + position: absolute; + font-size: 1.6em; + line-height: 1.6em; + top: 0; + right: .25em; + color: #FF0000; + } + +.rdrDay { + box-sizing: inherit; + width: calc(100% / 7); + position: relative; + font: inherit; + cursor: pointer; +} + +.rdrDayNumber { + display: block; + position: relative; +} + +.rdrDayNumber span{ + color: #1d2429; + } + +.rdrDayDisabled { + cursor: not-allowed; +} + +@supports (-ms-ime-align: auto) { + .rdrDay { + flex-basis: 14.285% !important; + } +} + +.rdrSelected, .rdrInRange, .rdrStartEdge, .rdrEndEdge{ + pointer-events: none; +} + +.rdrInRange{} + +.rdrDayStartPreview, .rdrDayInPreview, .rdrDayEndPreview{ + pointer-events: none; +} + +.rdrDayHovered{} + +.rdrDayActive{} + +.rdrDateRangePickerWrapper{ + display: inline-flex; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.rdrDefinedRangesWrapper{} + +.rdrStaticRanges{ + display: flex; + flex-direction: column; +} + +.rdrStaticRange{ + font-size: inherit; +} + +.rdrStaticRangeLabel{} + +.rdrInputRanges{} + +.rdrInputRange{ + display: flex; +} + +.rdrInputRangeInput{} diff --git a/apps/webapp/public/react-date-range/theme/default.css b/apps/webapp/public/react-date-range/theme/default.css new file mode 100644 index 000000000..0e12bb90b --- /dev/null +++ b/apps/webapp/public/react-date-range/theme/default.css @@ -0,0 +1,386 @@ +.rdrCalendarWrapper{ + color: #000000; + font-size: 12px; +} + +.rdrDateDisplayWrapper{ + background-color: rgb(239, 242, 247); +} + +.rdrDateDisplay{ + margin: 0.833em; +} + +.rdrDateDisplayItem{ + border-radius: 4px; + background-color: rgb(255, 255, 255); + box-shadow: 0 1px 2px 0 rgba(35, 57, 66, 0.21); + border: 1px solid transparent; +} + +.rdrDateDisplayItem input{ + cursor: pointer; + height: 2.5em; + line-height: 2.5em; + border: 0px; + background: transparent; + width: 100%; + color: #849095; + } + +.rdrDateDisplayItemActive{ + border-color: currentColor; +} + +.rdrDateDisplayItemActive input{ + color: #7d888d + } + +.rdrMonthAndYearWrapper { + align-items: center; + height: 60px; + padding-top: 10px; +} + +.rdrMonthAndYearPickers{ + font-weight: 600; +} + +.rdrMonthAndYearPickers select{ + -moz-appearance: none; + appearance: none; + -webkit-appearance: none; + border: 0; + background: transparent; + padding: 10px 30px 10px 10px; + border-radius: 4px; + outline: 0; + color: #3e484f; + background: url("data:image/svg+xml;utf8,") no-repeat; + background-position: right 8px center; + cursor: pointer; + text-align: center + } + +.rdrMonthAndYearPickers select:hover{ + background-color: rgba(0,0,0,0.07); + } + +.rdrMonthPicker, .rdrYearPicker{ + margin: 0 5px +} + +.rdrNextPrevButton { + display: block; + width: 24px; + height: 24px; + margin: 0 0.833em; + padding: 0; + border: 0; + border-radius: 5px; + background: #EFF2F7 +} + +.rdrNextPrevButton:hover{ + background: #E1E7F0; + } + +.rdrNextPrevButton i { + display: block; + width: 0; + height: 0; + padding: 0; + text-align: center; + border-style: solid; + margin: auto; + transform: translate(-3px, 0px); + } + +.rdrPprevButton i { + border-width: 4px 6px 4px 4px; + border-color: transparent rgb(52, 73, 94) transparent transparent; + transform: translate(-3px, 0px); + } + +.rdrNextButton i { + margin: 0 0 0 7px; + border-width: 4px 4px 4px 6px; + border-color: transparent transparent transparent rgb(52, 73, 94); + transform: translate(3px, 0px); + } + +.rdrWeekDays { + padding: 0 0.833em; +} + +.rdrMonth{ + padding: 0 0.833em 1.666em 0.833em; +} + +.rdrMonth .rdrWeekDays { + padding: 0; + } + +.rdrMonths.rdrMonthsVertical .rdrMonth:first-child .rdrMonthName{ + display: none; +} + +.rdrWeekDay { + font-weight: 400; + line-height: 2.667em; + color: rgb(132, 144, 149); +} + +.rdrDay { + background: transparent; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + border: 0; + padding: 0; + line-height: 3.000em; + height: 3.000em; + text-align: center; + color: #1d2429 +} + +.rdrDay:focus { + outline: 0; + } + +.rdrDayNumber { + outline: 0; + font-weight: 300; + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + top: 5px; + bottom: 5px; + display: flex; + align-items: center; + justify-content: center; +} + +.rdrDayToday .rdrDayNumber span{ + font-weight: 500 +} + +.rdrDayToday .rdrDayNumber span:after{ + content: ''; + position: absolute; + bottom: 4px; + left: 50%; + transform: translate(-50%, 0); + width: 18px; + height: 2px; + border-radius: 2px; + background: #3d91ff; + } + +.rdrDayToday:not(.rdrDayPassive) .rdrInRange ~ .rdrDayNumber span:after,.rdrDayToday:not(.rdrDayPassive) .rdrStartEdge ~ .rdrDayNumber span:after,.rdrDayToday:not(.rdrDayPassive) .rdrEndEdge ~ .rdrDayNumber span:after,.rdrDayToday:not(.rdrDayPassive) .rdrSelected ~ .rdrDayNumber span:after{ + background: #fff; + } + +.rdrDay:not(.rdrDayPassive) .rdrInRange ~ .rdrDayNumber span,.rdrDay:not(.rdrDayPassive) .rdrStartEdge ~ .rdrDayNumber span,.rdrDay:not(.rdrDayPassive) .rdrEndEdge ~ .rdrDayNumber span,.rdrDay:not(.rdrDayPassive) .rdrSelected ~ .rdrDayNumber span{ + color: rgba(255, 255, 255, 0.85); + } + +.rdrSelected, .rdrInRange, .rdrStartEdge, .rdrEndEdge{ + background: currentColor; + position: absolute; + top: 5px; + left: 0; + right: 0; + bottom: 5px; +} + +.rdrSelected{ + left: 2px; + right: 2px; +} + +.rdrInRange{} + +.rdrStartEdge{ + border-top-left-radius: 1.042em; + border-bottom-left-radius: 1.042em; + left: 2px; +} + +.rdrEndEdge{ + border-top-right-radius: 1.042em; + border-bottom-right-radius: 1.042em; + right: 2px; +} + +.rdrSelected{ + border-radius: 1.042em; +} + +.rdrDayStartOfMonth .rdrInRange, .rdrDayStartOfMonth .rdrEndEdge, .rdrDayStartOfWeek .rdrInRange, .rdrDayStartOfWeek .rdrEndEdge{ + border-top-left-radius: 1.042em; + border-bottom-left-radius: 1.042em; + left: 2px; + } + +.rdrDayEndOfMonth .rdrInRange, .rdrDayEndOfMonth .rdrStartEdge, .rdrDayEndOfWeek .rdrInRange, .rdrDayEndOfWeek .rdrStartEdge{ + border-top-right-radius: 1.042em; + border-bottom-right-radius: 1.042em; + right: 2px; + } + +.rdrDayStartOfMonth .rdrDayInPreview, .rdrDayStartOfMonth .rdrDayEndPreview, .rdrDayStartOfWeek .rdrDayInPreview, .rdrDayStartOfWeek .rdrDayEndPreview{ + border-top-left-radius: 1.333em; + border-bottom-left-radius: 1.333em; + border-left-width: 1px; + left: 0px; + } + +.rdrDayEndOfMonth .rdrDayInPreview, .rdrDayEndOfMonth .rdrDayStartPreview, .rdrDayEndOfWeek .rdrDayInPreview, .rdrDayEndOfWeek .rdrDayStartPreview{ + border-top-right-radius: 1.333em; + border-bottom-right-radius: 1.333em; + border-right-width: 1px; + right: 0px; + } + +.rdrDayStartPreview, .rdrDayInPreview, .rdrDayEndPreview{ + background: rgba(255, 255, 255, 0.09); + position: absolute; + top: 3px; + left: 0px; + right: 0px; + bottom: 3px; + pointer-events: none; + border: 0px solid currentColor; + z-index: 1; +} + +.rdrDayStartPreview{ + border-top-width: 1px; + border-left-width: 1px; + border-bottom-width: 1px; + border-top-left-radius: 1.333em; + border-bottom-left-radius: 1.333em; + left: 0px; +} + +.rdrDayInPreview{ + border-top-width: 1px; + border-bottom-width: 1px; +} + +.rdrDayEndPreview{ + border-top-width: 1px; + border-right-width: 1px; + border-bottom-width: 1px; + border-top-right-radius: 1.333em; + border-bottom-right-radius: 1.333em; + right: 2px; + right: 0px; +} + +.rdrDefinedRangesWrapper{ + font-size: 12px; + width: 226px; + border-right: solid 1px #eff2f7; + background: #fff; +} + +.rdrDefinedRangesWrapper .rdrStaticRangeSelected{ + color: currentColor; + font-weight: 600; + } + +.rdrStaticRange{ + border: 0; + cursor: pointer; + display: block; + outline: 0; + border-bottom: 1px solid #eff2f7; + padding: 0; + background: #fff +} + +.rdrStaticRange:hover .rdrStaticRangeLabel,.rdrStaticRange:focus .rdrStaticRangeLabel{ + background: #eff2f7; + } + +.rdrStaticRangeLabel{ + display: block; + outline: 0; + line-height: 18px; + padding: 10px 20px; + text-align: left; +} + +.rdrInputRanges{ + padding: 10px 0; +} + +.rdrInputRange{ + align-items: center; + padding: 5px 20px; +} + +.rdrInputRangeInput{ + width: 30px; + height: 30px; + line-height: 30px; + border-radius: 4px; + text-align: center; + border: solid 1px rgb(222, 231, 235); + margin-right: 10px; + color: rgb(108, 118, 122) +} + +.rdrInputRangeInput:focus, .rdrInputRangeInput:hover{ + border-color: rgb(180, 191, 196); + outline: 0; + color: #333; + } + +.rdrCalendarWrapper:not(.rdrDateRangeWrapper) .rdrDayHovered .rdrDayNumber:after{ + content: ''; + border: 1px solid currentColor; + border-radius: 1.333em; + position: absolute; + top: -2px; + bottom: -2px; + left: 0px; + right: 0px; + background: transparent; +} + +.rdrDayPassive{ + pointer-events: none; +} + +.rdrDayPassive .rdrDayNumber span{ + color: #d5dce0; + } + +.rdrDayPassive .rdrInRange, .rdrDayPassive .rdrStartEdge, .rdrDayPassive .rdrEndEdge, .rdrDayPassive .rdrSelected, .rdrDayPassive .rdrDayStartPreview, .rdrDayPassive .rdrDayInPreview, .rdrDayPassive .rdrDayEndPreview{ + display: none; + } + +.rdrDayDisabled { + background-color: rgb(248, 248, 248); +} + +.rdrDayDisabled .rdrDayNumber span{ + color: #aeb9bf; + } + +.rdrDayDisabled .rdrInRange, .rdrDayDisabled .rdrStartEdge, .rdrDayDisabled .rdrEndEdge, .rdrDayDisabled .rdrSelected, .rdrDayDisabled .rdrDayStartPreview, .rdrDayDisabled .rdrDayInPreview, .rdrDayDisabled .rdrDayEndPreview{ + filter: grayscale(100%) opacity(60%); + } + +.rdrMonthName{ + text-align: left; + font-weight: 600; + color: #849095; + padding: 0.833em; +} diff --git a/apps/webapp/remix.config.js b/apps/webapp/remix.config.js new file mode 100644 index 000000000..e398c17c1 --- /dev/null +++ b/apps/webapp/remix.config.js @@ -0,0 +1,16 @@ +/** @type {import('@remix-run/dev').AppConfig} */ +module.exports = { + cacheDirectory: "./node_modules/.cache/remix", + ignoredRouteFiles: ["**/.*"], + devServerPort: 8002, + serverDependenciesToBundle: [ + "@apihero/internal-nobuild", + "pretty-bytes", + "marked", + "@cfworker/json-schema", + "@apihero/node", + ], + watchPaths: async () => { + return ["../../packages/internal-nobuild/src/**/*"]; + }, +}; diff --git a/apps/webapp/remix.env.d.ts b/apps/webapp/remix.env.d.ts new file mode 100644 index 000000000..72e2affe3 --- /dev/null +++ b/apps/webapp/remix.env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/apps/webapp/server.ts b/apps/webapp/server.ts new file mode 100644 index 000000000..2a07a5678 --- /dev/null +++ b/apps/webapp/server.ts @@ -0,0 +1,111 @@ +import path from "path"; +import express from "express"; +import compression from "compression"; +import morgan from "morgan"; +import { createRequestHandler as expressCreateRequestHandler } from "@remix-run/express"; +import { wrapExpressCreateRequestHandler } from "@sentry/remix"; + +const createRequestHandler = + process.env.NODE_ENV === "production" + ? wrapExpressCreateRequestHandler(expressCreateRequestHandler) + : expressCreateRequestHandler; + +const app = express(); + +app.use((req, res, next) => { + // helpful headers: + res.set("x-fly-region", process.env.FLY_REGION ?? "unknown"); + res.set("Strict-Transport-Security", `max-age=${60 * 60 * 24 * 365 * 100}`); + + // /clean-urls/ -> /clean-urls + if (req.path.endsWith("/") && req.path.length > 1) { + const query = req.url.slice(req.path.length); + const safepath = req.path.slice(0, -1).replace(/\/+/g, "/"); + res.redirect(301, safepath + query); + return; + } + next(); +}); + +// if we're not in the primary region, then we need to make sure all +// non-GET/HEAD/OPTIONS requests hit the primary region rather than read-only +// Postgres DBs. +// learn more: https://fly.io/docs/getting-started/multi-region-databases/#replay-the-request +app.all("*", function getReplayResponse(req, res, next) { + const { method, path: pathname } = req; + const { PRIMARY_REGION, FLY_REGION } = process.env; + + const isMethodReplayable = !["GET", "OPTIONS", "HEAD"].includes(method); + const isReadOnlyRegion = + FLY_REGION && PRIMARY_REGION && FLY_REGION !== PRIMARY_REGION; + + const shouldReplay = isMethodReplayable && isReadOnlyRegion; + + if (!shouldReplay) return next(); + + const logInfo = { + pathname, + method, + PRIMARY_REGION, + FLY_REGION, + }; + console.info(`Replaying:`, logInfo); + res.set("fly-replay", `region=${PRIMARY_REGION}`); + return res.sendStatus(409); +}); + +app.use(compression()); + +// http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header +app.disable("x-powered-by"); + +// Remix fingerprints its assets so we can cache forever. +app.use( + "/build", + express.static("public/build", { immutable: true, maxAge: "1y" }) +); + +// Everything else (like favicon.ico) is cached for an hour. You may want to be +// more aggressive with this caching. +app.use(express.static("public", { maxAge: "1h" })); + +app.use(morgan("tiny")); + +const MODE = process.env.NODE_ENV; +const BUILD_DIR = path.join(process.cwd(), "build"); + +app.all( + "*", + MODE === "production" + ? createRequestHandler({ build: require(BUILD_DIR) }) + : (...args) => { + purgeRequireCache(); + const requestHandler = createRequestHandler({ + build: require(BUILD_DIR), + mode: MODE, + }); + return requestHandler(...args); + } +); + +const port = process.env.REMIX_APP_PORT || 3000; + +app.listen(port, () => { + // require the built app so we're ready when the first request comes in + require(BUILD_DIR); + console.log(`✅ app ready: http://localhost:${port}`); +}); + +function purgeRequireCache() { + // purge require cache on requests for "server side HMR" this won't let + // you have in-memory objects between requests in development, + // alternatively you can set up nodemon/pm2-dev to restart the server on + // file changes, we prefer the DX of this though, so we've included it + // for you by default + for (const key in require.cache) { + if (key.startsWith(BUILD_DIR)) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete require.cache[key]; + } + } +} diff --git a/apps/webapp/start.sh b/apps/webapp/start.sh new file mode 100644 index 000000000..e7b331bd9 --- /dev/null +++ b/apps/webapp/start.sh @@ -0,0 +1,3 @@ +set -ex +npx prisma migrate deploy --schema packages/database/prisma/schema.prisma +node ./server.js \ No newline at end of file diff --git a/apps/webapp/styles/tailwind-include.css b/apps/webapp/styles/tailwind-include.css new file mode 100644 index 000000000..2b8de3a81 --- /dev/null +++ b/apps/webapp/styles/tailwind-include.css @@ -0,0 +1,9 @@ +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&family=Roboto+Mono:wght@300;400;500;600;700&display=swap"); + +@import "tailwindcss/base"; +@import "tailwindcss/components"; +@import "tailwindcss/utilities"; + +@import "../node_modules/react-date-range/dist/styles.css"; +@import "../node_modules/react-date-range/dist/theme/default.css"; +@import '@tremor/react/dist/esm/tremor.css'; \ No newline at end of file diff --git a/apps/webapp/tailwind.config.js b/apps/webapp/tailwind.config.js new file mode 100644 index 000000000..61f7eec57 --- /dev/null +++ b/apps/webapp/tailwind.config.js @@ -0,0 +1,32 @@ +/** @type {import('tailwindcss').Config} */ +const parentConfig = require("@apihero/tailwind-config/tailwind.config"); +const colors = require('tailwindcss/colors') +const midnightColors = { + 1000: "#030713", +}; +const toxicColors = { + 500: "#41FF54" +}; +module.exports = { + ...parentConfig, + theme: { + ...parentConfig.theme, + extend: { + ...parentConfig.theme.extend, + height: { + mainMobileContainerHeight: "calc(100vh - 145px)", + mainDesktopContainerHeight: "calc(100vh - 65px)", + editEndpointContainerHeight: "calc(100vh - 112px)", + }, + colors: { + midnight: midnightColors[1000], + toxic: toxicColors[500], + }, + backgroundImage: { + "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", + "gradient-background": `radial-gradient(${colors.slate[800]} 0%,${midnightColors[1000]} 50%, ${midnightColors[1000]} 100%)`, + "gradient-secondary": `linear-gradient(90deg, ${colors.blue[600]} 0%, ${colors.purple[500]} 100%)`, + }, + }, + }, +}; diff --git a/apps/webapp/test/setup-test-env.ts b/apps/webapp/test/setup-test-env.ts new file mode 100644 index 000000000..48fcc4317 --- /dev/null +++ b/apps/webapp/test/setup-test-env.ts @@ -0,0 +1,4 @@ +import { installGlobals } from "@remix-run/node"; +import "@testing-library/jest-dom/extend-expect"; + +installGlobals(); diff --git a/apps/webapp/tsconfig.json b/apps/webapp/tsconfig.json new file mode 100644 index 000000000..2518afebb --- /dev/null +++ b/apps/webapp/tsconfig.json @@ -0,0 +1,31 @@ +{ + "exclude": ["./cypress", "./cypress.config.ts"], + "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2019"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "moduleResolution": "node", + "resolveJsonModule": true, + "target": "ES2019", + "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"], + "@apihero/internal-nobuild": [ + "../../packages/internal-nobuild/src/index" + ], + "@apihero/internal-nobuild/*": ["../../packages/internal-nobuild/src/*"] + }, + + // Remix takes care of building everything in `remix build`. + "noEmit": true + } + // "references": [{ "path": "../../packages/ui/tsconfig.json" }], +} diff --git a/apps/webapp/vitest.config.ts b/apps/webapp/vitest.config.ts new file mode 100644 index 000000000..de07a3255 --- /dev/null +++ b/apps/webapp/vitest.config.ts @@ -0,0 +1,15 @@ +/// +/// + +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [react(), tsconfigPaths()], + test: { + globals: true, + environment: "happy-dom", + setupFiles: ["./test/setup-test-env.ts"], + }, +}); diff --git a/config-packages/eslint-config-custom-next/index.js b/config-packages/eslint-config-custom-next/index.js new file mode 100644 index 000000000..0b82b35b2 --- /dev/null +++ b/config-packages/eslint-config-custom-next/index.js @@ -0,0 +1,12 @@ +module.exports = { + extends: ["next", "turbo", "prettier"], + settings: { + next: { + rootDir: ["apps/*/", "packages/*/"], + }, + }, + rules: { + "@next/next/no-html-link-for-pages": "off", + "react/jsx-key": "off", + }, +}; diff --git a/config-packages/eslint-config-custom-next/package.json b/config-packages/eslint-config-custom-next/package.json new file mode 100644 index 000000000..9a5174fa3 --- /dev/null +++ b/config-packages/eslint-config-custom-next/package.json @@ -0,0 +1,17 @@ +{ + "name": "eslint-config-custom-next", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "devDependencies": { + "eslint": "^8.24.0", + "eslint-config-next": "^12.3.1", + "eslint-config-prettier": "^8.3.0", + "eslint-config-turbo": "latest", + "eslint-plugin-react": "7.31.8", + "typescript": "^4.8.4" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/config-packages/eslint-config-custom/index.js b/config-packages/eslint-config-custom/index.js new file mode 100644 index 000000000..b4006d63b --- /dev/null +++ b/config-packages/eslint-config-custom/index.js @@ -0,0 +1,8 @@ +module.exports = { + extends: ["turbo", "prettier"], + settings: { + react: { + version: "detect", + }, + }, +}; diff --git a/config-packages/eslint-config-custom/package.json b/config-packages/eslint-config-custom/package.json new file mode 100644 index 000000000..548197001 --- /dev/null +++ b/config-packages/eslint-config-custom/package.json @@ -0,0 +1,17 @@ +{ + "name": "eslint-config-custom", + "version": "0.0.0", + "private": true, + "license": "MIT", + "main": "index.js", + "devDependencies": { + "eslint": "^8.24.0", + "eslint-config-prettier": "^8.3.0", + "eslint-config-turbo": "latest", + "eslint-plugin-react": "7.31.8", + "typescript": "^4.8.4" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/config-packages/eslint-config-vite/eslint-preset.js b/config-packages/eslint-config-vite/eslint-preset.js new file mode 100644 index 000000000..566210abb --- /dev/null +++ b/config-packages/eslint-config-vite/eslint-preset.js @@ -0,0 +1,22 @@ +module.exports = { + env: { + node: true, + }, + parser: "@typescript-eslint/parser", + extends: [ + "turbo", + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "prettier", + ], + plugins: ["@typescript-eslint"], + parserOptions: { + sourceType: "module", + ecmaVersion: 2020, + }, + rules: { + "@typescript-eslint/no-non-null-assertion": "off", + "no-var": "off", + "@typescript-eslint/explicit-function-return-type": "off", + }, +}; diff --git a/config-packages/eslint-config-vite/package.json b/config-packages/eslint-config-vite/package.json new file mode 100644 index 000000000..65b14bc44 --- /dev/null +++ b/config-packages/eslint-config-vite/package.json @@ -0,0 +1,20 @@ +{ + "name": "@apihero/eslint-config-vite", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "files": [ + "eslint-preset.js" + ], + "devDependencies": { + "@typescript-eslint/eslint-plugin": "^5.38.1", + "@typescript-eslint/parser": "^5.38.1", + "eslint": "^8.24.0", + "eslint-config-prettier": "^8.5.0", + "eslint-config-turbo": "latest", + "typescript": "^4.8.4" + }, + "publishConfig": { + "access": "public" + } +} \ No newline at end of file diff --git a/config-packages/tailwind-config/package.json b/config-packages/tailwind-config/package.json new file mode 100644 index 000000000..c2b8a1403 --- /dev/null +++ b/config-packages/tailwind-config/package.json @@ -0,0 +1,9 @@ +{ + "name": "@apihero/tailwind-config", + "version": "0.0.0", + "private": true, + "devDependencies": {}, + "publishConfig": { + "access": "public" + } +} diff --git a/config-packages/tailwind-config/postcss.config.js b/config-packages/tailwind-config/postcss.config.js new file mode 100644 index 000000000..12a703d90 --- /dev/null +++ b/config-packages/tailwind-config/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/config-packages/tailwind-config/tailwind.config.js b/config-packages/tailwind-config/tailwind.config.js new file mode 100644 index 000000000..899ffb72f --- /dev/null +++ b/config-packages/tailwind-config/tailwind.config.js @@ -0,0 +1,24 @@ +const colors = require("tailwindcss/colors"); + +module.exports = { + content: [ + // app content + // "./src/**/*.{ts,jsx,tsx}", + "./app/**/*.{ts,jsx,tsx}", + // include packages if not transpiling + "../../packages/**/*.{ts,tsx}", + ], + theme: { + extend: { + fontFamily: { + sans: ["Inter", "sans-serif"], + mono: ["Roboto Mono", "monospace"], + }, + colors: { + brandblue: colors.blue[500], + brandred: colors.red[500], + }, + }, + }, + plugins: [require("@tailwindcss/forms"), require("@tailwindcss/typography")], +}; diff --git a/config-packages/tsconfig/base.json b/config-packages/tsconfig/base.json new file mode 100644 index 000000000..858021ed8 --- /dev/null +++ b/config-packages/tsconfig/base.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Default", + "compilerOptions": { + "composite": false, + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "inlineSources": false, + "isolatedModules": true, + "moduleResolution": "node", + "noUnusedLocals": false, + "noUnusedParameters": false, + "preserveWatchOutput": true, + "skipLibCheck": true, + "strict": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "sourceMap": true, + "resolveJsonModule": true + }, + "exclude": ["node_modules", "**/*/lib", "**/*/dist"], + "references": [{ "path": "../utils/" }] +} diff --git a/config-packages/tsconfig/nextjs.json b/config-packages/tsconfig/nextjs.json new file mode 100644 index 000000000..91cd404f7 --- /dev/null +++ b/config-packages/tsconfig/nextjs.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Next.js", + "extends": "./base.json", + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "incremental": true, + "esModuleInterop": true, + "module": "esnext", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve" + }, + "include": ["src", "next-env.d.ts"], + "exclude": ["node_modules"] +} diff --git a/config-packages/tsconfig/node18.json b/config-packages/tsconfig/node18.json new file mode 100644 index 000000000..19174e674 --- /dev/null +++ b/config-packages/tsconfig/node18.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Node 18", + "extends": "./base.json", + "compilerOptions": { + "lib": [ + "ES2021" + ], + "module": "commonjs", + "target": "ES2021", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + } +} \ No newline at end of file diff --git a/config-packages/tsconfig/package.json b/config-packages/tsconfig/package.json new file mode 100644 index 000000000..05b0d4d26 --- /dev/null +++ b/config-packages/tsconfig/package.json @@ -0,0 +1,9 @@ +{ + "name": "@apihero/tsconfig", + "version": "0.0.0", + "private": true, + "license": "MIT", + "publishConfig": { + "access": "public" + } +} diff --git a/config-packages/tsconfig/react-library.json b/config-packages/tsconfig/react-library.json new file mode 100644 index 000000000..6d6a7fea9 --- /dev/null +++ b/config-packages/tsconfig/react-library.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "React Library", + "extends": "./base.json", + "compilerOptions": { + "lib": ["ES2015"], + "module": "ESNext", + "target": "ES6", + "jsx": "react-jsx" + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..24885315b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +version: "3.7" + +volumes: + database: + driver: local + +services: + redis: + image: redis:7.0.0-alpine + command: redis-server + restart: always + ports: + - 6379:6379 + db: + image: postgres:latest + restart: always + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=postgres + ports: + - "5432:5432" + volumes: + - database:/var/lib/postgresql-docker/data + networks: + - app_network + +networks: + app_network: + external: true diff --git a/package.json b/package.json new file mode 100644 index 000000000..a27c0ec89 --- /dev/null +++ b/package.json @@ -0,0 +1,56 @@ +{ + "name": "apihero", + "private": true, + "workspaces": [ + "apps/*", + "packages/*" + ], + "version": "0.1.0", + "prisma": { + "schema": "apps/webapp/prisma/schema.prisma", + "seed": "tsx apps/webapp/prisma/seed.ts" + }, + "scripts": { + "build": "turbo run build", + "build:force": "turbo run build --force", + "db:migrate:deploy": "turbo run db:migrate:deploy", + "db:migrate:dev": "turbo run db:migrate:dev", + "db:push": "turbo run db:push", + "db:seed": "turbo run db:seed --no-cache", + "db:migrate:force": "turbo run db:migrate:force --no-cache", + "dev": "turbo run dev --parallel", + "format": "prettier --write \"**/*.{ts,tsx,md}\"", + "generate": "turbo run generate", + "lint": "turbo run lint", + "docker:db": "docker-compose -f docker-compose.yml up -d", + "docker:db:stop": "docker-compose -f docker-compose.yml down", + "docker:build": "turbo run docker:build", + "docker:build:webapp": "docker build -t apihero-webapp -f ./apps/webapp/Dockerfile .", + "docker:run:webapp": "docker run -it --init --rm -p 3000:3000 --env-file ./apps/webapp/.env --env DATABASE_URL='postgresql://postgres:postgres@db:5432/postgres' --network=app_network apihero-webapp", + "docker:build:logs": "docker build -t apihero-logs -f ./apps/logs/Dockerfile .", + "docker:run:logs": "docker run -it --init --rm -p 3001:3001 --env-file ./apps/logs/.env --env PORT=3001 --env DATABASE_URL='postgresql://postgres:postgres@db:5433/postgres' --network=app_network apihero-logs", + "test": "turbo run test", + "test:dev": "turbo run test:dev", + "start": "turbo run start", + "clean": "turbo run clean", + "clean:node_modules": "find . -name 'node_modules' -type d -prune -exec rm -rf '{}' +", + "typecheck": "turbo run typecheck", + "test:e2e:dev": "turbo run test:e2e:dev", + "test:e2e:ci": "turbo run test:e2e:ci", + "setup": "turbo run generate db:migrate:force db:seed", + "env:pull": "turbo run env:pull" + }, + "devDependencies": { + "@manypkg/cli": "^0.19.2", + "@tailwindcss/forms": "^0.5.3", + "@tailwindcss/typography": "^0.5.7", + "autoprefixer": "^10.4.12", + "eslint-config-custom": "*", + "postcss": "^8.4.17", + "prettier": "^2.5.1", + "tailwindcss": "^3.1.8", + "tsx": "^3.7.1", + "turbo": "^1.5.5" + }, + "packageManager": "pnpm@7.13.5" +} \ No newline at end of file diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 000000000..c54dbc83e --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +packages: + - "config-packages/*" + - "packages/*" + - "apps/**" + - "examples/*" diff --git a/turbo.json b/turbo.json new file mode 100644 index 000000000..c3331ff13 --- /dev/null +++ b/turbo.json @@ -0,0 +1,127 @@ +{ + "$schema": "https://turborepo.org/schema.json", + "pipeline": { + "build": { + "dependsOn": [ + "^build" + ], + "outputs": [ + "dist/**", + "public/build/**", + "build/**", + "app/styles/tailwind.css", + ".cache" + ] + }, + "webapp#start": { + "dependsOn": [ + "^build" + ], + "outputs": [ + "public/build/**" + ] + }, + "start": { + "dependsOn": [ + "^build" + ], + "outputs": [ + "public/build/**" + ] + }, + "db:migrate:deploy": { + "outputs": [] + }, + "db:migrate:dev": { + "outputs": [] + }, + "db:push": { + "outputs": [] + }, + "db:seed": { + "outputs": [], + "cache": false + }, + "db:migrate:force": { + "outputs": [] + }, + "dev": { + "cache": false + }, + "generate": { + "dependsOn": [ + "^generate" + ] + }, + "lint": { + "outputs": [] + }, + "docker:build": { + "outputs": [], + "cache": false + }, + "test": { + "outputs": [] + }, + "test:dev": { + "outputs": [], + "cache": false + }, + "test:e2e:dev": { + "dependsOn": [ + "^build" + ], + "outputs": [], + "cache": false + }, + "test:e2e:ci": { + "dependsOn": [ + "^build" + ], + "outputs": [] + }, + "typecheck": { + "dependsOn": [ + "^build" + ], + "outputs": [] + }, + "clean": { + "cache": false + }, + "env:pull": { + "cache": false + } + }, + "globalDependencies": [ + ".env" + ], + "globalEnv": [ + "NODE_ENV", + "REMIX_APP_PORT", + "FLY_REGION", + "PRIMARY_REGION", + "CI", + "DATABASE_URL", + "GATEWAY_ORIGIN", + "GATEWAY_API_PRIVATE_KEY", + "SESSION_SECRET", + "GITHUB_USERNAME", + "GITHUB_TOKEN", + "MAGIC_LINK_SECRET", + "GITHUB_CLIENT_ID", + "GITHUB_SECRET", + "APP_ORIGIN", + "SENDGRID_API_KEY", + "SENDGRID_FROM_EMAIL", + "POSTHOG_PROJECT_KEY", + "API_AUTHENTICATION_TOKEN", + "LOGS_API_AUTHENTICATION_TOKEN", + "LOGS_ORIGIN", + "NEXT_PUBLIC_GITHUB_TOKEN", + "MAILGUN_KEY", + "MERGENT_KEY", + "PROXY_URL", + "APIHERO_PROJECT_KEY" + ] +} \ No newline at end of file