mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-10 08:58:31 +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 { GraphPopovers } from "./graph-popover";
|
||||||
import type { RawTriplet, NodePopupContent, EdgePopupContent } from "./type";
|
import type { RawTriplet, NodePopupContent, EdgePopupContent } from "./type";
|
||||||
|
|
||||||
import { createLabelColorMap, getNodeColor } from "./node-colors";
|
import { createLabelColorMap } from "./node-colors";
|
||||||
|
|
||||||
import { useTheme } from "remix-themes";
|
import { useTheme } from "remix-themes";
|
||||||
import { toGraphTriplets } from "./utils";
|
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 {
|
import {
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
MoreHorizontalIcon,
|
MoreHorizontalIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils";
|
||||||
import { Button, buttonVariants } from "~/components/ui/button"
|
import { type Button, buttonVariants } from "~/components/ui/button";
|
||||||
|
|
||||||
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||||
return (
|
return (
|
||||||
@ -17,7 +17,7 @@ function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
|||||||
className={cn("mx-auto flex w-full justify-center", className)}
|
className={cn("mx-auto flex w-full justify-center", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PaginationContent({
|
function PaginationContent({
|
||||||
@ -30,17 +30,17 @@ function PaginationContent({
|
|||||||
className={cn("flex flex-row items-center gap-1", className)}
|
className={cn("flex flex-row items-center gap-1", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||||
return <li data-slot="pagination-item" {...props} />
|
return <li data-slot="pagination-item" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PaginationLinkProps = {
|
type PaginationLinkProps = {
|
||||||
isActive?: boolean
|
isActive?: boolean;
|
||||||
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||||
React.ComponentProps<"a">
|
React.ComponentProps<"a">;
|
||||||
|
|
||||||
function PaginationLink({
|
function PaginationLink({
|
||||||
className,
|
className,
|
||||||
@ -58,11 +58,11 @@ function PaginationLink({
|
|||||||
variant: isActive ? "outline" : "ghost",
|
variant: isActive ? "outline" : "ghost",
|
||||||
size,
|
size,
|
||||||
}),
|
}),
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PaginationPrevious({
|
function PaginationPrevious({
|
||||||
@ -79,7 +79,7 @@ function PaginationPrevious({
|
|||||||
<ChevronLeftIcon />
|
<ChevronLeftIcon />
|
||||||
<span className="hidden sm:block">Previous</span>
|
<span className="hidden sm:block">Previous</span>
|
||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PaginationNext({
|
function PaginationNext({
|
||||||
@ -96,7 +96,7 @@ function PaginationNext({
|
|||||||
<span className="hidden sm:block">Next</span>
|
<span className="hidden sm:block">Next</span>
|
||||||
<ChevronRightIcon />
|
<ChevronRightIcon />
|
||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PaginationEllipsis({
|
function PaginationEllipsis({
|
||||||
@ -113,7 +113,7 @@ function PaginationEllipsis({
|
|||||||
<MoreHorizontalIcon className="size-4" />
|
<MoreHorizontalIcon className="size-4" />
|
||||||
<span className="sr-only">More pages</span>
|
<span className="sr-only">More pages</span>
|
||||||
</span>
|
</span>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@ -124,4 +124,4 @@ export {
|
|||||||
PaginationPrevious,
|
PaginationPrevious,
|
||||||
PaginationNext,
|
PaginationNext,
|
||||||
PaginationEllipsis,
|
PaginationEllipsis,
|
||||||
}
|
};
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Slot } from "@radix-ui/react-slot";
|
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 { PanelLeftIcon } from "lucide-react";
|
||||||
|
|
||||||
import { useIsMobile } from "~/hooks/use-mobile";
|
import { useIsMobile } from "~/hooks/use-mobile";
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { isValidDatabaseUrl } from "./utils/db";
|
import { isValidDatabaseUrl } from "./utils/db";
|
||||||
|
import { isValidRegex } from "./utils/regex";
|
||||||
|
|
||||||
const EnvironmentSchema = z.object({
|
const EnvironmentSchema = z.object({
|
||||||
NODE_ENV: z.union([
|
NODE_ENV: z.union([
|
||||||
@ -25,6 +26,15 @@ const EnvironmentSchema = z.object({
|
|||||||
DATABASE_READ_REPLICA_URL: z.string().optional(),
|
DATABASE_READ_REPLICA_URL: z.string().optional(),
|
||||||
SESSION_SECRET: z.string(),
|
SESSION_SECRET: z.string(),
|
||||||
ENCRYPTION_KEY: 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),
|
APP_ENV: z.string().default(process.env.NODE_ENV),
|
||||||
LOGIN_ORIGIN: z.string().default("http://localhost:5173"),
|
LOGIN_ORIGIN: z.string().default("http://localhost:5173"),
|
||||||
@ -47,6 +57,16 @@ const EnvironmentSchema = z.object({
|
|||||||
|
|
||||||
//OpenAI
|
//OpenAI
|
||||||
OPENAI_API_KEY: z.string(),
|
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>;
|
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 { type UIMatch } from "@remix-run/react";
|
||||||
|
|
||||||
import { useTypedMatchesData } from "./useTypedMatchData";
|
import { useTypedMatchesData } from "./useTypedMatchData";
|
||||||
import { loader } from "~/routes/home";
|
import { type loader } from "~/routes/home";
|
||||||
|
|
||||||
export function useOptionalWorkspace(
|
export function useOptionalWorkspace(
|
||||||
matches?: UIMatch[],
|
matches?: UIMatch[],
|
||||||
|
|||||||
@ -1,8 +1,14 @@
|
|||||||
import type { Prisma, User } from "@core/database";
|
import type { Prisma, User } from "@core/database";
|
||||||
import type { GoogleProfile } from "@coji/remix-auth-google";
|
import type { GoogleProfile } from "@coji/remix-auth-google";
|
||||||
import { prisma } from "~/db.server";
|
import { prisma } from "~/db.server";
|
||||||
|
import { env } from "~/env.server";
|
||||||
export type { User } from "@core/database";
|
export type { User } from "@core/database";
|
||||||
|
|
||||||
|
type FindOrCreateMagicLink = {
|
||||||
|
authenticationMethod: "MAGIC_LINK";
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
type FindOrCreateGoogle = {
|
type FindOrCreateGoogle = {
|
||||||
authenticationMethod: "GOOGLE";
|
authenticationMethod: "GOOGLE";
|
||||||
email: User["email"];
|
email: User["email"];
|
||||||
@ -10,7 +16,7 @@ type FindOrCreateGoogle = {
|
|||||||
authenticationExtraParams: Record<string, unknown>;
|
authenticationExtraParams: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FindOrCreateUser = FindOrCreateGoogle;
|
type FindOrCreateUser = FindOrCreateMagicLink | FindOrCreateGoogle;
|
||||||
|
|
||||||
type LoggedInUser = {
|
type LoggedInUser = {
|
||||||
user: User;
|
user: User;
|
||||||
@ -20,7 +26,55 @@ type LoggedInUser = {
|
|||||||
export async function findOrCreateUser(
|
export async function findOrCreateUser(
|
||||||
input: FindOrCreateUser,
|
input: FindOrCreateUser,
|
||||||
): Promise<LoggedInUser> {
|
): 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({
|
export async function findOrCreateGoogleUser({
|
||||||
|
|||||||
@ -1,9 +1,4 @@
|
|||||||
import {
|
import { createCookie, type LoaderFunction } from "@remix-run/node";
|
||||||
redirect,
|
|
||||||
createCookie,
|
|
||||||
type ActionFunction,
|
|
||||||
type LoaderFunction,
|
|
||||||
} from "@remix-run/node";
|
|
||||||
import { authenticator } from "~/services/auth.server";
|
import { authenticator } from "~/services/auth.server";
|
||||||
|
|
||||||
export let loader: LoaderFunction = async ({ request }) => {
|
export let loader: LoaderFunction = async ({ request }) => {
|
||||||
|
|||||||
@ -1,14 +1,7 @@
|
|||||||
import {
|
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||||
type ActionFunctionArgs,
|
import { requireUser, requireWorkpace } from "~/services/session.server";
|
||||||
type LoaderFunctionArgs,
|
|
||||||
} from "@remix-run/server-runtime";
|
|
||||||
import {
|
|
||||||
requireUser,
|
|
||||||
requireUserId,
|
|
||||||
requireWorkpace,
|
|
||||||
} from "~/services/session.server";
|
|
||||||
|
|
||||||
import { Outlet, useActionData } from "@remix-run/react";
|
import { Outlet } from "@remix-run/react";
|
||||||
import { typedjson } from "remix-typedjson";
|
import { typedjson } from "remix-typedjson";
|
||||||
import { clearRedirectTo, commitSession } from "~/services/redirectTo.server";
|
import { clearRedirectTo, commitSession } from "~/services/redirectTo.server";
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { setRedirectTo } from "~/services/redirectTo.server";
|
|||||||
import { getUserId } from "~/services/session.server";
|
import { getUserId } from "~/services/session.server";
|
||||||
import { commitSession } from "~/services/sessionStorage.server";
|
import { commitSession } from "~/services/sessionStorage.server";
|
||||||
import { requestUrl } from "~/utils/requestUrl.server";
|
import { requestUrl } from "~/utils/requestUrl.server";
|
||||||
|
import { env } from "~/env.server";
|
||||||
|
|
||||||
import { RiGoogleLine } from "@remixicon/react";
|
import { RiGoogleLine } from "@remixicon/react";
|
||||||
import {
|
import {
|
||||||
@ -18,6 +19,7 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "~/components/ui/card";
|
} from "~/components/ui/card";
|
||||||
import { Button } from "~/components/ui";
|
import { Button } from "~/components/ui";
|
||||||
|
import { Mail } from "lucide-react";
|
||||||
|
|
||||||
export async function loader({ request }: LoaderFunctionArgs) {
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
const userId = await getUserId(request);
|
const userId = await getUserId(request);
|
||||||
@ -30,7 +32,11 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
const session = await setRedirectTo(request, redirectTo);
|
const session = await setRedirectTo(request, redirectTo);
|
||||||
|
|
||||||
return typedjson(
|
return typedjson(
|
||||||
{ redirectTo, showGoogleAuth: isGoogleAuthSupported },
|
{
|
||||||
|
redirectTo,
|
||||||
|
showGoogleAuth: isGoogleAuthSupported,
|
||||||
|
isDevelopment: env.NODE_ENV === "development",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
"Set-Cookie": await commitSession(session),
|
"Set-Cookie": await commitSession(session),
|
||||||
@ -41,6 +47,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
return typedjson({
|
return typedjson({
|
||||||
redirectTo: null,
|
redirectTo: null,
|
||||||
showGoogleAuth: isGoogleAuthSupported,
|
showGoogleAuth: isGoogleAuthSupported,
|
||||||
|
isDevelopment: env.NODE_ENV === "development",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -72,6 +79,19 @@ export default function LoginPage() {
|
|||||||
<span>Continue with Google</span>
|
<span>Continue with Google</span>
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</Fieldset>
|
</Fieldset>
|
||||||
</CardContent>
|
</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 { Authenticator } from "remix-auth";
|
||||||
|
|
||||||
import type { AuthUser } from "./authUser";
|
import type { AuthUser } from "./authUser";
|
||||||
|
|
||||||
import { addGoogleStrategy } from "./googleAuth.server";
|
import { addGoogleStrategy } from "./googleAuth.server";
|
||||||
|
|
||||||
import { env } from "~/env.server";
|
import { env } from "~/env.server";
|
||||||
|
import { addEmailLinkStrategy } from "./emailAuth.server";
|
||||||
|
|
||||||
// Create an instance of the authenticator, pass a generic with what
|
// Create an instance of the authenticator, pass a generic with what
|
||||||
// strategies will return and will store in the session
|
// 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 };
|
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",
|
"dayjs": "^1.11.10",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"express": "^4.18.1",
|
"express": "^4.18.1",
|
||||||
|
"emails": "workspace:*",
|
||||||
"ioredis": "^5.6.1",
|
"ioredis": "^5.6.1",
|
||||||
"isbot": "^4.1.0",
|
"isbot": "^4.1.0",
|
||||||
"jose": "^5.2.3",
|
"jose": "^5.2.3",
|
||||||
@ -69,6 +70,7 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"remix-auth": "^4.2.0",
|
"remix-auth": "^4.2.0",
|
||||||
|
"@nichtsam/remix-auth-email-link": "3.0.0",
|
||||||
"remix-auth-oauth2": "^3.4.1",
|
"remix-auth-oauth2": "^3.4.1",
|
||||||
"remix-themes": "^1.3.1",
|
"remix-themes": "^1.3.1",
|
||||||
"remix-typedjson": "0.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 {
|
enum AuthenticationMethod {
|
||||||
GOOGLE
|
GOOGLE
|
||||||
|
MAGIC_LINK
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Used to generate PersonalAccessTokens, they're one-time use
|
/// 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_URI",
|
||||||
"NEO4J_USERNAME",
|
"NEO4J_USERNAME",
|
||||||
"NEO4J_PASSWORD",
|
"NEO4J_PASSWORD",
|
||||||
"OPENAI_API_KEY"
|
"OPENAI_API_KEY",
|
||||||
|
"MAGIC_LINK_SECRET"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user