Initial commit of the mono repo

This commit is contained in:
Matt Aitken
2022-12-06 12:28:16 +00:00
parent 604a9d7ee4
commit dc2e4c3a87
91 changed files with 3831 additions and 0 deletions
+37
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
*/**.js
*/**.d.ts
packages/*/dist
packages/*/lib
+14
View File
@@ -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,
},
};
+45
View File
@@ -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
+64
View File
@@ -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.
+7
View File
@@ -0,0 +1,7 @@
{
"extends": [
"@remix-run/eslint-config",
"@remix-run/eslint-config/node",
"prettier"
]
}
+10
View File
@@ -0,0 +1,10 @@
node_modules
/.cache
/build
/public/build
/cypress/screenshots
/cypress/videos
/app/styles/tailwind.css
+11
View File
@@ -0,0 +1,11 @@
node_modules
/build
/public/build
.env
/cypress/screenshots
/cypress/videos
/postgres-data
/app/styles/tailwind.css
+64
View File
@@ -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"]
+10
View File
@@ -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
```
@@ -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 (
<CopyText className={`${className}`} value={value} onCopied={onCopied}>
{copied ? (
<div className="flex items-center rounded bg-emerald-200 px-2 py-1 text-slate-600 transition hover:cursor-pointer hover:bg-emerald-200">
<p className="font-sans text-emerald-700 hover:text-emerald-700">
Copied!
</p>
</div>
) : (
<div
className={classNames(
"flex items-center rounded px-2 py-1 hover:cursor-pointer",
variantStyle[variant]
)}
>
<ClipboardIcon className="mr-[2px] h-4 w-4" />
<p className="font-sans">Copy</p>
</div>
)}
</CopyText>
);
}
@@ -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<ReactCodeMirrorProps, "onBlur"> {
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<JSONEditorProps>;
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<HTMLDivElement>(null);
const settings: Omit<UseCodeMirror, "onBlur"> = {
...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 (
<div
className={clsx("no-scrollbar overflow-y-auto", opts.className)}
ref={editor}
onBlur={() => {
if (!onBlur) return;
onBlur(editor.current?.textContent ?? "");
}}
/>
);
}
@@ -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<ReactCodeMirrorProps, "onBlur"> {
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<CodeEditorProps>;
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<HTMLDivElement>(null);
const settings: Omit<UseCodeMirror, "onBlur"> = {
...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 (
<div
className={clsx("no-scrollbar overflow-y-auto", opts.className)}
ref={editor}
onBlur={() => {
if (!onBlur) return;
onBlur(editor.current?.textContent ?? "");
}}
/>
);
}
@@ -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<Extension> {
return [
jsonLang(),
highlightSpecialChars(),
drawSelection(),
dropCursor(),
bracketMatching(),
highlightSelectionMatches(),
lineNumbers(),
];
}
export function getViewerSetup(): Array<Extension> {
return [drawSelection(), dropCursor(), bracketMatching(), lineNumbers()];
}
export function getEditorSetup(
showLineNumbers = true,
showHighlights = true
): Array<Extension> {
const options = [
drawSelection(),
dropCursor(),
bracketMatching(),
closeBrackets(),
];
if (showLineNumbers) {
options.push(lineNumbers());
}
if (showHighlights) {
options.push([
highlightActiveLineGutter(),
highlightSpecialChars(),
highlightActiveLine(),
highlightSelectionMatches(),
]);
}
return options;
}
@@ -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)];
}
@@ -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>,
HTMLButtonElement
>;
type LinkProps = Parameters<typeof Link>[0];
type AProps = React.DetailedHTMLProps<
React.AnchorHTMLAttributes<HTMLAnchorElement>,
HTMLAnchorElement
>;
export function PrimaryButton({ children, className, ...props }: ButtonProps) {
return (
<button className={classnames(primaryClasses, className)} {...props}>
{children}
</button>
);
}
export function SecondaryButton({
children,
className,
...props
}: ButtonProps) {
return (
<button className={classnames(secondaryClasses, className)} {...props}>
{children}
</button>
);
}
export function PrimaryLink({ children, className, to, ...props }: LinkProps) {
return (
<Link to={to} className={classnames(primaryClasses, className)} {...props}>
{children}
</Link>
);
}
export function SecondaryLink({
children,
className,
to,
...props
}: LinkProps) {
return (
<Link
to={to}
className={classnames(secondaryClasses, className)}
{...props}
>
{children}
</Link>
);
}
export function PrimaryA({ children, className, href, ...props }: AProps) {
return (
<a href={href} className={classnames(primaryClasses, className)} {...props}>
{children}
</a>
);
}
export function SecondaryA({ children, className, href, ...props }: AProps) {
return (
<a
href={href}
className={classnames(secondaryClasses, className)}
{...props}
>
{children}
</a>
);
}
@@ -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>,
HTMLInputElement
> & {
roundedEdges?: "roundedLeft" | "roundedRight" | "roundedFull";
};
export function Input({
children,
className,
roundedEdges = "roundedFull",
...props
}: InputProps) {
const classes = clsx(roundedStyles[roundedEdges], className);
return (
<input
{...props}
className={`flex grow border border-slate-200 py-2 pl-3 pr-1 text-slate-700 ${classes}`}
>
{children}
</input>
);
}
@@ -0,0 +1,17 @@
import classNames from "classnames";
type SelectProps = React.DetailedHTMLProps<
React.SelectHTMLAttributes<HTMLSelectElement>,
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 (
<select className={classNames(defaultClasses, className)} {...props}>
{children}
</select>
);
}
@@ -0,0 +1,28 @@
export function Spinner() {
return (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="animate-spin"
>
<rect
x="2"
y="2"
width="16"
height="16"
rx="8"
stroke="#BBF7D0"
strokeWidth="3"
/>
<path
d="M10 18C5.58172 18 2 14.4183 2 10C2 5.58172 5.58172 2 10 2"
stroke="#22C55E"
strokeWidth="3"
strokeLinecap="round"
/>
</svg>
);
}
@@ -0,0 +1,98 @@
import { Tab as HeadlessTab } from "@headlessui/react";
import classNames from "classnames";
import classnames from "classnames";
type HeadlessTabProps = Parameters<typeof HeadlessTab>[0];
type HeadlessTabListProps = Parameters<typeof HeadlessTab.List>[0];
export function ClassicList({ children, ...props }: HeadlessTabListProps) {
return (
<HeadlessTab.List className={"-mb-px flex bg-slate-50"} {...props}>
{children}
</HeadlessTab.List>
);
}
export function Classic({ children, ...props }: HeadlessTabProps) {
return (
<HeadlessTab
className={({ selected }: { selected: boolean }) =>
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}
</HeadlessTab>
);
}
export function UnderlinedList({ children, ...props }: HeadlessTabListProps) {
return (
<HeadlessTab.List
className={"-mb-px flex space-x-4 border-b border-slate-200"}
{...props}
>
{children}
</HeadlessTab.List>
);
}
export function Underlined({ children, ...props }: HeadlessTabProps) {
return (
<HeadlessTab
className={({ selected }: { selected: boolean }) =>
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}
</HeadlessTab>
);
}
export function SegmentedList({
children,
className,
...props
}: HeadlessTabListProps) {
return (
<HeadlessTab.List
className={classNames(
"flex ml-8 gap-0.5 max-w-fit bg-slate-200 rounded-md p-0.5 border border-slate-300",
className
)}
{...props}
>
{children}
</HeadlessTab.List>
);
}
export function Segmented({ children, ...props }: HeadlessTabProps) {
return (
<HeadlessTab
className={({ selected }: { selected: boolean }) =>
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}
</HeadlessTab>
);
}
@@ -0,0 +1,8 @@
export type BodyProps = {
children: React.ReactNode;
className?: string;
};
export function Body({ children, className }: BodyProps) {
return <p className={`font-sans text-base ${className}`}>{children}</p>;
}
@@ -0,0 +1,12 @@
export type BodyBoldProps = {
children: React.ReactNode;
className: string;
};
export function BodyBold({ children, className }: BodyBoldProps) {
return (
<p className={`font-sans text-base font-semibold ${className}`}>
{children}
</p>
);
}
@@ -0,0 +1,8 @@
export type ExtraLargeTitleProps = {
children: React.ReactNode;
className: string;
};
export function ExtraLargeTitle({ children, className }: ExtraLargeTitleProps) {
return <p className={`font-sans text-2xl ${className}`}>{children}</p>;
}
@@ -0,0 +1,8 @@
export type ExtraSmallBodyProps = {
children: React.ReactNode;
className?: string;
};
export function ExtraSmallBody({ children, className }: ExtraSmallBodyProps) {
return <p className={`font-sans text-xs ${className}`}>{children}</p>;
}
@@ -0,0 +1,8 @@
export type LargeTitleProps = {
children: React.ReactNode;
className?: string;
};
export function LargeTitle({ children, className }: LargeTitleProps) {
return <p className={`font-sans text-base ${className}`}>{children}</p>;
}
@@ -0,0 +1,8 @@
export type SmallBodyProps = {
children: React.ReactNode;
className: string;
};
export function SmallBody({ children, className }: SmallBodyProps) {
return <p className={`font-sans text-sm ${className}`}>{children}</p>;
}
@@ -0,0 +1,8 @@
export type SmallTitleProps = {
children: React.ReactNode;
className?: string;
};
export function SmallTitle({ children, className }: SmallTitleProps) {
return <p className={`font-sans text-lg ${className}`}>{children}</p>;
}
@@ -0,0 +1,8 @@
export type TitleProps = {
children: React.ReactNode;
className?: string;
};
export function Title({ children, className }: TitleProps) {
return <p className={`font-sans text-xl ${className}`}>{children}</p>;
}
+71
View File
@@ -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";
+22
View File
@@ -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(<RemixBrowser />, 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
),
}),
],
});
}
+41
View File
@@ -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: <This is recommended by Remix>
const markup = renderToString(
// deepcode ignore OR: <All good in the hood>
<RemixServer context={remixContext} url={request.url} />
);
responseHeaders.set("Content-Type", "text/html; charset=utf-8");
return new Response("<!DOCTYPE html>" + 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");
// });
+14
View File
@@ -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<typeof EnvironmentSchema>;
export const env = EnvironmentSchema.parse(process.env);
+13
View File
@@ -0,0 +1,13 @@
type Falsy = false | 0 | "" | null | undefined;
interface Array<T> {
/**
* 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<S extends T>(
predicate: BooleanConstructor,
thisArg?: any
): Exclude<S, Falsy>[];
}
+116
View File
@@ -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<string, unknown>;
};
type FindOrCreateUser = FindOrCreateMagicLink | FindOrCreateGithub;
type LoggedInUser = {
user: User;
isNewUser: boolean;
};
export async function findOrCreateUser(
input: FindOrCreateUser
): Promise<LoggedInUser> {
switch (input.authenticationMethod) {
case "GITHUB": {
return findOrCreateGithubUser(input);
}
case "MAGIC_LINK": {
return findOrCreateMagicLinkUser(input);
}
}
}
export async function findOrCreateMagicLinkUser(
input: FindOrCreateMagicLink
): Promise<LoggedInUser> {
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<LoggedInUser> {
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 } });
}
+122
View File
@@ -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<ReturnType<typeof getUser>>;
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<LoaderData>(
{
user: await getUser(request),
toastMessage,
posthogProjectKey,
},
{ headers: { "Set-Cookie": await commitSession(session) } }
);
};
function App() {
const { toastMessage, posthogProjectKey, user } = useLoaderData<LoaderData>();
const postHogInitialised = useRef<boolean>(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 (
<html lang="en" className="h-full">
<head>
<Meta />
<Links />
</head>
<body className="h-full overflow-hidden">
<Outlet />
<Toaster position="top-right" />
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}
export default withSentry(App);
+34
View File
@@ -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<typeof loader>;
export async function loader({ request }: LoaderArgs) {
return typedjson(
{ },
{
headers: {
"Set-Cookie": await commitSession(await clearRedirectTo(request)),
},
}
);
}
export default function AppLayout() {
return (
<div className="flex h-screen flex-col overflow-auto">
<header>
<h1>Root</h1>
</header>
<Outlet />
</div>
);
}
+24
View File
@@ -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 });
}
};
+60
View File
@@ -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 (
<div className="flex h-screen flex-col overflow-auto">
<div className="flex-shrink-0">
<Header>Dashboard</Header>
</div>
<div className="flex flex-shrink flex-grow items-center justify-between bg-slate-50">
<ul className="h-full basis-80 bg-white p-6">
<li className="mb-6">
<div className="flex items-center">
<p className="mb-2 text-xl font-semibold">Legal stuff</p>
</div>
<ul className="flex flex-col gap-2">
{pages.map((page) => (
<li
key={page.title}
className="flex rounded-md bg-slate-50 p-3 transition hover:bg-slate-200"
>
<Link
to={page.href}
className="group flex flex-grow items-center"
>
<BookOpenIcon className="mr-2 h-6 w-6 text-slate-500 transition group-hover:text-blue-500" />
<p className="text-base text-slate-700">{page.title}</p>
</Link>
</li>
))}
</ul>
</li>
</ul>
<div className="flex w-full items-center justify-center">
<div className="prose max-w-none p-8">
<Outlet />
</div>
</div>
</div>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
# Use Restrictions
_Last updated: September 22, 2022_
We recognize that however good the makers intentions, technology can amplify the ability to cause great harm. Thats why weve 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 cant 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 dont 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 dont 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 dont tolerate folks (including their bots) using API Hero products for spamming purposes. If your emails dont 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, its not allowed.
- **Cybersquatting**: We dont like username extortionists. If you purchase a API Hero product account in someone elses 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 cant 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 cant be exhaustive — its possible an offense could defy categorization, present for the first time, or illuminate a moral quandary we hadnt 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 youre 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 weve 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/)
+120
View File
@@ -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. Heres 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. Thats 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.
Well never sell your personal information to third parties, and we wont 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 doesnt 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, well 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 wont 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 youve 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 customers 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 Heros 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 Heros 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 dont plan on that, but if it happens — well 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 well 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/)
+88
View File
@@ -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 dont 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. Thats 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. Thats 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, well 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.** Well 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.** Well 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 Heros 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 Companys 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 dont 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 users 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 accounts 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 accounts 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, thats 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/)
+3
View File
@@ -0,0 +1,3 @@
export type AuthUser = {
userId: string;
};
@@ -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<string | undefined> {
const session = await getRedirectSession(request);
if (session) {
return z.string().optional().parse(session.get("redirectTo"));
}
}
@@ -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<string | undefined> {
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");
}
@@ -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;
+13
View File
@@ -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);
});
+71
View File
@@ -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<string, unknown> | 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("@");
}
+27
View File
@@ -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<Cypress.PluginConfigOptions> = {
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 };
},
},
});
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
parserOptions: {
tsconfigRootDir: __dirname,
project: "./tsconfig.json",
},
};
+16
View File
@@ -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");
});
});
+3
View File
@@ -0,0 +1,3 @@
{
"foo": "bar"
}
+35
View File
@@ -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",
*/
+15
View File
@@ -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;
}
});
+39
View File
@@ -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
}
}
+51
View File
@@ -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 = { }
+7
View File
@@ -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)
+9
View File
@@ -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());
+178
View File
@@ -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"
}
}
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
plugins: [
require("postcss-import"),
require("tailwindcss"),
require("autoprefixer"),
],
};
+16
View File
@@ -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
}
+16
View File
@@ -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();
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -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{}
@@ -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,<svg width='9px' height='6px' viewBox='0 0 9 6' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'><g id='Artboard' stroke='none' stroke-width='1' fill='none' fill-rule='evenodd' transform='translate(-636.000000, -171.000000)' fill-opacity='0.368716033'><g id='input' transform='translate(172.000000, 37.000000)' fill='%230E242F' fill-rule='nonzero'><g id='Group-9' transform='translate(323.000000, 127.000000)'><path d='M142.280245,7.23952813 C141.987305,6.92353472 141.512432,6.92361662 141.219585,7.23971106 C140.926739,7.5558055 140.926815,8.06821394 141.219755,8.38420735 L145.498801,13 L149.780245,8.38162071 C150.073185,8.0656273 150.073261,7.55321886 149.780415,7.23712442 C149.487568,6.92102998 149.012695,6.92094808 148.719755,7.23694149 L145.498801,10.7113732 L142.280245,7.23952813 Z' id='arrow'></path></g></g></g></svg>") 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;
}
+16
View File
@@ -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/**/*"];
},
};
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="@remix-run/dev" />
/// <reference types="@remix-run/node/globals" />
+111
View File
@@ -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];
}
}
}
+3
View File
@@ -0,0 +1,3 @@
set -ex
npx prisma migrate deploy --schema packages/database/prisma/schema.prisma
node ./server.js
+9
View File
@@ -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';
+32
View File
@@ -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%)`,
},
},
},
};
+4
View File
@@ -0,0 +1,4 @@
import { installGlobals } from "@remix-run/node";
import "@testing-library/jest-dom/extend-expect";
installGlobals();
+31
View File
@@ -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" }],
}
+15
View File
@@ -0,0 +1,15 @@
/// <reference types="vitest" />
/// <reference types="vite/client" />
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"],
},
});
@@ -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",
},
};
@@ -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"
}
}
@@ -0,0 +1,8 @@
module.exports = {
extends: ["turbo", "prettier"],
settings: {
react: {
version: "detect",
},
},
};
@@ -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"
}
}
@@ -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",
},
};
@@ -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"
}
}
@@ -0,0 +1,9 @@
{
"name": "@apihero/tailwind-config",
"version": "0.0.0",
"private": true,
"devDependencies": {},
"publishConfig": {
"access": "public"
}
}
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
@@ -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")],
};
+25
View File
@@ -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/" }]
}
+22
View File
@@ -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"]
}
+16
View File
@@ -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
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@apihero/tsconfig",
"version": "0.0.0",
"private": true,
"license": "MIT",
"publishConfig": {
"access": "public"
}
}
@@ -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"
}
}
+30
View File
@@ -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
+56
View File
@@ -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"
}
+5
View File
@@ -0,0 +1,5 @@
packages:
- "config-packages/*"
- "packages/*"
- "apps/**"
- "examples/*"
+127
View File
@@ -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"
]
}