mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-09 22:48:39 +00:00
changes
This commit is contained in:
parent
a9034fb448
commit
d111220aca
35
.configs/tsconfig.base.json
Normal file
35
.configs/tsconfig.base.json
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
|
||||
22
apps/webapp/app/components/ui/FormButtons.tsx
Normal file
22
apps/webapp/app/components/ui/FormButtons.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
export function FormButtons({
|
||||
cancelButton,
|
||||
confirmButton,
|
||||
className,
|
||||
}: {
|
||||
cancelButton?: React.ReactNode;
|
||||
confirmButton: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-grid-bright flex w-full items-center justify-between border-t pt-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{cancelButton ? cancelButton : <div />} {confirmButton}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
apps/webapp/app/components/ui/Icon.tsx
Normal file
55
apps/webapp/app/components/ui/Icon.tsx
Normal file
@ -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 <Icon className={props.className} />;
|
||||
}
|
||||
|
||||
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<FunctionComponent<any>>(
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
"border-charcoal-750 bg-charcoal-850 grid h-9 w-9 place-content-center rounded-sm border",
|
||||
boxClassName,
|
||||
)}
|
||||
>
|
||||
<Icon icon={props.icon} className={cn("h-6 w-6", props.className)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
apps/webapp/app/components/ui/TextLink.tsx
Normal file
57
apps/webapp/app/components/ui/TextLink.tsx
Normal file
@ -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<HTMLAnchorElement>;
|
||||
|
||||
export function TextLink({
|
||||
href,
|
||||
to,
|
||||
children,
|
||||
className,
|
||||
trailingIcon,
|
||||
trailingIconClassName,
|
||||
variant = "primary",
|
||||
...props
|
||||
}: TextLinkProps) {
|
||||
const classes = variations[variant];
|
||||
return to ? (
|
||||
<Link to={to} className={cn(classes, className)} {...props}>
|
||||
{children}{" "}
|
||||
{trailingIcon && (
|
||||
<Icon
|
||||
icon={trailingIcon}
|
||||
className={cn("size-4", trailingIconClassName)}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
) : href ? (
|
||||
<a href={href} className={cn(classes, className)} {...props}>
|
||||
{children}{" "}
|
||||
{trailingIcon && (
|
||||
<Icon
|
||||
icon={trailingIcon}
|
||||
className={cn("size-4", trailingIconClassName)}
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
) : (
|
||||
<span>Need to define a path or href</span>
|
||||
);
|
||||
}
|
||||
@ -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 <li data-slot="pagination-item" {...props} />
|
||||
return <li data-slot="pagination-item" {...props} />;
|
||||
}
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
isActive?: boolean;
|
||||
} & Pick<React.ComponentProps<typeof Button>, "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({
|
||||
<ChevronLeftIcon />
|
||||
<span className="hidden sm:block">Previous</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationNext({
|
||||
@ -96,7 +96,7 @@ function PaginationNext({
|
||||
<span className="hidden sm:block">Next</span>
|
||||
<ChevronRightIcon />
|
||||
</PaginationLink>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationEllipsis({
|
||||
@ -113,7 +113,7 @@ function PaginationEllipsis({
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@ -124,4 +124,4 @@ export {
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationEllipsis,
|
||||
}
|
||||
};
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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<typeof EnvironmentSchema>;
|
||||
|
||||
@ -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[],
|
||||
|
||||
@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
type FindOrCreateUser = FindOrCreateGoogle;
|
||||
type FindOrCreateUser = FindOrCreateMagicLink | FindOrCreateGoogle;
|
||||
|
||||
type LoggedInUser = {
|
||||
user: User;
|
||||
@ -20,7 +26,55 @@ type LoggedInUser = {
|
||||
export async function findOrCreateUser(
|
||||
input: FindOrCreateUser,
|
||||
): Promise<LoggedInUser> {
|
||||
return findOrCreateGoogleUser(input);
|
||||
switch (input.authenticationMethod) {
|
||||
case "GOOGLE": {
|
||||
return findOrCreateGoogleUser(input);
|
||||
}
|
||||
case "MAGIC_LINK": {
|
||||
return findOrCreateMagicLinkUser(input);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function findOrCreateMagicLinkUser(
|
||||
input: FindOrCreateMagicLink,
|
||||
): Promise<LoggedInUser> {
|
||||
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({
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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() {
|
||||
<span>Continue with Google</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{data.isDevelopment && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
data-action="continue with email"
|
||||
className="text-text-bright"
|
||||
onClick={() => (window.location.href = "/login/magic")}
|
||||
>
|
||||
<Mail className="text-text-bright mr-2 size-5" />
|
||||
Continue with Email
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Fieldset>
|
||||
</CardContent>
|
||||
212
apps/webapp/app/routes/login.magic.tsx
Normal file
212
apps/webapp/app/routes/login.magic.tsx
Normal file
@ -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<any> {
|
||||
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<typeof loader>();
|
||||
const navigate = useNavigation();
|
||||
|
||||
if (!data.isDevelopment) {
|
||||
return (
|
||||
<LoginPageLayout>
|
||||
<Paragraph className="text-center">
|
||||
Magic link login is only available in development mode.
|
||||
</Paragraph>
|
||||
</LoginPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const isLoading =
|
||||
(navigate.state === "loading" || navigate.state === "submitting") &&
|
||||
navigate.formAction !== undefined &&
|
||||
navigate.formData?.get("action") === "send";
|
||||
|
||||
return (
|
||||
<LoginPageLayout>
|
||||
<Form method="post">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
{data.magicLinkSent ? (
|
||||
<>
|
||||
<Header1 className="pb-6 text-center text-xl leading-7 font-normal md:text-xl lg:text-2xl">
|
||||
We've sent you a magic link!
|
||||
</Header1>
|
||||
<Fieldset className="flex w-full flex-col items-center gap-y-2">
|
||||
<Inbox className="text-primary mb-4 h-12 w-12" />
|
||||
<Paragraph className="mb-6 text-center">
|
||||
We sent you an email which contains a magic link that will log
|
||||
you in to your account.
|
||||
</Paragraph>
|
||||
<FormButtons
|
||||
cancelButton={
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="reset"
|
||||
variant="link"
|
||||
data-action="re-enter email"
|
||||
>
|
||||
Re-enter email
|
||||
</Button>
|
||||
}
|
||||
confirmButton={
|
||||
<Button
|
||||
variant="ghost"
|
||||
data-action="log in using another option"
|
||||
>
|
||||
Log in using another option
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Header1 className="pb-4 font-semibold sm:text-2xl md:text-3xl lg:text-4xl">
|
||||
Welcome
|
||||
</Header1>
|
||||
<Paragraph variant="base" className="mb-6 text-center">
|
||||
Create an account or login using email
|
||||
</Paragraph>
|
||||
<Fieldset className="flex w-full flex-col items-center gap-y-2">
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
spellCheck={false}
|
||||
placeholder="Email Address"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<Button
|
||||
name="action"
|
||||
value="send"
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
disabled={isLoading}
|
||||
data-action="send a magic link"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader className="mr-2 size-5" color="white" />
|
||||
) : (
|
||||
<Mail className="text-text-bright mr-2 size-5" />
|
||||
)}
|
||||
{isLoading ? (
|
||||
<span className="text-text-bright">Sending…</span>
|
||||
) : (
|
||||
<span className="text-text-bright">Send a magic link</span>
|
||||
)}
|
||||
</Button>
|
||||
{data.magicLinkError && <>{data.magicLinkError}</>}
|
||||
</Fieldset>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
</LoginPageLayout>
|
||||
);
|
||||
}
|
||||
23
apps/webapp/app/routes/magic.tsx
Normal file
23
apps/webapp/app/routes/magic.tsx
Normal file
@ -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,
|
||||
});
|
||||
}
|
||||
@ -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
|
||||
>;
|
||||
@ -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 };
|
||||
|
||||
95
apps/webapp/app/services/email.server.ts
Normal file
95
apps/webapp/app/services/email.server.ts
Normal file
@ -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<void> {
|
||||
// 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);
|
||||
}
|
||||
43
apps/webapp/app/services/emailAuth.server.tsx
Normal file
43
apps/webapp/app/services/emailAuth.server.tsx
Normal file
@ -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<AuthUser>) {
|
||||
authenticator.use(emailStrategy as any, "email-link");
|
||||
}
|
||||
@ -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",
|
||||
|
||||
74
docker/Dockerfile
Normal file
74
docker/Dockerfile
Normal file
@ -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"]
|
||||
0
docker/docker-compose.yaml
Normal file
0
docker/docker-compose.yaml
Normal file
18
docker/scripts/entrypoint.sh
Executable file
18
docker/scripts/entrypoint.sh
Executable file
@ -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
|
||||
|
||||
184
docker/scripts/wait-for-it.sh
Executable file
184
docker/scripts/wait-for-it.sh
Executable file
@ -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 "$@"
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "AuthenticationMethod" ADD VALUE 'MAGIC_LINK';
|
||||
@ -61,6 +61,7 @@ model Workspace {
|
||||
|
||||
enum AuthenticationMethod {
|
||||
GOOGLE
|
||||
MAGIC_LINK
|
||||
}
|
||||
|
||||
/// Used to generate PersonalAccessTokens, they're one-time use
|
||||
|
||||
1
packages/emails/.gitignore
vendored
Normal file
1
packages/emails/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
.react-email
|
||||
17
packages/emails/README.md
Normal file
17
packages/emails/README.md
Normal file
@ -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.
|
||||
10
packages/emails/emails/components/BasePath.tsx
Normal file
10
packages/emails/emails/components/BasePath.tsx
Normal file
@ -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;
|
||||
}
|
||||
17
packages/emails/emails/components/Footer.tsx
Normal file
17
packages/emails/emails/components/Footer.tsx
Normal file
@ -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 (
|
||||
<>
|
||||
<Hr style={hr} />
|
||||
<Text style={footer}>
|
||||
©Sol.ai
|
||||
<Link style={footerAnchor} href="https://core.heysol.dev/">
|
||||
C.O.R.E
|
||||
</Link>
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
13
packages/emails/emails/components/Image.tsx
Normal file
13
packages/emails/emails/components/Image.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { Img } from "@react-email/components";
|
||||
import * as React from "react";
|
||||
import { getGlobalBasePath } from "./BasePath";
|
||||
|
||||
type ImageProps = Omit<Parameters<typeof Img>[0], "src"> & {
|
||||
path: string;
|
||||
};
|
||||
|
||||
export function Image({ path, ...props }: ImageProps) {
|
||||
const basePath = getGlobalBasePath();
|
||||
|
||||
return <Img src={`${basePath}${path}`} {...props} />;
|
||||
}
|
||||
113
packages/emails/emails/components/styles.ts
Normal file
113
packages/emails/emails/components/styles.ts
Normal file
@ -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",
|
||||
};
|
||||
49
packages/emails/emails/invite.tsx
Normal file
49
packages/emails/emails/invite.tsx
Normal file
@ -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<typeof InviteEmailSchema>) {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{`You've been invited to ${orgName}`}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Text style={h1}>{`You've been invited to ${orgName}`}</Text>
|
||||
<Text style={paragraphLight}>
|
||||
{inviterName ?? inviterEmail} has invited you to join their organization on Sol.ai.
|
||||
</Text>
|
||||
<Link
|
||||
href={inviteLink}
|
||||
target="_blank"
|
||||
style={{
|
||||
...anchor,
|
||||
display: "block",
|
||||
marginBottom: "50px",
|
||||
}}
|
||||
>
|
||||
Click here to view the invitation
|
||||
</Link>
|
||||
|
||||
<Image path="/emails/logo-mono.png" width="120" height="22" alt="Sol.ai" />
|
||||
<Footer />
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
39
packages/emails/emails/magic-link.tsx
Normal file
39
packages/emails/emails/magic-link.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { Body, Container, Head, Html, Link, Preview, Text } from "@react-email/components";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { Image } from "./components/Image";
|
||||
import { anchor, container, h1, main, paragraphLight } from "./components/styles";
|
||||
|
||||
export default function Email({ magicLink }: { magicLink: string }) {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Log in with this magic link 🪄</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Text style={h1}>Log in to C.O.R.E.</Text>
|
||||
<Link
|
||||
href={magicLink}
|
||||
target="_blank"
|
||||
style={{
|
||||
...anchor,
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
Click here to log in with this magic link
|
||||
</Link>
|
||||
<Text
|
||||
style={{
|
||||
...paragraphLight,
|
||||
display: "block",
|
||||
marginBottom: "50px",
|
||||
}}
|
||||
>
|
||||
If you didn't try to log in, you can safely ignore this email.
|
||||
</Text>
|
||||
<Image path="/emails/logo-mono.png" width="120" height="22" alt="core.heysol.ai" />
|
||||
<Footer />
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
53
packages/emails/emails/welcome.tsx
Normal file
53
packages/emails/emails/welcome.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { Body, Head, Html, Link, Preview, Section, Text } from "@react-email/components";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { anchor, bullets, footerItalic, main, paragraphLight } from "./components/styles";
|
||||
|
||||
export default function Email({ name }: { name?: string }) {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Welcome to C.O.R.E. - Your Personal AI Assistant</Preview>
|
||||
<Body style={main}>
|
||||
<Text style={paragraphLight}>Hey {name ?? "there"},</Text>
|
||||
<Text style={paragraphLight}>Welcome to C.O.R.E., your new personal AI assistant!</Text>
|
||||
<Text style={paragraphLight}>
|
||||
I'm excited to help you streamline your daily tasks, boost your productivity, and make
|
||||
your work life easier. C.O.R.E. is designed to be intuitive and powerful, adapting to your
|
||||
unique needs and preferences.
|
||||
</Text>
|
||||
<Text style={paragraphLight}>
|
||||
To get started, you can{" "}
|
||||
<Link style={anchor} href="https://core.heysol.ai/dashboard">
|
||||
visit your dashboard
|
||||
</Link>{" "}
|
||||
where you'll find all the features and capabilities at your disposal. Whether it's
|
||||
managing your schedule, handling communications, or automating repetitive tasks, I'm here
|
||||
to help.
|
||||
</Text>
|
||||
|
||||
<Text style={paragraphLight}>
|
||||
If you have any questions or need assistance, don't hesitate to reach out. You can:{"\n"}•
|
||||
Ask me directly through the chat interface{"\n"}•{" "}
|
||||
<Link style={anchor} href="https://core.heysol.ai/support">
|
||||
Visit our support center
|
||||
</Link>
|
||||
{"\n"}• Join our{" "}
|
||||
<Link style={anchor} href="https://discord.gg/heysol">
|
||||
Discord community
|
||||
</Link>{" "}
|
||||
to connect with other users and our team
|
||||
</Text>
|
||||
|
||||
<Text style={paragraphLight}>Looking forward to being your trusted assistant!</Text>
|
||||
|
||||
<Text style={bullets}>Best regards,</Text>
|
||||
<Text style={bullets}>C.O.R.E.</Text>
|
||||
<Text style={paragraphLight}>Your AI Assistant</Text>
|
||||
<Text style={footerItalic}>
|
||||
You can customize your notification preferences anytime in your account settings.
|
||||
</Text>
|
||||
<Footer />
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
29
packages/emails/package.json
Normal file
29
packages/emails/package.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "emails",
|
||||
"version": "1.0.0",
|
||||
"description": "Send emails",
|
||||
"main": "./src/index.tsx",
|
||||
"types": "./src/index.tsx",
|
||||
"scripts": {
|
||||
"dev": "PORT=3080 email dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-ses": "^3.716.0",
|
||||
"@react-email/components": "0.0.16",
|
||||
"@react-email/render": "^0.0.12",
|
||||
"nodemailer": "^6.9.16",
|
||||
"react": "^18.2.0",
|
||||
"react-email": "^2.1.1",
|
||||
"resend": "^3.2.0",
|
||||
"tiny-invariant": "^1.2.0",
|
||||
"zod": "3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/react": "18.2.69"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
96
packages/emails/src/index.tsx
Normal file
96
packages/emails/src/index.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import { ReactElement } from "react";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { setGlobalBasePath } from "../emails/components/BasePath";
|
||||
|
||||
import InviteEmail, { InviteEmailSchema } from "../emails/invite";
|
||||
import MagicLinkEmail from "../emails/magic-link";
|
||||
import WelcomeEmail from "../emails/welcome";
|
||||
import { constructMailTransport, MailTransport, MailTransportOptions } from "./transports";
|
||||
|
||||
export { type MailTransportOptions };
|
||||
|
||||
export const DeliverEmailSchema = z
|
||||
.discriminatedUnion("email", [
|
||||
z.object({
|
||||
email: z.literal("magic_link"),
|
||||
magicLink: z.string().url(),
|
||||
}),
|
||||
InviteEmailSchema,
|
||||
])
|
||||
.and(z.object({ to: z.string() }));
|
||||
|
||||
export type DeliverEmail = z.infer<typeof DeliverEmailSchema>;
|
||||
|
||||
export type SendPlainTextOptions = { to: string; subject: string; text: string };
|
||||
|
||||
export class EmailClient {
|
||||
#transport: MailTransport;
|
||||
|
||||
#imagesBaseUrl: string;
|
||||
#from: string;
|
||||
#replyTo: string;
|
||||
|
||||
constructor(config: {
|
||||
transport?: MailTransportOptions;
|
||||
imagesBaseUrl: string;
|
||||
from: string;
|
||||
replyTo: string;
|
||||
}) {
|
||||
this.#transport = constructMailTransport(config.transport ?? { type: undefined });
|
||||
|
||||
this.#imagesBaseUrl = config.imagesBaseUrl;
|
||||
this.#from = config.from;
|
||||
this.#replyTo = config.replyTo;
|
||||
}
|
||||
|
||||
async send(data: DeliverEmail) {
|
||||
const { subject, component } = this.#getTemplate(data);
|
||||
|
||||
setGlobalBasePath(this.#imagesBaseUrl);
|
||||
|
||||
return await this.#transport.send({
|
||||
to: data.to,
|
||||
subject,
|
||||
react: component,
|
||||
from: this.#from,
|
||||
replyTo: this.#replyTo,
|
||||
});
|
||||
}
|
||||
|
||||
async sendPlainText(options: SendPlainTextOptions) {
|
||||
await this.#transport.sendPlainText({
|
||||
...options,
|
||||
from: this.#from,
|
||||
replyTo: this.#replyTo,
|
||||
});
|
||||
}
|
||||
|
||||
#getTemplate(data: DeliverEmail): {
|
||||
subject: string;
|
||||
component: ReactElement;
|
||||
} {
|
||||
switch (data.email) {
|
||||
case "magic_link":
|
||||
return {
|
||||
subject: "Magic sign-in link for C.O.R.E.",
|
||||
component: <MagicLinkEmail magicLink={data.magicLink} />,
|
||||
};
|
||||
case "invite":
|
||||
return {
|
||||
subject: `You've been invited to join ${data.orgName} on C.O.R.E.`,
|
||||
component: <InviteEmail {...data} />,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatErrorMessageForSubject(message?: string) {
|
||||
if (!message) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const singleLine = message.replace(/[\r\n]+/g, " ");
|
||||
return singleLine.length > 30 ? singleLine.substring(0, 27) + "..." : singleLine;
|
||||
}
|
||||
67
packages/emails/src/transports/aws-ses.ts
Normal file
67
packages/emails/src/transports/aws-ses.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { render } from "@react-email/render";
|
||||
import { EmailError, MailMessage, MailTransport, PlainTextMailMessage } from "./index";
|
||||
import nodemailer from "nodemailer"
|
||||
import * as awsSes from "@aws-sdk/client-ses"
|
||||
|
||||
export type AwsSesMailTransportOptions = {
|
||||
type: 'aws-ses',
|
||||
}
|
||||
|
||||
export class AwsSesMailTransport implements MailTransport {
|
||||
#client: nodemailer.Transporter;
|
||||
|
||||
constructor(options: AwsSesMailTransportOptions) {
|
||||
const ses = new awsSes.SESClient()
|
||||
|
||||
this.#client = nodemailer.createTransport({
|
||||
SES: {
|
||||
aws: awsSes,
|
||||
ses
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async send({to, from, replyTo, subject, react}: MailMessage): Promise<void> {
|
||||
try {
|
||||
await this.#client.sendMail({
|
||||
from: from,
|
||||
to,
|
||||
replyTo: replyTo,
|
||||
subject,
|
||||
html: render(react),
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(
|
||||
`Failed to send email to ${to}, ${subject}. Error ${error.name}: ${error.message}`
|
||||
);
|
||||
throw new EmailError(error);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async sendPlainText({to, from, replyTo, subject, text}: PlainTextMailMessage): Promise<void> {
|
||||
try {
|
||||
await this.#client.sendMail({
|
||||
from: from,
|
||||
to,
|
||||
replyTo: replyTo,
|
||||
subject,
|
||||
text: text,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(
|
||||
`Failed to send email to ${to}, ${subject}. Error ${error.name}: ${error.message}`
|
||||
);
|
||||
throw new EmailError(error);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
52
packages/emails/src/transports/index.ts
Normal file
52
packages/emails/src/transports/index.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { ReactElement } from "react";
|
||||
import { AwsSesMailTransport, AwsSesMailTransportOptions } from "./aws-ses";
|
||||
import { NullMailTransport, NullMailTransportOptions } from "./null";
|
||||
import { ResendMailTransport, ResendMailTransportOptions } from "./resend";
|
||||
import { SmtpMailTransport, SmtpMailTransportOptions } from "./smtp";
|
||||
|
||||
export type MailMessage = {
|
||||
to: string;
|
||||
from: string;
|
||||
replyTo: string;
|
||||
subject: string;
|
||||
react: ReactElement;
|
||||
};
|
||||
|
||||
export type PlainTextMailMessage = {
|
||||
to: string;
|
||||
from: string;
|
||||
replyTo: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface MailTransport {
|
||||
send(message: MailMessage): Promise<void>;
|
||||
sendPlainText(message: PlainTextMailMessage): Promise<void>;
|
||||
}
|
||||
|
||||
export class EmailError extends Error {
|
||||
constructor({ name, message }: { name: string; message: string }) {
|
||||
super(message);
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
export type MailTransportOptions =
|
||||
AwsSesMailTransportOptions |
|
||||
ResendMailTransportOptions |
|
||||
NullMailTransportOptions |
|
||||
SmtpMailTransportOptions
|
||||
|
||||
export function constructMailTransport(options: MailTransportOptions): MailTransport {
|
||||
switch(options.type) {
|
||||
case "aws-ses":
|
||||
return new AwsSesMailTransport(options);
|
||||
case "resend":
|
||||
return new ResendMailTransport(options);
|
||||
case "smtp":
|
||||
return new SmtpMailTransport(options);
|
||||
case undefined:
|
||||
return new NullMailTransport(options);
|
||||
}
|
||||
}
|
||||
29
packages/emails/src/transports/null.ts
Normal file
29
packages/emails/src/transports/null.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { render } from "@react-email/render";
|
||||
import { MailMessage, MailTransport, PlainTextMailMessage } from "./index";
|
||||
|
||||
export type NullMailTransportOptions = {
|
||||
type: undefined,
|
||||
}
|
||||
|
||||
export class NullMailTransport implements MailTransport {
|
||||
constructor(options: NullMailTransportOptions) {
|
||||
}
|
||||
|
||||
async send({to, subject, react}: MailMessage): Promise<void> {
|
||||
console.log(`
|
||||
##### sendEmail to ${to}, subject: ${subject}
|
||||
|
||||
${render(react, {
|
||||
plainText: true,
|
||||
})}
|
||||
`);
|
||||
}
|
||||
|
||||
async sendPlainText({to, subject, text}: PlainTextMailMessage): Promise<void> {
|
||||
console.log(`
|
||||
##### sendEmail to ${to}, subject: ${subject}
|
||||
|
||||
${text}
|
||||
`);
|
||||
}
|
||||
}
|
||||
51
packages/emails/src/transports/resend.ts
Normal file
51
packages/emails/src/transports/resend.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { EmailError, MailMessage, MailTransport, PlainTextMailMessage } from "./index";
|
||||
import { Resend } from "resend";
|
||||
|
||||
export type ResendMailTransportOptions = {
|
||||
type: 'resend',
|
||||
config: {
|
||||
apiKey?: string
|
||||
}
|
||||
}
|
||||
|
||||
export class ResendMailTransport implements MailTransport {
|
||||
#client: Resend;
|
||||
|
||||
constructor(options: ResendMailTransportOptions) {
|
||||
this.#client = new Resend(options.config.apiKey)
|
||||
}
|
||||
|
||||
async send({to, from, replyTo, subject, react}: MailMessage): Promise<void> {
|
||||
const result = await this.#client.emails.send({
|
||||
from: from,
|
||||
to,
|
||||
reply_to: replyTo,
|
||||
subject,
|
||||
react,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error(
|
||||
`Failed to send email to ${to}, ${subject}. Error ${result.error.name}: ${result.error.message}`
|
||||
);
|
||||
throw new EmailError(result.error);
|
||||
}
|
||||
}
|
||||
|
||||
async sendPlainText({to, from, replyTo, subject, text}: PlainTextMailMessage): Promise<void> {
|
||||
const result = await this.#client.emails.send({
|
||||
from: from,
|
||||
to,
|
||||
reply_to: replyTo,
|
||||
subject,
|
||||
text,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error(
|
||||
`Failed to send email to ${to}, ${subject}. Error ${result.error.name}: ${result.error.message}`
|
||||
);
|
||||
throw new EmailError(result.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
66
packages/emails/src/transports/smtp.ts
Normal file
66
packages/emails/src/transports/smtp.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { render } from "@react-email/render";
|
||||
import nodemailer from "nodemailer";
|
||||
import { EmailError, MailMessage, MailTransport, PlainTextMailMessage } from "./index";
|
||||
|
||||
export type SmtpMailTransportOptions = {
|
||||
type: "smtp";
|
||||
config: {
|
||||
host?: string;
|
||||
port?: number;
|
||||
secure?: boolean;
|
||||
auth?: {
|
||||
user?: string;
|
||||
pass?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export class SmtpMailTransport implements MailTransport {
|
||||
#client: nodemailer.Transporter;
|
||||
|
||||
constructor(options: SmtpMailTransportOptions) {
|
||||
this.#client = nodemailer.createTransport(options.config);
|
||||
}
|
||||
|
||||
async send({ to, from, replyTo, subject, react }: MailMessage): Promise<void> {
|
||||
try {
|
||||
await this.#client.sendMail({
|
||||
from: from,
|
||||
to,
|
||||
replyTo: replyTo,
|
||||
subject,
|
||||
html: render(react),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(
|
||||
`Failed to send email to ${to}, ${subject}. Error ${error.name}: ${error.message}`
|
||||
);
|
||||
throw new EmailError(error);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async sendPlainText({ to, from, replyTo, subject, text }: PlainTextMailMessage): Promise<void> {
|
||||
try {
|
||||
await this.#client.sendMail({
|
||||
from: from,
|
||||
to,
|
||||
replyTo: replyTo,
|
||||
subject,
|
||||
text: text,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(
|
||||
`Failed to send email to ${to}, ${subject}. Error ${error.name}: ${error.message}`
|
||||
);
|
||||
throw new EmailError(error);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
packages/emails/tsconfig.json
Normal file
20
packages/emails/tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"incremental": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"target": "ES2015",
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
3862
pnpm-lock.yaml
generated
3862
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -56,6 +56,7 @@
|
||||
"NEO4J_URI",
|
||||
"NEO4J_USERNAME",
|
||||
"NEO4J_PASSWORD",
|
||||
"OPENAI_API_KEY"
|
||||
"OPENAI_API_KEY",
|
||||
"MAGIC_LINK_SECRET"
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user