From d111220acab16fe3a252c3bcd69fefc88b84ef88 Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Thu, 12 Jun 2025 00:41:00 +0530 Subject: [PATCH] changes --- .configs/tsconfig.base.json | 35 + .../components/graph/graph-visualization.tsx | 2 +- apps/webapp/app/components/ui/FormButtons.tsx | 22 + apps/webapp/app/components/ui/Icon.tsx | 55 + apps/webapp/app/components/ui/TextLink.tsx | 57 + apps/webapp/app/components/ui/pagination.tsx | 30 +- apps/webapp/app/components/ui/sidebar.tsx | 2 +- apps/webapp/app/env.server.ts | 20 + apps/webapp/app/hooks/useWorkspace.ts | 2 +- apps/webapp/app/models/user.server.ts | 58 +- apps/webapp/app/routes/auth.google.tsx | 7 +- apps/webapp/app/routes/home.tsx | 13 +- .../routes/{login.tsx => login._index.tsx} | 22 +- apps/webapp/app/routes/login.magic.tsx | 212 + apps/webapp/app/routes/magic.tsx | 23 + .../app/services/apiRateLimit.server.ts | 69 - apps/webapp/app/services/auth.server.ts | 6 + apps/webapp/app/services/email.server.ts | 95 + apps/webapp/app/services/emailAuth.server.tsx | 43 + apps/webapp/package.json | 2 + docker/Dockerfile | 74 + docker/docker-compose.yaml | 0 docker/scripts/entrypoint.sh | 18 + docker/scripts/wait-for-it.sh | 184 + .../migration.sql | 2 + packages/database/prisma/schema.prisma | 1 + packages/emails/.gitignore | 1 + packages/emails/README.md | 17 + .../emails/emails/components/BasePath.tsx | 10 + packages/emails/emails/components/Footer.tsx | 17 + packages/emails/emails/components/Image.tsx | 13 + packages/emails/emails/components/styles.ts | 113 + packages/emails/emails/invite.tsx | 49 + packages/emails/emails/magic-link.tsx | 39 + packages/emails/emails/welcome.tsx | 53 + packages/emails/package.json | 29 + packages/emails/src/index.tsx | 96 + packages/emails/src/transports/aws-ses.ts | 67 + packages/emails/src/transports/index.ts | 52 + packages/emails/src/transports/null.ts | 29 + packages/emails/src/transports/resend.ts | 51 + packages/emails/src/transports/smtp.ts | 66 + packages/emails/tsconfig.json | 20 + pnpm-lock.yaml | 3862 ++++++++++++++++- turbo.json | 3 +- 45 files changed, 5506 insertions(+), 135 deletions(-) create mode 100644 .configs/tsconfig.base.json create mode 100644 apps/webapp/app/components/ui/FormButtons.tsx create mode 100644 apps/webapp/app/components/ui/Icon.tsx create mode 100644 apps/webapp/app/components/ui/TextLink.tsx rename apps/webapp/app/routes/{login.tsx => login._index.tsx} (77%) create mode 100644 apps/webapp/app/routes/login.magic.tsx create mode 100644 apps/webapp/app/routes/magic.tsx delete mode 100644 apps/webapp/app/services/apiRateLimit.server.ts create mode 100644 apps/webapp/app/services/email.server.ts create mode 100644 apps/webapp/app/services/emailAuth.server.tsx create mode 100644 docker/Dockerfile create mode 100644 docker/docker-compose.yaml create mode 100755 docker/scripts/entrypoint.sh create mode 100755 docker/scripts/wait-for-it.sh create mode 100644 packages/database/prisma/migrations/20250611173339_add_magic_link/migration.sql create mode 100644 packages/emails/.gitignore create mode 100644 packages/emails/README.md create mode 100644 packages/emails/emails/components/BasePath.tsx create mode 100644 packages/emails/emails/components/Footer.tsx create mode 100644 packages/emails/emails/components/Image.tsx create mode 100644 packages/emails/emails/components/styles.ts create mode 100644 packages/emails/emails/invite.tsx create mode 100644 packages/emails/emails/magic-link.tsx create mode 100644 packages/emails/emails/welcome.tsx create mode 100644 packages/emails/package.json create mode 100644 packages/emails/src/index.tsx create mode 100644 packages/emails/src/transports/aws-ses.ts create mode 100644 packages/emails/src/transports/index.ts create mode 100644 packages/emails/src/transports/null.ts create mode 100644 packages/emails/src/transports/resend.ts create mode 100644 packages/emails/src/transports/smtp.ts create mode 100644 packages/emails/tsconfig.json diff --git a/.configs/tsconfig.base.json b/.configs/tsconfig.base.json new file mode 100644 index 0000000..17ba5e4 --- /dev/null +++ b/.configs/tsconfig.base.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + "target": "es2022", + "lib": ["ES2022", "DOM", "DOM.Iterable", "DOM.AsyncIterable"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "moduleDetection": "force", + "verbatimModuleSyntax": false, + "jsx": "react", + + "strict": true, + "alwaysStrict": true, + "strictPropertyInitialization": false, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + + "noFallthroughCasesInSwitch": true, + "resolveJsonModule": true, + + "removeComments": false, + "esModuleInterop": true, + "emitDecoratorMetadata": false, + "experimentalDecorators": false, + "downlevelIteration": true, + "isolatedModules": true, + "noUncheckedIndexedAccess": true, + + "pretty": true + } +} diff --git a/apps/webapp/app/components/graph/graph-visualization.tsx b/apps/webapp/app/components/graph/graph-visualization.tsx index f25a5d0..e77e7c8 100644 --- a/apps/webapp/app/components/graph/graph-visualization.tsx +++ b/apps/webapp/app/components/graph/graph-visualization.tsx @@ -5,7 +5,7 @@ import { Graph, type GraphRef } from "./graph"; import { GraphPopovers } from "./graph-popover"; import type { RawTriplet, NodePopupContent, EdgePopupContent } from "./type"; -import { createLabelColorMap, getNodeColor } from "./node-colors"; +import { createLabelColorMap } from "./node-colors"; import { useTheme } from "remix-themes"; import { toGraphTriplets } from "./utils"; diff --git a/apps/webapp/app/components/ui/FormButtons.tsx b/apps/webapp/app/components/ui/FormButtons.tsx new file mode 100644 index 0000000..40486f7 --- /dev/null +++ b/apps/webapp/app/components/ui/FormButtons.tsx @@ -0,0 +1,22 @@ +import { cn } from "~/lib/utils"; + +export function FormButtons({ + cancelButton, + confirmButton, + className, +}: { + cancelButton?: React.ReactNode; + confirmButton: React.ReactNode; + className?: string; +}) { + return ( +
+ {cancelButton ? cancelButton :
} {confirmButton} +
+ ); +} diff --git a/apps/webapp/app/components/ui/Icon.tsx b/apps/webapp/app/components/ui/Icon.tsx new file mode 100644 index 0000000..0f0c049 --- /dev/null +++ b/apps/webapp/app/components/ui/Icon.tsx @@ -0,0 +1,55 @@ +import React, { type FunctionComponent, createElement } from "react"; +import { cn } from "~/lib/utils"; + +export type RenderIcon = + | FunctionComponent<{ className?: string }> + | React.ReactNode; + +export type IconProps = { + icon?: RenderIcon; + className?: string; +}; + +/** Use this icon to either render a passed in React component, or a NamedIcon/CompanyIcon */ +export function Icon(props: IconProps) { + if (!props.icon) return null; + + if (typeof props.icon === "function") { + const Icon = props.icon; + return ; + } + + if (React.isValidElement(props.icon)) { + return <>{props.icon}; + } + + if ( + props.icon && + typeof props.icon === "object" && + ("type" in props.icon || "$$typeof" in props.icon) + ) { + return createElement>( + props.icon as any, + { className: props.className } as any, + ); + } + + console.error("Invalid icon", props); + return null; +} + +export function IconInBox({ + boxClassName, + ...props +}: IconProps & { boxClassName?: string }) { + return ( +
+ +
+ ); +} diff --git a/apps/webapp/app/components/ui/TextLink.tsx b/apps/webapp/app/components/ui/TextLink.tsx new file mode 100644 index 0000000..8b447c5 --- /dev/null +++ b/apps/webapp/app/components/ui/TextLink.tsx @@ -0,0 +1,57 @@ +import { Link } from "@remix-run/react"; + +import { Icon, type RenderIcon } from "./Icon"; +import { cn } from "~/lib/utils"; + +const variations = { + primary: + "text-indigo-500 transition hover:text-indigo-400 inline-flex gap-0.5 items-center group focus-visible:focus-custom", + secondary: + "text-text-dimmed transition hover:text-text-bright inline-flex gap-0.5 items-center group focus-visible:focus-custom", +} as const; + +type TextLinkProps = { + href?: string; + to?: string; + className?: string; + trailingIcon?: RenderIcon; + trailingIconClassName?: string; + variant?: keyof typeof variations; + children: React.ReactNode; +} & React.AnchorHTMLAttributes; + +export function TextLink({ + href, + to, + children, + className, + trailingIcon, + trailingIconClassName, + variant = "primary", + ...props +}: TextLinkProps) { + const classes = variations[variant]; + return to ? ( + + {children}{" "} + {trailingIcon && ( + + )} + + ) : href ? ( + + {children}{" "} + {trailingIcon && ( + + )} + + ) : ( + Need to define a path or href + ); +} diff --git a/apps/webapp/app/components/ui/pagination.tsx b/apps/webapp/app/components/ui/pagination.tsx index 1f89d73..7dd760e 100644 --- a/apps/webapp/app/components/ui/pagination.tsx +++ b/apps/webapp/app/components/ui/pagination.tsx @@ -1,12 +1,12 @@ -import * as React from "react" +import * as React from "react"; import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon, -} from "lucide-react" +} from "lucide-react"; -import { cn } from "~/lib/utils" -import { Button, buttonVariants } from "~/components/ui/button" +import { cn } from "~/lib/utils"; +import { type Button, buttonVariants } from "~/components/ui/button"; function Pagination({ className, ...props }: React.ComponentProps<"nav">) { return ( @@ -17,7 +17,7 @@ function Pagination({ className, ...props }: React.ComponentProps<"nav">) { className={cn("mx-auto flex w-full justify-center", className)} {...props} /> - ) + ); } function PaginationContent({ @@ -30,17 +30,17 @@ function PaginationContent({ className={cn("flex flex-row items-center gap-1", className)} {...props} /> - ) + ); } function PaginationItem({ ...props }: React.ComponentProps<"li">) { - return
  • + return
  • ; } type PaginationLinkProps = { - isActive?: boolean + isActive?: boolean; } & Pick, "size"> & - React.ComponentProps<"a"> + React.ComponentProps<"a">; function PaginationLink({ className, @@ -58,11 +58,11 @@ function PaginationLink({ variant: isActive ? "outline" : "ghost", size, }), - className + className, )} {...props} /> - ) + ); } function PaginationPrevious({ @@ -79,7 +79,7 @@ function PaginationPrevious({ Previous - ) + ); } function PaginationNext({ @@ -96,7 +96,7 @@ function PaginationNext({ Next - ) + ); } function PaginationEllipsis({ @@ -113,7 +113,7 @@ function PaginationEllipsis({ More pages - ) + ); } export { @@ -124,4 +124,4 @@ export { PaginationPrevious, PaginationNext, PaginationEllipsis, -} +}; diff --git a/apps/webapp/app/components/ui/sidebar.tsx b/apps/webapp/app/components/ui/sidebar.tsx index d4d62eb..914fc28 100644 --- a/apps/webapp/app/components/ui/sidebar.tsx +++ b/apps/webapp/app/components/ui/sidebar.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import { Slot } from "@radix-ui/react-slot"; -import { cva, VariantProps } from "class-variance-authority"; +import { cva, type VariantProps } from "class-variance-authority"; import { PanelLeftIcon } from "lucide-react"; import { useIsMobile } from "~/hooks/use-mobile"; diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 34e839a..4c9d379 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { isValidDatabaseUrl } from "./utils/db"; +import { isValidRegex } from "./utils/regex"; const EnvironmentSchema = z.object({ NODE_ENV: z.union([ @@ -25,6 +26,15 @@ const EnvironmentSchema = z.object({ DATABASE_READ_REPLICA_URL: z.string().optional(), SESSION_SECRET: z.string(), ENCRYPTION_KEY: z.string(), + MAGIC_LINK_SECRET: z.string(), + WHITELISTED_EMAILS: z + .string() + .refine(isValidRegex, "WHITELISTED_EMAILS must be a valid regex.") + .optional(), + ADMIN_EMAILS: z + .string() + .refine(isValidRegex, "ADMIN_EMAILS must be a valid regex.") + .optional(), APP_ENV: z.string().default(process.env.NODE_ENV), LOGIN_ORIGIN: z.string().default("http://localhost:5173"), @@ -47,6 +57,16 @@ const EnvironmentSchema = z.object({ //OpenAI OPENAI_API_KEY: z.string(), + + EMAIL_TRANSPORT: z.enum(["resend", "smtp", "aws-ses"]).optional(), + FROM_EMAIL: z.string().optional(), + REPLY_TO_EMAIL: z.string().optional(), + RESEND_API_KEY: z.string().optional(), + SMTP_HOST: z.string().optional(), + SMTP_PORT: z.coerce.number().optional(), + SMTP_SECURE: z.coerce.boolean().optional(), + SMTP_USER: z.string().optional(), + SMTP_PASSWORD: z.string().optional(), }); export type Environment = z.infer; diff --git a/apps/webapp/app/hooks/useWorkspace.ts b/apps/webapp/app/hooks/useWorkspace.ts index fc63f39..b7d6c13 100644 --- a/apps/webapp/app/hooks/useWorkspace.ts +++ b/apps/webapp/app/hooks/useWorkspace.ts @@ -2,7 +2,7 @@ import { type Workspace } from "@core/database"; import { type UIMatch } from "@remix-run/react"; import { useTypedMatchesData } from "./useTypedMatchData"; -import { loader } from "~/routes/home"; +import { type loader } from "~/routes/home"; export function useOptionalWorkspace( matches?: UIMatch[], diff --git a/apps/webapp/app/models/user.server.ts b/apps/webapp/app/models/user.server.ts index ca165d7..8273fa5 100644 --- a/apps/webapp/app/models/user.server.ts +++ b/apps/webapp/app/models/user.server.ts @@ -1,8 +1,14 @@ import type { Prisma, User } from "@core/database"; import type { GoogleProfile } from "@coji/remix-auth-google"; import { prisma } from "~/db.server"; +import { env } from "~/env.server"; export type { User } from "@core/database"; +type FindOrCreateMagicLink = { + authenticationMethod: "MAGIC_LINK"; + email: string; +}; + type FindOrCreateGoogle = { authenticationMethod: "GOOGLE"; email: User["email"]; @@ -10,7 +16,7 @@ type FindOrCreateGoogle = { authenticationExtraParams: Record; }; -type FindOrCreateUser = FindOrCreateGoogle; +type FindOrCreateUser = FindOrCreateMagicLink | FindOrCreateGoogle; type LoggedInUser = { user: User; @@ -20,7 +26,55 @@ type LoggedInUser = { export async function findOrCreateUser( input: FindOrCreateUser, ): Promise { - return findOrCreateGoogleUser(input); + switch (input.authenticationMethod) { + case "GOOGLE": { + return findOrCreateGoogleUser(input); + } + case "MAGIC_LINK": { + return findOrCreateMagicLinkUser(input); + } + } +} + +export async function findOrCreateMagicLinkUser( + input: FindOrCreateMagicLink, +): Promise { + if ( + env.WHITELISTED_EMAILS && + !new RegExp(env.WHITELISTED_EMAILS).test(input.email) + ) { + throw new Error("This email is unauthorized"); + } + + const existingUser = await prisma.user.findFirst({ + where: { + email: input.email, + }, + }); + + const adminEmailRegex = env.ADMIN_EMAILS + ? new RegExp(env.ADMIN_EMAILS) + : undefined; + const makeAdmin = adminEmailRegex ? adminEmailRegex.test(input.email) : false; + + const user = await prisma.user.upsert({ + where: { + email: input.email, + }, + update: { + email: input.email, + }, + create: { + email: input.email, + authenticationMethod: "MAGIC_LINK", + admin: makeAdmin, // only on create, to prevent automatically removing existing admins + }, + }); + + return { + user, + isNewUser: !existingUser, + }; } export async function findOrCreateGoogleUser({ diff --git a/apps/webapp/app/routes/auth.google.tsx b/apps/webapp/app/routes/auth.google.tsx index 86d9335..974cb59 100644 --- a/apps/webapp/app/routes/auth.google.tsx +++ b/apps/webapp/app/routes/auth.google.tsx @@ -1,9 +1,4 @@ -import { - redirect, - createCookie, - type ActionFunction, - type LoaderFunction, -} from "@remix-run/node"; +import { createCookie, type LoaderFunction } from "@remix-run/node"; import { authenticator } from "~/services/auth.server"; export let loader: LoaderFunction = async ({ request }) => { diff --git a/apps/webapp/app/routes/home.tsx b/apps/webapp/app/routes/home.tsx index 4a45257..b5f9509 100644 --- a/apps/webapp/app/routes/home.tsx +++ b/apps/webapp/app/routes/home.tsx @@ -1,14 +1,7 @@ -import { - type ActionFunctionArgs, - type LoaderFunctionArgs, -} from "@remix-run/server-runtime"; -import { - requireUser, - requireUserId, - requireWorkpace, -} from "~/services/session.server"; +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { requireUser, requireWorkpace } from "~/services/session.server"; -import { Outlet, useActionData } from "@remix-run/react"; +import { Outlet } from "@remix-run/react"; import { typedjson } from "remix-typedjson"; import { clearRedirectTo, commitSession } from "~/services/redirectTo.server"; diff --git a/apps/webapp/app/routes/login.tsx b/apps/webapp/app/routes/login._index.tsx similarity index 77% rename from apps/webapp/app/routes/login.tsx rename to apps/webapp/app/routes/login._index.tsx index a622d57..7a10b64 100644 --- a/apps/webapp/app/routes/login.tsx +++ b/apps/webapp/app/routes/login._index.tsx @@ -8,6 +8,7 @@ import { setRedirectTo } from "~/services/redirectTo.server"; import { getUserId } from "~/services/session.server"; import { commitSession } from "~/services/sessionStorage.server"; import { requestUrl } from "~/utils/requestUrl.server"; +import { env } from "~/env.server"; import { RiGoogleLine } from "@remixicon/react"; import { @@ -18,6 +19,7 @@ import { CardTitle, } from "~/components/ui/card"; import { Button } from "~/components/ui"; +import { Mail } from "lucide-react"; export async function loader({ request }: LoaderFunctionArgs) { const userId = await getUserId(request); @@ -30,7 +32,11 @@ export async function loader({ request }: LoaderFunctionArgs) { const session = await setRedirectTo(request, redirectTo); return typedjson( - { redirectTo, showGoogleAuth: isGoogleAuthSupported }, + { + redirectTo, + showGoogleAuth: isGoogleAuthSupported, + isDevelopment: env.NODE_ENV === "development", + }, { headers: { "Set-Cookie": await commitSession(session), @@ -41,6 +47,7 @@ export async function loader({ request }: LoaderFunctionArgs) { return typedjson({ redirectTo: null, showGoogleAuth: isGoogleAuthSupported, + isDevelopment: env.NODE_ENV === "development", }); } } @@ -72,6 +79,19 @@ export default function LoginPage() { Continue with Google )} + + {data.isDevelopment && ( + + )}
  • diff --git a/apps/webapp/app/routes/login.magic.tsx b/apps/webapp/app/routes/login.magic.tsx new file mode 100644 index 0000000..634e2eb --- /dev/null +++ b/apps/webapp/app/routes/login.magic.tsx @@ -0,0 +1,212 @@ +import { + redirect, + type ActionFunctionArgs, + type LoaderFunctionArgs, + type MetaFunction, +} from "@remix-run/node"; + +import { Form, useNavigation } from "@remix-run/react"; +import { Inbox, Loader, Mail } from "lucide-react"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { LoginPageLayout } from "~/components/layout/LoginPageLayout"; +import { Button } from "~/components/ui"; +import { Fieldset } from "~/components/ui/Fieldset"; +import { FormButtons } from "~/components/ui/FormButtons"; +import { Header1 } from "~/components/ui/Headers"; +import { Input } from "~/components/ui/input"; +import { Paragraph } from "~/components/ui/Paragraph"; +import { TextLink } from "~/components/ui/TextLink"; + +import { authenticator } from "~/services/auth.server"; +import { getUserId } from "~/services/session.server"; +import { + commitSession, + getUserSession, +} from "~/services/sessionStorage.server"; +import { env } from "~/env.server"; + +export const meta: MetaFunction = ({ matches }) => { + const parentMeta = matches + .flatMap((match) => match.meta ?? []) + .filter((meta) => { + if ("title" in meta) return false; + if ("name" in meta && meta.name === "viewport") return false; + return true; + }); + + return [ + ...parentMeta, + { title: `Login to C.O.R.E.` }, + { + name: "viewport", + content: "width=device-width,initial-scale=1", + }, + ]; +}; + +export async function loader({ request }: LoaderFunctionArgs): Promise { + if (env.NODE_ENV !== "development") { + return typedjson({ isDevelopment: false }); + } + + const userId = await getUserId(request); + if (userId) return redirect("/"); + + const session = await getUserSession(request); + const error = session.get("auth:error"); + + let magicLinkError: string | undefined | unknown; + if (error) { + if ("message" in error) { + magicLinkError = error.message; + } else { + magicLinkError = JSON.stringify(error, null, 2); + } + } + + return typedjson( + { + isDevelopment: true, + magicLinkSent: session.has("core:magiclink"), + magicLinkError, + }, + { + headers: { "Set-Cookie": await commitSession(session) }, + }, + ); +} + +export async function action({ request }: ActionFunctionArgs) { + if (env.NODE_ENV !== "development") { + throw new Error("Magic link login is only available in development mode"); + } + + const clonedRequest = request.clone(); + + const payload = Object.fromEntries(await clonedRequest.formData()); + + const { action } = z + .object({ + action: z.enum(["send", "reset"]), + }) + .parse(payload); + + if (action === "send") { + return await authenticator.authenticate("email-link", request); + } else { + const session = await getUserSession(request); + session.unset("core:magiclink"); + + return redirect("/magic", { + headers: { + "Set-Cookie": await commitSession(session), + }, + }); + } +} + +export default function LoginMagicLinkPage() { + const data = useTypedLoaderData(); + const navigate = useNavigation(); + + if (!data.isDevelopment) { + return ( + + + Magic link login is only available in development mode. + + + ); + } + + const isLoading = + (navigate.state === "loading" || navigate.state === "submitting") && + navigate.formAction !== undefined && + navigate.formData?.get("action") === "send"; + + return ( + +
    +
    + {data.magicLinkSent ? ( + <> + + We've sent you a magic link! + +
    + + + We sent you an email which contains a magic link that will log + you in to your account. + + + Re-enter email + + } + confirmButton={ + + } + /> +
    + + ) : ( + <> + + Welcome + + + Create an account or login using email + +
    + + + + {data.magicLinkError && <>{data.magicLinkError}} +
    + + )} +
    +
    +
    + ); +} diff --git a/apps/webapp/app/routes/magic.tsx b/apps/webapp/app/routes/magic.tsx new file mode 100644 index 0000000..5d71fd9 --- /dev/null +++ b/apps/webapp/app/routes/magic.tsx @@ -0,0 +1,23 @@ +import { redirect } from "@remix-run/node"; +import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { authenticator } from "~/services/auth.server"; +import { logger } from "~/services/logger.service"; +import { saveSession } from "~/services/sessionStorage.server"; +import { redirectCookie } from "./auth.google"; + +export async function loader({ request }: LoaderFunctionArgs) { + const cookie = request.headers.get("Cookie"); + const redirectValue = await redirectCookie.parse(cookie); + const authuser = await authenticator.authenticate("email-link", request); + const redirectTo = redirectValue ?? "/"; + + const headers = await saveSession(request, authuser); + + logger.debug("auth.google.callback authuser", { + authuser, + }); + + return redirect(redirectTo, { + headers, + }); +} diff --git a/apps/webapp/app/services/apiRateLimit.server.ts b/apps/webapp/app/services/apiRateLimit.server.ts deleted file mode 100644 index d5d1085..0000000 --- a/apps/webapp/app/services/apiRateLimit.server.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { env } from "~/env.server"; -import { authenticateAuthorizationHeader } from "./apiAuth.server"; -import { authorizationRateLimitMiddleware } from "./authorizationRateLimitMiddleware.server"; -import { Duration } from "./rateLimiter.server"; - -export const apiRateLimiter = authorizationRateLimitMiddleware({ - redis: { - port: env.RATE_LIMIT_REDIS_PORT, - host: env.RATE_LIMIT_REDIS_HOST, - username: env.RATE_LIMIT_REDIS_USERNAME, - password: env.RATE_LIMIT_REDIS_PASSWORD, - tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true", - clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1", - }, - keyPrefix: "api", - defaultLimiter: { - type: "tokenBucket", - refillRate: env.API_RATE_LIMIT_REFILL_RATE, - interval: env.API_RATE_LIMIT_REFILL_INTERVAL as Duration, - maxTokens: env.API_RATE_LIMIT_MAX, - }, - limiterCache: { - fresh: 60_000 * 10, // Data is fresh for 10 minutes - stale: 60_000 * 20, // Date is stale after 20 minutes - }, - limiterConfigOverride: async (authorizationValue) => { - const authenticatedEnv = await authenticateAuthorizationHeader( - authorizationValue, - { - allowPublicKey: true, - allowJWT: true, - }, - ); - - if (!authenticatedEnv || !authenticatedEnv.ok) { - return; - } - - if (authenticatedEnv.type === "PUBLIC_JWT") { - return { - type: "fixedWindow", - window: env.API_RATE_LIMIT_JWT_WINDOW, - tokens: env.API_RATE_LIMIT_JWT_TOKENS, - }; - } else { - return authenticatedEnv.environment.organization.apiRateLimiterConfig; - } - }, - pathMatchers: [/^\/api/], - // Allow /api/v1/tasks/:id/callback/:secret - pathWhiteList: [ - "/api/internal/stripe_webhooks", - "/api/v1/authorization-code", - "/api/v1/token", - "/api/v1/usage/ingest", - "/api/v1/timezones", - "/api/v1/usage/ingest", - "/api/v1/auth/jwt/claims", - ], - log: { - rejections: env.API_RATE_LIMIT_REJECTION_LOGS_ENABLED === "1", - requests: env.API_RATE_LIMIT_REQUEST_LOGS_ENABLED === "1", - limiter: env.API_RATE_LIMIT_LIMITER_LOGS_ENABLED === "1", - }, -}); - -export type RateLimitMiddleware = ReturnType< - typeof authorizationRateLimitMiddleware ->; diff --git a/apps/webapp/app/services/auth.server.ts b/apps/webapp/app/services/auth.server.ts index 1823ef8..38b9967 100644 --- a/apps/webapp/app/services/auth.server.ts +++ b/apps/webapp/app/services/auth.server.ts @@ -1,9 +1,11 @@ import { Authenticator } from "remix-auth"; + import type { AuthUser } from "./authUser"; import { addGoogleStrategy } from "./googleAuth.server"; import { env } from "~/env.server"; +import { addEmailLinkStrategy } from "./emailAuth.server"; // Create an instance of the authenticator, pass a generic with what // strategies will return and will store in the session @@ -21,4 +23,8 @@ if (env.AUTH_GOOGLE_CLIENT_ID && env.AUTH_GOOGLE_CLIENT_SECRET) { ); } +if (env.NODE_ENV === "development") { + addEmailLinkStrategy(authenticator); +} + export { authenticator, isGoogleAuthSupported }; diff --git a/apps/webapp/app/services/email.server.ts b/apps/webapp/app/services/email.server.ts new file mode 100644 index 0000000..5791010 --- /dev/null +++ b/apps/webapp/app/services/email.server.ts @@ -0,0 +1,95 @@ +import { + type DeliverEmail, + type SendPlainTextOptions, + EmailClient, + type MailTransportOptions, +} from "emails"; + +import { redirect } from "remix-typedjson"; +import { env } from "~/env.server"; +import type { AuthUser } from "./authUser"; + +import { logger } from "./logger.service"; +import { singleton } from "~/utils/singleton"; + +const client = singleton( + "email-client", + () => + new EmailClient({ + transport: buildTransportOptions(), + imagesBaseUrl: env.APP_ORIGIN, + from: env.FROM_EMAIL ?? "team@core.heysol.ai", + replyTo: env.REPLY_TO_EMAIL ?? "help@core.heysol.ai", + }), +); + +function buildTransportOptions(): MailTransportOptions { + const transportType = env.EMAIL_TRANSPORT; + logger.debug( + `Constructing email transport '${transportType}' for usage general`, + ); + + switch (transportType) { + case "aws-ses": + return { type: "aws-ses" }; + case "resend": + return { + type: "resend", + config: { + apiKey: env.RESEND_API_KEY, + }, + }; + case "smtp": + return { + type: "smtp", + config: { + host: env.SMTP_HOST, + port: env.SMTP_PORT, + secure: env.SMTP_SECURE, + auth: { + user: env.SMTP_USER, + pass: env.SMTP_PASSWORD, + }, + }, + }; + default: + return { type: undefined }; + } +} + +export async function sendMagicLinkEmail(options: any): Promise { + // Auto redirect when in development mode + if (env.NODE_ENV === "development") { + throw redirect(options.magicLink); + } + + logger.debug("Sending magic link email", { + emailAddress: options.emailAddress, + }); + + try { + return await client.send({ + email: "magic_link", + to: options.emailAddress, + magicLink: options.magicLink, + }); + } catch (error) { + logger.error("Error sending magic link email", { + error: JSON.stringify(error), + }); + throw error; + } +} + +export async function sendPlainTextEmail(options: SendPlainTextOptions) { + return client.sendPlainText(options); +} + +export async function scheduleEmail( + data: DeliverEmail, + delay?: { seconds: number }, +) {} + +export async function sendEmail(data: DeliverEmail) { + return client.send(data); +} diff --git a/apps/webapp/app/services/emailAuth.server.tsx b/apps/webapp/app/services/emailAuth.server.tsx new file mode 100644 index 0000000..7a3a305 --- /dev/null +++ b/apps/webapp/app/services/emailAuth.server.tsx @@ -0,0 +1,43 @@ +import { EmailLinkStrategy } from "@nichtsam/remix-auth-email-link"; +import type { Authenticator } from "remix-auth"; +import type { AuthUser } from "./authUser"; +import { findOrCreateUser } from "~/models/user.server"; +import { env } from "~/env.server"; +import { sendMagicLinkEmail } from "~/services/email.server"; +import { postAuthentication } from "./postAuth.server"; +import { logger } from "./logger.service"; + +let secret = env.MAGIC_LINK_SECRET; +let APP_ORIGIN = env.APP_ORIGIN; +if (!secret) throw new Error("Missing MAGIC_LINK_SECRET env variable."); + +const emailStrategy = new EmailLinkStrategy( + { + sendEmail: sendMagicLinkEmail, + secret, + magicEndpoint: `${APP_ORIGIN}/magic`, + }, + async ({ email }: { email: string }) => { + logger.info("Magic link user authenticated", { email }); + + try { + const { user, isNewUser } = await findOrCreateUser({ + email, + authenticationMethod: "MAGIC_LINK", + }); + + await postAuthentication({ user, isNewUser, loginMethod: "MAGIC_LINK" }); + + return { userId: user.id }; + } catch (error) { + logger.debug("Magic link user failed to authenticate", { + error: JSON.stringify(error), + }); + throw error; + } + }, +); + +export function addEmailLinkStrategy(authenticator: Authenticator) { + authenticator.use(emailStrategy as any, "email-link"); +} diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 08430ec..1035765 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -57,6 +57,7 @@ "dayjs": "^1.11.10", "date-fns": "^4.1.0", "express": "^4.18.1", + "emails": "workspace:*", "ioredis": "^5.6.1", "isbot": "^4.1.0", "jose": "^5.2.3", @@ -69,6 +70,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "remix-auth": "^4.2.0", + "@nichtsam/remix-auth-email-link": "3.0.0", "remix-auth-oauth2": "^3.4.1", "remix-themes": "^1.3.1", "remix-typedjson": "0.3.1", diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..3d1a6f6 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,74 @@ +ARG NODE_IMAGE=node:20.11.1-bullseye-slim@sha256:5a5a92b3a8d392691c983719dbdc65d9f30085d6dcd65376e7a32e6fe9bf4cbe + +FROM ${NODE_IMAGE} AS pruner + +WORKDIR /core + +COPY --chown=node:node . . +RUN npx -q turbo@1.10.9 prune --scope=webapp --docker +RUN find . -name "node_modules" -type d -prune -exec rm -rf '{}' + + +# Base strategy to have layer caching +FROM ${NODE_IMAGE} AS base +RUN apt-get update && apt-get install -y openssl dumb-init +WORKDIR /core +COPY --chown=node:node .gitignore .gitignore +COPY --from=pruner --chown=node:node /core/out/json/ . +COPY --from=pruner --chown=node:node /core/out/pnpm-lock.yaml ./pnpm-lock.yaml +COPY --from=pruner --chown=node:node /core/out/pnpm-workspace.yaml ./pnpm-workspace.yaml + +## Dev deps +FROM base AS dev-deps +WORKDIR /core +# Corepack is used to install pnpm +RUN corepack enable +ENV NODE_ENV development +RUN pnpm install --ignore-scripts --no-frozen-lockfile + +## Production deps +FROM base AS production-deps +WORKDIR /core +# Corepack is used to install pnpm +RUN corepack enable +ENV NODE_ENV production +RUN pnpm install --prod --no-frozen-lockfile +COPY --from=pruner --chown=node:node /core/packages/database/prisma/schema.prisma /core/packages/database/prisma/schema.prisma +# RUN pnpm add @prisma/client@5.1.1 -w +ENV NPM_CONFIG_IGNORE_WORKSPACE_ROOT_CHECK true +RUN pnpx prisma@5.4.1 generate --schema /core/packages/database/prisma/schema.prisma + +## Builder (builds the webapp) +FROM base AS builder +WORKDIR /core +# Corepack is used to install pnpm +RUN corepack enable + +COPY --from=pruner --chown=node:node /core/out/full/ . +COPY --from=dev-deps --chown=node:node /core/ . +COPY --chown=node:node turbo.json turbo.json +COPY --chown=node:node docker/scripts ./scripts +RUN chmod +x ./scripts/wait-for-it.sh +RUN chmod +x ./scripts/entrypoint.sh +COPY --chown=node:node .configs/tsconfig.base.json .configs/tsconfig.base.json +RUN pnpm run generate +RUN pnpm run build --filter=webapp... + +# Runner +FROM ${NODE_IMAGE} AS runner +RUN apt-get update && apt-get install -y openssl netcat-openbsd ca-certificates +WORKDIR /core +RUN corepack enable +ENV NODE_ENV production + +COPY --from=base /usr/bin/dumb-init /usr/bin/dumb-init +COPY --from=pruner --chown=node:node /core/out/full/ . +COPY --from=production-deps --chown=node:node /core . +COPY --from=builder --chown=node:node /core/apps/webapp/build/server.js ./apps/webapp/build/server.js +COPY --from=builder --chown=node:node /core/apps/webapp/build ./apps/webapp/build +COPY --from=builder --chown=node:node /core/apps/webapp/public ./apps/webapp/public +COPY --from=builder --chown=node:node /core/scripts ./scripts + +EXPOSE 3000 + +USER node +CMD ["./scripts/entrypoint.sh"] diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml new file mode 100644 index 0000000..e69de29 diff --git a/docker/scripts/entrypoint.sh b/docker/scripts/entrypoint.sh new file mode 100755 index 0000000..c972f33 --- /dev/null +++ b/docker/scripts/entrypoint.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -xe + +if [ -n "$DATABASE_HOST" ]; then + scripts/wait-for-it.sh ${DATABASE_HOST} -- echo "database is up" +fi + +# Run migrations +pnpm --filter @core/database db:migrate:deploy + +# Copy over required prisma files +cp packages/database/prisma/schema.prisma apps/webapp/prisma/ +cp node_modules/@prisma/engines/*.node apps/webapp/prisma/ + +cd /core/apps/webapp +# exec dumb-init pnpm run start:local +NODE_PATH='/core/node_modules/.pnpm/node_modules' exec dumb-init node --max-old-space-size=8192 ./build/server.js + diff --git a/docker/scripts/wait-for-it.sh b/docker/scripts/wait-for-it.sh new file mode 100755 index 0000000..1ebf3b0 --- /dev/null +++ b/docker/scripts/wait-for-it.sh @@ -0,0 +1,184 @@ +#!/bin/sh + +# The MIT License (MIT) +# +# Copyright (c) 2017 Eficode Oy +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +set -- "$@" -- "$TIMEOUT" "$QUIET" "$PROTOCOL" "$HOST" "$PORT" "$result" +TIMEOUT=15 +QUIET=0 +# The protocol to make the request with, either "tcp" or "http" +PROTOCOL="tcp" + +echoerr() { + if [ "$QUIET" -ne 1 ]; then printf "%s\n" "$*" 1>&2; fi +} + +usage() { + exitcode="$1" + cat << USAGE >&2 +Usage: + $0 host:port|url [-t timeout] [-- command args] + -q | --quiet Do not output any status messages + -t TIMEOUT | --timeout=timeout Timeout in seconds, zero for no timeout + -- COMMAND ARGS Execute command with args after the test finishes +USAGE + exit "$exitcode" +} + +wait_for() { + case "$PROTOCOL" in + tcp) + if ! command -v nc >/dev/null; then + echoerr 'nc command is missing!' + exit 1 + fi + ;; + wget) + if ! command -v wget >/dev/null; then + echoerr 'wget command is missing!' + exit 1 + fi + ;; + esac + + while :; do + case "$PROTOCOL" in + tcp) + nc -w 1 -z "$HOST" "$PORT" > /dev/null 2>&1 + ;; + http) + wget --timeout=1 -q "$HOST" -O /dev/null > /dev/null 2>&1 + ;; + *) + echoerr "Unknown protocol '$PROTOCOL'" + exit 1 + ;; + esac + + result=$? + + if [ $result -eq 0 ] ; then + if [ $# -gt 7 ] ; then + for result in $(seq $(($# - 7))); do + result=$1 + shift + set -- "$@" "$result" + done + + TIMEOUT=$2 QUIET=$3 PROTOCOL=$4 HOST=$5 PORT=$6 result=$7 + shift 7 + exec "$@" + fi + exit 0 + fi + + if [ "$TIMEOUT" -le 0 ]; then + break + fi + TIMEOUT=$((TIMEOUT - 1)) + + sleep 1 + done + echo "Operation timed out" >&2 + exit 1 +} + +while :; do + case "$1" in + http://*|https://*) + HOST="$1" + PROTOCOL="http" + shift 1 + ;; + *:* ) + HOST=$(printf "%s\n" "$1"| cut -d : -f 1) + PORT=$(printf "%s\n" "$1"| cut -d : -f 2) + shift 1 + ;; + -q | --quiet) + QUIET=1 + shift 1 + ;; + -q-*) + QUIET=0 + echoerr "Unknown option: $1" + usage 1 + ;; + -q*) + QUIET=1 + result=$1 + shift 1 + set -- -"${result#-q}" "$@" + ;; + -t | --timeout) + TIMEOUT="$2" + shift 2 + ;; + -t*) + TIMEOUT="${1#-t}" + shift 1 + ;; + --timeout=*) + TIMEOUT="${1#*=}" + shift 1 + ;; + --) + shift + break + ;; + --help) + usage 0 + ;; + -*) + QUIET=0 + echoerr "Unknown option: $1" + usage 1 + ;; + *) + QUIET=0 + echoerr "Unknown argument: $1" + usage 1 + ;; + esac +done + +if ! [ "$TIMEOUT" -ge 0 ] 2>/dev/null; then + echoerr "Error: invalid timeout '$TIMEOUT'" + usage 3 +fi + +case "$PROTOCOL" in + tcp) + if [ "$HOST" = "" ] || [ "$PORT" = "" ]; then + echoerr "Error: you need to provide a host and port to test." + usage 2 + fi + ;; + http) + if [ "$HOST" = "" ]; then + echoerr "Error: you need to provide a host to test." + usage 2 + fi + ;; +esac + +wait_for "$@" \ No newline at end of file diff --git a/packages/database/prisma/migrations/20250611173339_add_magic_link/migration.sql b/packages/database/prisma/migrations/20250611173339_add_magic_link/migration.sql new file mode 100644 index 0000000..1e48db7 --- /dev/null +++ b/packages/database/prisma/migrations/20250611173339_add_magic_link/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "AuthenticationMethod" ADD VALUE 'MAGIC_LINK'; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 386d51a..7035de3 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -61,6 +61,7 @@ model Workspace { enum AuthenticationMethod { GOOGLE + MAGIC_LINK } /// Used to generate PersonalAccessTokens, they're one-time use diff --git a/packages/emails/.gitignore b/packages/emails/.gitignore new file mode 100644 index 0000000..9e9e879 --- /dev/null +++ b/packages/emails/.gitignore @@ -0,0 +1 @@ +.react-email \ No newline at end of file diff --git a/packages/emails/README.md b/packages/emails/README.md new file mode 100644 index 0000000..d0ef603 --- /dev/null +++ b/packages/emails/README.md @@ -0,0 +1,17 @@ +# Emails + +## Getting Started + +1. First, install the dependencies: + +```sh +pnpm install --filter emails +``` + +2. Then, run the development server: + +```sh +pnpm run dev --filter emails +``` + +Open [localhost:3080](http://localhost:3080) with your browser to see the result. diff --git a/packages/emails/emails/components/BasePath.tsx b/packages/emails/emails/components/BasePath.tsx new file mode 100644 index 0000000..ef9852c --- /dev/null +++ b/packages/emails/emails/components/BasePath.tsx @@ -0,0 +1,10 @@ +// Use a global variable to store the base path +let globalBasePath: string = "http://localhost:3000"; + +export function setGlobalBasePath(basePath: string) { + globalBasePath = basePath; +} + +export function getGlobalBasePath() { + return globalBasePath; +} diff --git a/packages/emails/emails/components/Footer.tsx b/packages/emails/emails/components/Footer.tsx new file mode 100644 index 0000000..b2e02d4 --- /dev/null +++ b/packages/emails/emails/components/Footer.tsx @@ -0,0 +1,17 @@ +import { Hr, Link, Text } from "@react-email/components"; +import React from "react"; +import { footer, footerAnchor, hr } from "./styles"; + +export function Footer() { + return ( + <> +
    + + ©Sol.ai + + C.O.R.E + + + + ); +} diff --git a/packages/emails/emails/components/Image.tsx b/packages/emails/emails/components/Image.tsx new file mode 100644 index 0000000..3009f58 --- /dev/null +++ b/packages/emails/emails/components/Image.tsx @@ -0,0 +1,13 @@ +import { Img } from "@react-email/components"; +import * as React from "react"; +import { getGlobalBasePath } from "./BasePath"; + +type ImageProps = Omit[0], "src"> & { + path: string; +}; + +export function Image({ path, ...props }: ImageProps) { + const basePath = getGlobalBasePath(); + + return ; +} diff --git a/packages/emails/emails/components/styles.ts b/packages/emails/emails/components/styles.ts new file mode 100644 index 0000000..6c07873 --- /dev/null +++ b/packages/emails/emails/components/styles.ts @@ -0,0 +1,113 @@ +export const h1 = { + color: "#D7D9DD", + fontFamily: + "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", + fontSize: "24px", + fontWeight: "bold", + margin: "40px 0", + padding: "0", +}; + +export const main = { + backgroundColor: "#15171A", + padding: "0 20px", +}; + +export const container = { + backgroundColor: "#15171A", + margin: "0 auto", + padding: "20px 0 48px", + marginBottom: "64px", +}; + +export const box = { + padding: "0 48px", +}; + +export const hr = { + borderColor: "#272A2E", + margin: "20px 0", +}; + +export const paragraph = { + color: "#878C99", + fontFamily: + '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif', + fontSize: "16px", + lineHeight: "24px", + textAlign: "left" as const, +}; + +export const paragraphLight = { + color: "#D7D9DD", + fontFamily: + '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif', + fontSize: "16px", + lineHeight: "24px", + textAlign: "left" as const, +}; + +export const paragraphTight = { + color: "#D7D9DD", + fontFamily: + '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif', + fontSize: "16px", + lineHeight: "16px", + textAlign: "left" as const, +}; + +export const bullets = { + color: "#D7D9DD", + fontFamily: + '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif', + fontSize: "16px", + lineHeight: "24px", + textAlign: "left" as const, + margin: "0", +}; + +export const anchor = { + color: "#826DFF", + fontFamily: + "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", + fontSize: "16px", + textDecoration: "underline", +}; + +export const button = { + backgroundColor: "#826DFF", + borderRadius: "5px", + color: "#D7D9DD", + fontFamily: + '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif', + fontSize: "16px", + fontWeight: "bold", + textDecoration: "none", + textAlign: "center" as const, + display: "block", +}; + +export const footer = { + color: "#878C99", + fontFamily: + '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif', + fontSize: "12px", + lineHeight: "16px", +}; + +export const footerItalic = { + color: "#878C99", + fontStyle: "italic", + fontFamily: + '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif', + fontSize: "12px", + lineHeight: "16px", +}; + +export const footerAnchor = { + color: "#878C99", + fontFamily: + "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", + fontSize: "12px", + textDecoration: "underline", +}; diff --git a/packages/emails/emails/invite.tsx b/packages/emails/emails/invite.tsx new file mode 100644 index 0000000..3b30f9f --- /dev/null +++ b/packages/emails/emails/invite.tsx @@ -0,0 +1,49 @@ +import { Body, Container, Head, Html, Link, Preview, Text } from "@react-email/components"; +import { z } from "zod"; +import { Footer } from "./components/Footer"; +import { Image } from "./components/Image"; +import { anchor, container, h1, main, paragraphLight } from "./components/styles"; + +export const InviteEmailSchema = z.object({ + email: z.literal("invite"), + orgName: z.string(), + inviterName: z.string().optional(), + inviterEmail: z.string(), + inviteLink: z.string().url(), +}); + +export default function Email({ + orgName, + inviterName, + inviterEmail, + inviteLink, +}: z.infer) { + return ( + + + {`You've been invited to ${orgName}`} + + + {`You've been invited to ${orgName}`} + + {inviterName ?? inviterEmail} has invited you to join their organization on Sol.ai. + + + Click here to view the invitation + + + Sol.ai +