improve email formatting and invite flow for new users

This commit is contained in:
Milo Schwartz 2024-12-31 18:25:11 -05:00
parent d244d6003b
commit d447de9e8a
No known key found for this signature in database
15 changed files with 107 additions and 89 deletions

View file

@ -131,7 +131,7 @@ export const newtSessions = sqliteTable("newtSession", {
export const userOrgs = sqliteTable("userOrgs", { export const userOrgs = sqliteTable("userOrgs", {
userId: text("userId") userId: text("userId")
.notNull() .notNull()
.references(() => users.userId), .references(() => users.userId, { onDelete: "cascade" }),
orgId: text("orgId") orgId: text("orgId")
.references(() => orgs.orgId, { .references(() => orgs.orgId, {
onDelete: "cascade" onDelete: "cascade"

View file

@ -6,6 +6,7 @@ import logger from "@server/logger";
export async function sendEmail( export async function sendEmail(
template: ReactElement, template: ReactElement,
opts: { opts: {
name: string | undefined;
from: string | undefined; from: string | undefined;
to: string | undefined; to: string | undefined;
subject: string; subject: string;
@ -23,14 +24,15 @@ export async function sendEmail(
const emailHtml = await render(template); const emailHtml = await render(template);
const options = { await emailClient.sendMail({
from: opts.from, from: {
name: opts.name || "Pangolin Proxy",
address: opts.from,
},
to: opts.to, to: opts.to,
subject: opts.subject, subject: opts.subject,
html: emailHtml, html: emailHtml,
}; });
await emailClient.sendMail(options);
} }
export default sendEmail; export default sendEmail;

View file

@ -10,6 +10,7 @@ import {
Tailwind Tailwind
} from "@react-email/components"; } from "@react-email/components";
import * as React from "react"; import * as React from "react";
import LetterHead from "./components/LetterHead";
interface Props { interface Props {
email: string; email: string;
@ -35,15 +36,7 @@ export const ConfirmPasswordReset = ({ email }: Props) => {
> >
<Body className="font-sans relative"> <Body className="font-sans relative">
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg"> <Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
<div className="flex items-center justify-between"> <LetterHead />
<div className="text-sm font-bold text-orange-500">
Pangolin
</div>
<div className="text-sm text-gray-500">
{new Date().toLocaleDateString()}
</div>
</div>
<Heading className="text-2xl font-semibold text-gray-800 text-center"> <Heading className="text-2xl font-semibold text-gray-800 text-center">
Password Reset Confirmation Password Reset Confirmation
@ -56,10 +49,6 @@ export const ConfirmPasswordReset = ({ email }: Props) => {
reset. If you made this change, no further action is reset. If you made this change, no further action is
required. required.
</Text> </Text>
<Text className="text-base text-gray-700">
If you did not request this change, please contact
our support team immediately.
</Text>
<Text className="text-base text-gray-700 mt-2"> <Text className="text-base text-gray-700 mt-2">
Thank you for keeping your account secure. Thank you for keeping your account secure.
</Text> </Text>

View file

@ -10,6 +10,7 @@ import {
Tailwind Tailwind
} from "@react-email/components"; } from "@react-email/components";
import * as React from "react"; import * as React from "react";
import LetterHead from "./components/LetterHead";
interface Props { interface Props {
email: string; email: string;
@ -18,7 +19,7 @@ interface Props {
} }
export const ResetPasswordCode = ({ email, code, link }: Props) => { export const ResetPasswordCode = ({ email, code, link }: Props) => {
const previewText = `Reset your password, ${email}`; const previewText = `Your password reset code is ${code}`;
return ( return (
<Html> <Html>
@ -37,15 +38,7 @@ export const ResetPasswordCode = ({ email, code, link }: Props) => {
> >
<Body className="font-sans"> <Body className="font-sans">
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg"> <Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
<div className="flex items-center justify-between"> <LetterHead />
<div className="text-sm font-bold text-orange-500">
Pangolin
</div>
<div className="text-sm text-gray-500">
{new Date().toLocaleDateString()}
</div>
</div>
<Heading className="text-2xl font-semibold text-gray-800 text-center"> <Heading className="text-2xl font-semibold text-gray-800 text-center">
Password Reset Request Password Reset Request

View file

@ -10,6 +10,7 @@ import {
Tailwind Tailwind
} from "@react-email/components"; } from "@react-email/components";
import * as React from "react"; import * as React from "react";
import LetterHead from "./components/LetterHead";
interface ResourceOTPCodeProps { interface ResourceOTPCodeProps {
email?: string; email?: string;
@ -24,7 +25,7 @@ export const ResourceOTPCode = ({
orgName: organizationName, orgName: organizationName,
otp otp
}: ResourceOTPCodeProps) => { }: ResourceOTPCodeProps) => {
const previewText = `Your one-time password for ${resourceName} is ready!`; const previewText = `Your one-time password for ${resourceName} is ${otp}`;
return ( return (
<Html> <Html>
@ -43,27 +44,18 @@ export const ResourceOTPCode = ({
> >
<Body className="font-sans"> <Body className="font-sans">
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg"> <Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
<div className="flex items-center justify-between"> <LetterHead />
<div className="text-sm font-bold text-orange-500">
Pangolin
</div>
<div className="text-sm text-gray-500">
{new Date().toLocaleDateString()}
</div>
</div>
<Heading className="text-2xl font-semibold text-gray-800 text-center"> <Heading className="text-2xl font-semibold text-gray-800 text-center">
Your One-Time Password Your One-Time Password for {resourceName}
</Heading> </Heading>
<Text className="text-base text-gray-700 mt-4"> <Text className="text-base text-gray-700 mt-4">
Hi {email || "there"}, Hi {email || "there"},
</Text> </Text>
<Text className="text-base text-gray-700 mt-2"> <Text className="text-base text-gray-700 mt-2">
Youve requested a one-time password (OTP) to Youve requested a one-time password to access{" "}
authenticate with the resource{" "}
<strong>{resourceName}</strong> in{" "} <strong>{resourceName}</strong> in{" "}
<strong>{organizationName}</strong>. Use the OTP <strong>{organizationName}</strong>. Use the code
below to complete your authentication: below to complete your authentication:
</Text> </Text>
<Section className="text-center"> <Section className="text-center">

View file

@ -11,6 +11,7 @@ import {
Button Button
} from "@react-email/components"; } from "@react-email/components";
import * as React from "react"; import * as React from "react";
import LetterHead from "./components/LetterHead";
interface SendInviteLinkProps { interface SendInviteLinkProps {
email: string; email: string;
@ -27,7 +28,7 @@ export const SendInviteLink = ({
inviterName, inviterName,
expiresInDays expiresInDays
}: SendInviteLinkProps) => { }: SendInviteLinkProps) => {
const previewText = `${inviterName} invited to join ${orgName}`; const previewText = `${inviterName} invited you to join ${orgName}`;
return ( return (
<Html> <Html>
@ -46,18 +47,10 @@ export const SendInviteLink = ({
> >
<Body className="font-sans"> <Body className="font-sans">
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg"> <Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
<div className="flex items-center justify-between"> <LetterHead />
<div className="text-sm font-bold text-orange-500">
Pangolin
</div>
<div className="text-sm text-gray-500">
{new Date().toLocaleDateString()}
</div>
</div>
<Heading className="text-2xl font-semibold text-gray-800 text-center"> <Heading className="text-2xl font-semibold text-gray-800 text-center">
You're Invite to Join {orgName} Invited to Join {orgName}
</Heading> </Heading>
<Text className="text-base text-gray-700 mt-4"> <Text className="text-base text-gray-700 mt-4">
Hi {email || "there"}, Hi {email || "there"},

View file

@ -10,6 +10,7 @@ import {
Tailwind Tailwind
} from "@react-email/components"; } from "@react-email/components";
import * as React from "react"; import * as React from "react";
import LetterHead from "./components/LetterHead";
interface Props { interface Props {
email: string; email: string;
@ -36,15 +37,7 @@ export const TwoFactorAuthNotification = ({ email, enabled }: Props) => {
> >
<Body className="font-sans"> <Body className="font-sans">
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg"> <Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
<div className="flex items-center justify-between"> <LetterHead />
<div className="text-sm font-bold text-orange-500">
Pangolin
</div>
<div className="text-sm text-gray-500">
{new Date().toLocaleDateString()}
</div>
</div>
<Heading className="text-2xl font-semibold text-gray-800 text-center"> <Heading className="text-2xl font-semibold text-gray-800 text-center">
Two-Factor Authentication{" "} Two-Factor Authentication{" "}
@ -71,10 +64,6 @@ export const TwoFactorAuthNotification = ({ email, enabled }: Props) => {
enabling it to protect your account. enabling it to protect your account.
</Text> </Text>
)} )}
<Text className="text-base text-gray-700 mt-2">
If you did not make this change, please contact our
support team immediately.
</Text>
<Text className="text-sm text-gray-500 mt-6"> <Text className="text-sm text-gray-500 mt-6">
Best regards, Best regards,
<br /> <br />

View file

@ -10,6 +10,7 @@ import {
Tailwind Tailwind
} from "@react-email/components"; } from "@react-email/components";
import * as React from "react"; import * as React from "react";
import LetterHead from "./components/LetterHead";
interface VerifyEmailProps { interface VerifyEmailProps {
username?: string; username?: string;
@ -22,7 +23,7 @@ export const VerifyEmail = ({
verificationCode, verificationCode,
verifyLink verifyLink
}: VerifyEmailProps) => { }: VerifyEmailProps) => {
const previewText = `Verify your email, ${username}`; const previewText = `Your verification code is ${verificationCode}`;
return ( return (
<Html> <Html>
@ -41,15 +42,7 @@ export const VerifyEmail = ({
> >
<Body className="font-sans"> <Body className="font-sans">
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg"> <Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
<div className="flex items-center justify-between"> <LetterHead />
<div className="text-sm font-bold text-orange-500">
Pangolin
</div>
<div className="text-sm text-gray-500">
{new Date().toLocaleDateString()}
</div>
</div>
<Heading className="text-2xl font-semibold text-gray-800 text-center"> <Heading className="text-2xl font-semibold text-gray-800 text-center">
Please Verify Your Email Please Verify Your Email

View file

@ -0,0 +1,36 @@
import React from "react";
export function LetterHead() {
return (
<table
role="presentation"
width="100%"
style={{
marginBottom: "24px"
}}
>
<tr>
<td
style={{
fontSize: "14px",
fontWeight: "bold",
color: "#F97317"
}}
>
Pangolin
</td>
<td
style={{
fontSize: "14px",
textAlign: "right",
color: "#6B7280"
}}
>
{new Date().getFullYear()}
</td>
</tr>
</table>
);
}
export default LetterHead;

View file

@ -84,6 +84,15 @@ export async function signup(
createHttpError(HttpCode.BAD_REQUEST, "Invite does not exist") createHttpError(HttpCode.BAD_REQUEST, "Invite does not exist")
); );
} }
if (existingInvite.email !== email) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invite is not for this user"
)
);
}
} }
try { try {

View file

@ -137,7 +137,7 @@ authenticated.post(
verifyUserHasAction(ActionsEnum.inviteUser), verifyUserHasAction(ActionsEnum.inviteUser),
user.inviteUser user.inviteUser
); // maybe make this /invite/create instead ); // maybe make this /invite/create instead
authenticated.post("/invite/accept", user.acceptInvite); unauthenticated.post("/invite/accept", user.acceptInvite); // this is supposed to be unauthenticated
authenticated.get( authenticated.get(
"/resource/:resourceId/roles", "/resource/:resourceId/roles",

View file

@ -12,6 +12,7 @@ import { fromError } from "zod-validation-error";
import { isWithinExpirationDate } from "oslo"; import { isWithinExpirationDate } from "oslo";
import { verifyPassword } from "@server/auth/password"; import { verifyPassword } from "@server/auth/password";
import { checkValidInvite } from "@server/auth/checkValidInvite"; import { checkValidInvite } from "@server/auth/checkValidInvite";
import { verifySession } from "@server/auth";
const acceptInviteBodySchema = z const acceptInviteBodySchema = z
.object({ .object({
@ -72,7 +73,9 @@ export async function acceptInvite(
); );
} }
if (req.user && req.user.email !== existingInvite.email) { const { user, session } = await verifySession(req);
if (user && user.email !== existingInvite.email) {
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,

View file

@ -304,8 +304,8 @@ export default function ReverseProxyTargets(props: {
{row.original.method} {row.original.method}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="http">http</SelectItem> <SelectItem value="http">HTTP</SelectItem>
<SelectItem value="https">https</SelectItem> <SelectItem value="https">HTTPS</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
) )

View file

@ -5,19 +5,34 @@ import {
CardContent, CardContent,
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle
} from "@/components/ui/card"; } from "@/components/ui/card";
import { createApiClient } from "@app/api";
import LoginForm from "@app/components/LoginForm"; import LoginForm from "@app/components/LoginForm";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect } from "react";
type DashboardLoginFormProps = { type DashboardLoginFormProps = {
redirect?: string; redirect?: string;
}; };
export default function DashboardLoginForm({ export default function DashboardLoginForm({
redirect, redirect
}: DashboardLoginFormProps) { }: DashboardLoginFormProps) {
const router = useRouter(); const router = useRouter();
// const api = createApiClient(useEnvContext());
//
// useEffect(() => {
// const logout = async () => {
// try {
// await api.post("/auth/logout");
// console.log("user logged out");
// } catch (e) {}
// };
//
// logout();
// });
return ( return (
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">

View file

@ -20,10 +20,6 @@ export default async function InvitePage(props: {
const user = await verifySession(); const user = await verifySession();
if (!user) {
redirect(`/auth/signup?redirect=/invite?token=${params.token}`);
}
const parts = tokenParam.split("-"); const parts = tokenParam.split("-");
if (parts.length !== 2) { if (parts.length !== 2) {
return ( return (
@ -70,9 +66,17 @@ export default async function InvitePage(props: {
} }
} }
const type = cardType();
console.log("card type is", type, error)
if (!user && type === "user_does_not_exist") {
redirect(`/auth/signup?redirect=/invite?token=${params.token}`);
}
return ( return (
<> <>
<InviteStatusCard type={cardType()} token={tokenParam} /> <InviteStatusCard type={type} token={tokenParam} />
</> </>
); );
} }