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", {
userId: text("userId")
.notNull()
.references(() => users.userId),
.references(() => users.userId, { onDelete: "cascade" }),
orgId: text("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
@ -395,4 +395,4 @@ export type ResourcePassword = InferSelectModel<typeof resourcePassword>;
export type ResourceOtp = InferSelectModel<typeof resourceOtp>;
export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
export type VersionMigration = InferSelectModel<typeof versionMigrations>;

View file

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

View file

@ -10,6 +10,7 @@ import {
Tailwind
} from "@react-email/components";
import * as React from "react";
import LetterHead from "./components/LetterHead";
interface Props {
email: string;
@ -35,15 +36,7 @@ export const ConfirmPasswordReset = ({ email }: Props) => {
>
<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">
<div className="flex items-center justify-between">
<div className="text-sm font-bold text-orange-500">
Pangolin
</div>
<div className="text-sm text-gray-500">
{new Date().toLocaleDateString()}
</div>
</div>
<LetterHead />
<Heading className="text-2xl font-semibold text-gray-800 text-center">
Password Reset Confirmation
@ -56,10 +49,6 @@ export const ConfirmPasswordReset = ({ email }: Props) => {
reset. If you made this change, no further action is
required.
</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">
Thank you for keeping your account secure.
</Text>

View file

@ -10,6 +10,7 @@ import {
Tailwind
} from "@react-email/components";
import * as React from "react";
import LetterHead from "./components/LetterHead";
interface Props {
email: string;
@ -18,7 +19,7 @@ interface Props {
}
export const ResetPasswordCode = ({ email, code, link }: Props) => {
const previewText = `Reset your password, ${email}`;
const previewText = `Your password reset code is ${code}`;
return (
<Html>
@ -37,15 +38,7 @@ export const ResetPasswordCode = ({ email, code, link }: Props) => {
>
<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">
<div className="flex items-center justify-between">
<div className="text-sm font-bold text-orange-500">
Pangolin
</div>
<div className="text-sm text-gray-500">
{new Date().toLocaleDateString()}
</div>
</div>
<LetterHead />
<Heading className="text-2xl font-semibold text-gray-800 text-center">
Password Reset Request

View file

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

View file

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

View file

@ -10,6 +10,7 @@ import {
Tailwind
} from "@react-email/components";
import * as React from "react";
import LetterHead from "./components/LetterHead";
interface Props {
email: string;
@ -36,15 +37,7 @@ export const TwoFactorAuthNotification = ({ email, enabled }: Props) => {
>
<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">
<div className="flex items-center justify-between">
<div className="text-sm font-bold text-orange-500">
Pangolin
</div>
<div className="text-sm text-gray-500">
{new Date().toLocaleDateString()}
</div>
</div>
<LetterHead />
<Heading className="text-2xl font-semibold text-gray-800 text-center">
Two-Factor Authentication{" "}
@ -71,10 +64,6 @@ export const TwoFactorAuthNotification = ({ email, enabled }: Props) => {
enabling it to protect your account.
</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">
Best regards,
<br />

View file

@ -10,6 +10,7 @@ import {
Tailwind
} from "@react-email/components";
import * as React from "react";
import LetterHead from "./components/LetterHead";
interface VerifyEmailProps {
username?: string;
@ -22,7 +23,7 @@ export const VerifyEmail = ({
verificationCode,
verifyLink
}: VerifyEmailProps) => {
const previewText = `Verify your email, ${username}`;
const previewText = `Your verification code is ${verificationCode}`;
return (
<Html>
@ -41,15 +42,7 @@ export const VerifyEmail = ({
>
<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">
<div className="flex items-center justify-between">
<div className="text-sm font-bold text-orange-500">
Pangolin
</div>
<div className="text-sm text-gray-500">
{new Date().toLocaleDateString()}
</div>
</div>
<LetterHead />
<Heading className="text-2xl font-semibold text-gray-800 text-center">
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")
);
}
if (existingInvite.email !== email) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invite is not for this user"
)
);
}
}
try {

View file

@ -137,7 +137,7 @@ authenticated.post(
verifyUserHasAction(ActionsEnum.inviteUser),
user.inviteUser
); // 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(
"/resource/:resourceId/roles",

View file

@ -12,6 +12,7 @@ import { fromError } from "zod-validation-error";
import { isWithinExpirationDate } from "oslo";
import { verifyPassword } from "@server/auth/password";
import { checkValidInvite } from "@server/auth/checkValidInvite";
import { verifySession } from "@server/auth";
const acceptInviteBodySchema = z
.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(
createHttpError(
HttpCode.BAD_REQUEST,

View file

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

View file

@ -5,19 +5,34 @@ import {
CardContent,
CardDescription,
CardHeader,
CardTitle,
CardTitle
} from "@/components/ui/card";
import { createApiClient } from "@app/api";
import LoginForm from "@app/components/LoginForm";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
type DashboardLoginFormProps = {
redirect?: string;
};
export default function DashboardLoginForm({
redirect,
redirect
}: DashboardLoginFormProps) {
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 (
<Card className="w-full max-w-md">

View file

@ -20,10 +20,6 @@ export default async function InvitePage(props: {
const user = await verifySession();
if (!user) {
redirect(`/auth/signup?redirect=/invite?token=${params.token}`);
}
const parts = tokenParam.split("-");
if (parts.length !== 2) {
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 (
<>
<InviteStatusCard type={cardType()} token={tokenParam} />
<InviteStatusCard type={type} token={tokenParam} />
</>
);
}